Split components, add sessions to the DB and make the apercu page

This commit is contained in:
Arthur Belleville 2026-05-08 16:03:54 +02:00
parent 0a38442d88
commit c4eb878b0e
No known key found for this signature in database
22 changed files with 4748 additions and 617 deletions

11
.zed/settings.json Normal file
View file

@ -0,0 +1,11 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"languages": {
"Go": {
"language_servers": ["gopls"],
},
},
}

View file

@ -10,7 +10,7 @@ INSERT INTO auth.users (
$1,
$2,
$3,
jsonb_build_object('display_name', sqlc.arg(display_name)),
jsonb_build_object('display_name', sqlc.arg(display_name)::text),
now(),
now()
)
@ -27,3 +27,30 @@ SELECT id, email, created_at, updated_at, display_name
FROM public.users
WHERE id = $1
LIMIT 1;
-- name: CreateSession :exec
INSERT INTO auth.sessions (
id,
session_token,
user_id,
created_at,
updated_at,
expires_at
) VALUES (
$1,
$2,
$3,
now(),
now(),
$4
);
-- name: GetSessionByToken :one
SELECT id, session_token, user_id, created_at, updated_at, expires_at
FROM auth.sessions
WHERE session_token = $1
LIMIT 1;
-- name: DeleteSessionByToken :execrows
DELETE FROM auth.sessions
WHERE session_token = $1;

View file

@ -4,10 +4,12 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
@ -100,3 +102,46 @@ func (r *PostgresAuthRepository) GetPublicUserByID(ctx context.Context, id uuid.
UpdatedAt: row.UpdatedAt.Time,
}, nil
}
func (r *PostgresAuthRepository) CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error {
err := r.queries.CreateSession(ctx, sqlcdb.CreateSessionParams{
ID: uuid.New(),
SessionToken: token,
UserID: userID,
ExpiresAt: pgtypeTimestamptz(expiresAt),
})
return err
}
func (r *PostgresAuthRepository) GetSessionByToken(ctx context.Context, token string) (handlers.Session, error) {
row, err := r.queries.GetSessionByToken(ctx, token)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return handlers.Session{}, handlers.ErrSessionNotFound
}
return handlers.Session{}, err
}
return handlers.Session{
Token: row.SessionToken,
UserID: row.UserID,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
ExpiresAt: row.ExpiresAt.Time,
}, nil
}
func (r *PostgresAuthRepository) DeleteSessionByToken(ctx context.Context, token string) error {
rows, err := r.queries.DeleteSessionByToken(ctx, token)
if err != nil {
return err
}
if rows == 0 {
return handlers.ErrSessionNotFound
}
return nil
}
func pgtypeTimestamptz(value time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: value, Valid: true}
}

View file

@ -17,6 +17,17 @@ CREATE TABLE IF NOT EXISTS public.users (
display_name text NOT NULL
);
CREATE TABLE IF NOT EXISTS auth.sessions (
id uuid PRIMARY KEY,
session_token text NOT NULL UNIQUE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth.sessions(user_id);
CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER

View file

@ -9,6 +9,15 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type AuthSession struct {
ID uuid.UUID `db:"id"`
SessionToken string `db:"session_token"`
UserID uuid.UUID `db:"user_id"`
CreatedAt pgtype.Timestamptz `db:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
ExpiresAt pgtype.Timestamptz `db:"expires_at"`
}
type AuthUser struct {
ID uuid.UUID `db:"id"`
Email string `db:"email"`

View file

@ -12,8 +12,11 @@ import (
type Querier interface {
CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error)
CreateSession(ctx context.Context, arg CreateSessionParams) error
DeleteSessionByToken(ctx context.Context, sessionToken string) (int64, error)
GetAuthUserByEmail(ctx context.Context, email string) (GetAuthUserByEmailRow, error)
GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, error)
GetSessionByToken(ctx context.Context, sessionToken string) (AuthSession, error)
}
var _ Querier = (*Queries)(nil)

View file

@ -24,7 +24,7 @@ INSERT INTO auth.users (
$1,
$2,
$3,
jsonb_build_object('display_name', $4),
jsonb_build_object('display_name', $4::text),
now(),
now()
)
@ -32,10 +32,10 @@ RETURNING id
`
type CreateAuthUserParams struct {
ID uuid.UUID `db:"id"`
Email string `db:"email"`
EncryptedPassword string `db:"encrypted_password"`
DisplayName interface{} `db:"display_name"`
ID uuid.UUID `db:"id"`
Email string `db:"email"`
EncryptedPassword string `db:"encrypted_password"`
DisplayName string `db:"display_name"`
}
func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (uuid.UUID, error) {
@ -50,6 +50,54 @@ func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams)
return id, err
}
const createSession = `-- name: CreateSession :exec
INSERT INTO auth.sessions (
id,
session_token,
user_id,
created_at,
updated_at,
expires_at
) VALUES (
$1,
$2,
$3,
now(),
now(),
$4
)
`
type CreateSessionParams struct {
ID uuid.UUID `db:"id"`
SessionToken string `db:"session_token"`
UserID uuid.UUID `db:"user_id"`
ExpiresAt pgtype.Timestamptz `db:"expires_at"`
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
_, err := q.db.Exec(ctx, createSession,
arg.ID,
arg.SessionToken,
arg.UserID,
arg.ExpiresAt,
)
return err
}
const deleteSessionByToken = `-- name: DeleteSessionByToken :execrows
DELETE FROM auth.sessions
WHERE session_token = $1
`
func (q *Queries) DeleteSessionByToken(ctx context.Context, sessionToken string) (int64, error) {
result, err := q.db.Exec(ctx, deleteSessionByToken, sessionToken)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const getAuthUserByEmail = `-- name: GetAuthUserByEmail :one
SELECT id, email, encrypted_password, created_at, updated_at
FROM auth.users
@ -97,3 +145,24 @@ func (q *Queries) GetPublicUserByID(ctx context.Context, id uuid.UUID) (User, er
)
return i, err
}
const getSessionByToken = `-- name: GetSessionByToken :one
SELECT id, session_token, user_id, created_at, updated_at, expires_at
FROM auth.sessions
WHERE session_token = $1
LIMIT 1
`
func (q *Queries) GetSessionByToken(ctx context.Context, sessionToken string) (AuthSession, error) {
row := q.db.QueryRow(ctx, getSessionByToken, sessionToken)
var i AuthSession
err := row.Scan(
&i.ID,
&i.SessionToken,
&i.UserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.ExpiresAt,
)
return i, err
}

View file

@ -7,7 +7,6 @@ import (
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
@ -15,17 +14,24 @@ import (
"golang.org/x/crypto/bcrypt"
"xtablo-backend/internal/web/views"
"github.com/a-h/templ"
)
const sessionCookieName = "xtablo_session"
const sessionLifetime = 30 * 24 * time.Hour
var ErrUserNotFound = errors.New("user not found")
var ErrUserAlreadyExists = errors.New("user already exists")
var ErrSessionNotFound = errors.New("session not found")
type AuthRepository interface {
CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error)
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error)
CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error
GetSessionByToken(ctx context.Context, token string) (Session, error)
DeleteSessionByToken(ctx context.Context, token string) error
}
type CreateAuthUserInput struct {
@ -50,82 +56,134 @@ type PublicUser struct {
UpdatedAt time.Time
}
type Session struct {
Token string
UserID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
type AuthHandler struct {
repo AuthRepository
sessions *sessionStore
}
type sessionStore struct {
mu sync.RWMutex
sessions map[string]uuid.UUID
}
type InMemoryAuthRepository struct {
mu sync.RWMutex
authUsers map[string]AuthUser
publicUsers map[uuid.UUID]PublicUser
repo AuthRepository
}
func NewAuthHandler(repo AuthRepository) *AuthHandler {
return &AuthHandler{
repo: repo,
sessions: &sessionStore{sessions: map[string]uuid.UUID{}},
}
}
func NewInMemoryAuthRepository() *InMemoryAuthRepository {
repo := &InMemoryAuthRepository{
authUsers: map[string]AuthUser{},
publicUsers: map[uuid.UUID]PublicUser{},
}
demoHash, err := hashPassword("xtablo-demo")
if err != nil {
panic(err)
}
if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
Email: "demo@xtablo.com",
EncryptedPassword: demoHash,
DisplayName: "demo",
}); err != nil {
panic(err)
}
return repo
return &AuthHandler{repo: repo}
}
func (h *AuthHandler) GetHome() http.HandlerFunc {
return h.renderAppPage("/", func(user PublicUser) templ.Component {
return views.OverviewMainContent(user.DisplayName, user.Email)
})
}
func (h *AuthHandler) GetTasksPage() http.HandlerFunc {
return h.renderAppPage("/tasks", func(user PublicUser) templ.Component {
return views.TasksMainContent()
})
}
func (h *AuthHandler) GetTablosPage() http.HandlerFunc {
return h.renderAppPage("/tablos", func(user PublicUser) templ.Component {
return views.TablosMainContent()
})
}
func (h *AuthHandler) GetPlanningPage() http.HandlerFunc {
return h.renderAppPage("/planning", func(user PublicUser) templ.Component {
return views.PlanningMainContent()
})
}
func (h *AuthHandler) GetChatPage() http.HandlerFunc {
return h.renderAppPage("/chat", func(user PublicUser) templ.Component {
return views.ChatMainContent()
})
}
func (h *AuthHandler) GetFilesPage() http.HandlerFunc {
return h.renderAppPage("/files", func(user PublicUser) templ.Component {
return views.FilesMainContent()
})
}
func (h *AuthHandler) GetFeedbackPage() http.HandlerFunc {
return h.renderAppPage("/feedback", func(user PublicUser) templ.Component {
return views.FeedbackMainContent()
})
}
func (h *AuthHandler) GetNotFound() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.currentUserID(r)
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user, err := h.repo.GetPublicUserByID(r.Context(), userID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
content := views.NotFoundContent(user.DisplayName)
var err error
if isHXRequest(r) {
err = views.DashboardContentSwap("", content).Render(r.Context(), w)
} else {
err = views.DashboardPage("", content).Render(r.Context(), w)
}
if err != nil {
http.Error(w, "failed to render not found page", http.StatusInternalServerError)
}
}
}
func (h *AuthHandler) renderAppPage(activePath string, content func(user PublicUser) templ.Component) 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
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.HomePage(user.DisplayName, user.Email).Render(r.Context(), w); err != nil {
http.Error(w, "failed to render home page", http.StatusInternalServerError)
pageContent := content(user)
var err error
if isHXRequest(r) {
err = views.DashboardContentSwap(activePath, pageContent).Render(r.Context(), w)
} else {
err = views.DashboardPage(activePath, pageContent).Render(r.Context(), w)
}
if err != nil {
http.Error(w, "failed to render app page", http.StatusInternalServerError)
}
}
}
func (h *AuthHandler) authenticatedUser(ctx context.Context, r *http.Request) (PublicUser, bool) {
userID, ok := h.currentUserID(ctx, r)
if !ok {
return PublicUser{}, false
}
user, err := h.repo.GetPublicUserByID(ctx, userID)
if err != nil {
return PublicUser{}, false
}
return user, true
}
func (h *AuthHandler) PostLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var email string
if userID, ok := h.currentUserID(r); ok {
if userID, ok := h.currentUserID(r.Context(), r); ok {
if user, err := h.repo.GetPublicUserByID(r.Context(), userID); err == nil {
email = user.Email
}
}
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
h.sessions.delete(cookie.Value, email)
_ = h.repo.DeleteSessionByToken(r.Context(), cookie.Value)
logStoreMutation("delete_session", email, cookie.Value, 0, 0)
}
http.SetCookie(w, &http.Cookie{
@ -142,13 +200,13 @@ func (h *AuthHandler) PostLogout() http.HandlerFunc {
func (h *AuthHandler) GetLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := h.currentUserID(r); ok {
if _, ok := h.currentUserID(r.Context(), r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.AuthPage(views.LoginScreen()).Render(r.Context(), w); err != nil {
if err := views.LoginPage().Render(r.Context(), w); err != nil {
http.Error(w, "failed to render login page", http.StatusInternalServerError)
}
}
@ -156,13 +214,13 @@ func (h *AuthHandler) GetLoginPage() http.HandlerFunc {
func (h *AuthHandler) GetSignupPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := h.currentUserID(r); ok {
if _, ok := h.currentUserID(r.Context(), r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.AuthPage(views.SignupScreen()).Render(r.Context(), w); err != nil {
if err := views.SignupPage().Render(r.Context(), w); err != nil {
http.Error(w, "failed to render signup page", http.StatusInternalServerError)
}
}
@ -194,7 +252,7 @@ func (h *AuthHandler) PostLogin() http.HandlerFunc {
return
}
h.setSession(w, authUser.ID, authUser.Email)
h.setSession(r.Context(), w, authUser.ID, authUser.Email)
w.Header().Set("HX-Redirect", "/")
_ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w)
}
@ -246,110 +304,51 @@ func (h *AuthHandler) PostSignup() http.HandlerFunc {
_ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w)
return
}
http.Error(w, "failed to create user", http.StatusInternalServerError)
log.Err(err).Msg("failed to create user")
w.WriteHeader(http.StatusInternalServerError)
_ = views.AuthStatus("error", "Un problème est survenu.").Render(r.Context(), w)
return
}
h.setSession(w, userID, email)
h.setSession(r.Context(), w, userID, email)
w.Header().Set("HX-Redirect", "/")
_ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w)
}
}
func (h *AuthHandler) currentUserID(r *http.Request) (uuid.UUID, bool) {
func (h *AuthHandler) currentUserID(ctx context.Context, r *http.Request) (uuid.UUID, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return uuid.Nil, false
}
return h.sessions.get(cookie.Value)
session, err := h.repo.GetSessionByToken(ctx, cookie.Value)
if err != nil {
return uuid.Nil, false
}
if session.ExpiresAt.Before(time.Now().UTC()) {
_ = h.repo.DeleteSessionByToken(ctx, cookie.Value)
return uuid.Nil, false
}
return session.UserID, true
}
func (h *AuthHandler) setSession(w http.ResponseWriter, userID uuid.UUID, email string) {
func (h *AuthHandler) setSession(ctx context.Context, w http.ResponseWriter, userID uuid.UUID, email string) {
sessionID := randomToken(32)
h.sessions.set(sessionID, userID, email)
expiresAt := time.Now().UTC().Add(sessionLifetime)
if err := h.repo.CreateSession(ctx, sessionID, userID, expiresAt); err != nil {
panic(err)
}
logStoreMutation("create_session", email, sessionID, 0, 0)
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (s *sessionStore) get(sessionID string) (uuid.UUID, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
userID, ok := s.sessions[sessionID]
return userID, ok
}
func (s *sessionStore) set(sessionID string, userID uuid.UUID, email string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[sessionID] = userID
logStoreMutation("create_session", email, sessionID, 0, len(s.sessions))
}
func (s *sessionStore) delete(sessionID string, email string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, sessionID)
logStoreMutation("delete_session", email, sessionID, 0, len(s.sessions))
}
func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.authUsers[input.Email]; exists {
return uuid.Nil, ErrUserAlreadyExists
}
id := uuid.New()
now := time.Now().UTC()
authUser := AuthUser{
ID: id,
Email: input.Email,
EncryptedPassword: input.EncryptedPassword,
CreatedAt: now,
UpdatedAt: now,
}
publicUser := PublicUser{
ID: id,
Email: input.Email,
DisplayName: input.DisplayName,
CreatedAt: now,
UpdatedAt: now,
}
r.authUsers[input.Email] = authUser
r.publicUsers[id] = publicUser
logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0)
return id, nil
}
func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.authUsers[email]
if !ok {
return AuthUser{}, ErrUserNotFound
}
return user, nil
}
func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.publicUsers[id]
if !ok {
return PublicUser{}, ErrUserNotFound
}
return user, nil
}
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
@ -394,3 +393,7 @@ func logStoreMutation(action string, email string, sessionID string, usersCount
event.Msg("auth store mutated")
}
func isHXRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}

View file

@ -0,0 +1,132 @@
package handlers
import (
"context"
"sync"
"time"
"github.com/google/uuid"
)
// InMemoryAuthRepository exists only as test support.
// It must not be used as a production auth/session store.
type InMemoryAuthRepository struct {
mu sync.RWMutex
authUsers map[string]AuthUser
publicUsers map[uuid.UUID]PublicUser
sessions map[string]Session
}
// NewInMemoryAuthRepository creates a testing-only auth repository.
// Use the Postgres-backed repository in real application flows.
func NewInMemoryAuthRepository() *InMemoryAuthRepository {
repo := &InMemoryAuthRepository{
authUsers: map[string]AuthUser{},
publicUsers: map[uuid.UUID]PublicUser{},
sessions: map[string]Session{},
}
demoHash, err := hashPassword("xtablo-demo")
if err != nil {
panic(err)
}
if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
Email: "demo@xtablo.com",
EncryptedPassword: demoHash,
DisplayName: "demo",
}); err != nil {
panic(err)
}
return repo
}
func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.authUsers[input.Email]; exists {
return uuid.Nil, ErrUserAlreadyExists
}
id := uuid.New()
now := time.Now().UTC()
authUser := AuthUser{
ID: id,
Email: input.Email,
EncryptedPassword: input.EncryptedPassword,
CreatedAt: now,
UpdatedAt: now,
}
publicUser := PublicUser{
ID: id,
Email: input.Email,
DisplayName: input.DisplayName,
CreatedAt: now,
UpdatedAt: now,
}
r.authUsers[input.Email] = authUser
r.publicUsers[id] = publicUser
logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0)
return id, nil
}
func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.authUsers[email]
if !ok {
return AuthUser{}, ErrUserNotFound
}
return user, nil
}
func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.publicUsers[id]
if !ok {
return PublicUser{}, ErrUserNotFound
}
return user, nil
}
func (r *InMemoryAuthRepository) CreateSession(_ context.Context, token string, userID uuid.UUID, expiresAt time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
r.sessions[token] = Session{
Token: token,
UserID: userID,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: expiresAt,
}
return nil
}
func (r *InMemoryAuthRepository) GetSessionByToken(_ context.Context, token string) (Session, error) {
r.mu.RLock()
defer r.mu.RUnlock()
session, ok := r.sessions[token]
if !ok {
return Session{}, ErrSessionNotFound
}
return session, nil
}
func (r *InMemoryAuthRepository) DeleteSessionByToken(_ context.Context, token string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.sessions[token]; !ok {
return ErrSessionNotFound
}
delete(r.sessions, token)
return nil
}

View file

@ -1,146 +1,73 @@
package views
templ AuthPage(content templ.Component) {
<!doctype html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/png" sizes="32x32" href="/pwa-icons/favicon-32x32.png"/>
<link rel="icon" type="image/png" sizes="16x16" href="/pwa-icons/favicon-16x16.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/pwa-icons/apple-touch-icon-180x180.png"/>
<link rel="manifest" href="/manifest.webmanifest"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<meta name="theme-color" content="#1e1b2e"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
<title>XTablo</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
<link rel="stylesheet" href="/static/styles.css"/>
</head>
<body>
<div id="root">
<section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite" aria-relevant="additions text" aria-atomic="false"></section>
<div class="app-shell">
<div class="login-screen">
@AnimatedBackground()
<div class="card-wrap">
<div class="card-glow"></div>
<div data-testid="auth-card-shell" class="auth-card-shell">
<div class="auth-card-topbar">
<div>
<a href="https://www.xtablo.com" class="back-home-link">
<svg class="back-home-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Retour à l'accueil
</a>
</div>
<button class="theme-toggle-button" aria-label="change theme (system)" type="button">
<svg xmlns="http://www.w3.org/2000/svg" class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
<line x1="8" x2="16" y1="21" y2="21"></line>
<line x1="12" x2="12" y1="17" y2="21"></line>
</svg>
</button>
</div>
<div class="brand-header">
<img alt="Xtablo" class="brand-logo light-only" src="/logo_dark.png"/>
<img alt="Xtablo" class="brand-logo dark-only" src="/logo_white.png"/>
</div>
@content
</div>
</div>
</div>
</div>
</div>
</body>
</html>
}
templ LoginScreen() {
<div class="title-group">
<h1>Se connecter à Xtablo</h1>
</div>
<div class="new-experience-link-wrap">
<a class="new-experience-link" href="/login-v2">Découvrez la nouvelle expérience de connexion</a>
</div>
<div class="auth-body">
<form class="login-form" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML">
<div id="login-status" class="status-slot"></div>
<div class="field-stack">
<label for="email">Email *</label>
<input id="email" name="email" type="email" placeholder="Votre email" required/>
</div>
<div class="field-stack">
<label for="password">Mot de passe *</label>
<input id="password" name="password" type="password" placeholder="Votre mot de passe" required/>
</div>
<div class="forgot-password-row">
<a class="forgot-password-link" href="/reset-password">Mot de passe oublié ?</a>
</div>
<button class="submit-button" type="submit">Se connecter</button>
</form>
@AuthDivider()
@GoogleButton()
<p class="signup-copy">
Pas encore de compte ?
<a class="signup-link" href="/signup">S'inscrire</a>
</p>
</div>
@AuthScreen(
"Se connecter à Xtablo",
"/login-v2",
"Découvrez la nouvelle expérience de connexion",
LoginForm(),
AuthScreenFooter("Pas encore de compte ?", "/signup", "S'inscrire"),
)
}
templ SignupScreen() {
@AuthScreen(
"S'inscrire à Xtablo",
"/login",
"Vous avez déjà un compte ?",
SignupForm(),
AuthScreenFooter("Vous avez déjà un compte ?", "/login", "Se connecter"),
)
}
templ AuthScreen(title string, helperHref string, helperLabel string, form templ.Component, footer templ.Component) {
<div class="title-group">
<h1>S'inscrire à Xtablo</h1>
<h1>{ title }</h1>
</div>
<div class="new-experience-link-wrap">
<a class="new-experience-link" href="/login">Vous avez déjà un compte ?</a>
<a class="new-experience-link" href={ templ.SafeURL(helperHref) }>{ helperLabel }</a>
</div>
<div class="auth-body">
<form class="login-form" hx-post="/signup" hx-target="#signup-status" hx-swap="innerHTML">
<div id="signup-status" class="status-slot"></div>
<div class="field-stack">
<label for="signup-email">Email *</label>
<input id="signup-email" name="email" type="email" placeholder="Votre email" required/>
</div>
<div class="field-stack">
<label for="signup-password">Mot de passe *</label>
<input id="signup-password" name="password" type="password" placeholder="Choisissez un mot de passe" required/>
</div>
<button class="submit-button" type="submit">Créer mon compte</button>
</form>
@form
@AuthDivider()
@GoogleButton()
<p class="signup-copy">
Vous avez déjà un compte ?
<a class="signup-link" href="/login">Se connecter</a>
</p>
@footer
</div>
}
templ HomePage(displayName string, email string) {
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>XTablo</title>
<link rel="stylesheet" href="/static/styles.css"/>
</head>
<body>
<main class="home-shell">
<section class="home-card">
<img alt="Xtablo" class="home-logo" src="/logo_dark.png"/>
<h1>Bienvenue</h1>
<p>{ displayName }</p>
<p>Session active pour { email }</p>
<form action="/logout" method="post" class="logout-form">
<button type="submit" class="submit-button logout-button">Se déconnecter</button>
</form>
</section>
</main>
</body>
</html>
templ LoginForm() {
<form class="login-form" hx-post="/login" hx-target="#login-status" hx-swap="innerHTML">
<div id="login-status" class="status-slot"></div>
@AuthField("email", "email", "Email *", "email", "Votre email")
@AuthField("password", "password", "Mot de passe *", "password", "Votre mot de passe")
<div class="forgot-password-row">
<a class="forgot-password-link" href="/reset-password">Mot de passe oublié ?</a>
</div>
<button class="submit-button" type="submit">Se connecter</button>
</form>
}
templ SignupForm() {
<form class="login-form" hx-post="/signup" hx-target="#signup-status" hx-swap="innerHTML">
<div id="signup-status" class="status-slot"></div>
@AuthField("signup-email", "email", "Email *", "email", "Votre email")
@AuthField("signup-password", "password", "Mot de passe *", "password", "Choisissez un mot de passe")
<button class="submit-button" type="submit">Créer mon compte</button>
</form>
}
templ AuthField(fieldID string, fieldName string, fieldLabel string, inputType string, placeholder string) {
<div class="field-stack">
<label for={ fieldID }>{ fieldLabel }</label>
<input id={ fieldID } name={ fieldName } type={ inputType } placeholder={ placeholder } required/>
</div>
}
templ AuthScreenFooter(copy string, href string, label string) {
<p class="signup-copy">
{ copy }
<a class="signup-link" href={ templ.SafeURL(href) }>{ label }</a>
</p>
}
templ AuthStatus(kind string, message string) {
@ -164,7 +91,7 @@ templ GoogleButton() {
<div class="gsi-material-button-state"></div>
<div class="gsi-material-button-content-wrapper">
<div class="gsi-material-button-icon">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" xmlns:xlink="http://www.w3.org/1999/xlink" style="display: block;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
@ -180,10 +107,22 @@ templ GoogleButton() {
templ AnimatedBackground() {
<div class="background-layer" aria-hidden="true">
<div class="background-logo bg-01 animate-move-right-slow opacity-04"><img alt="Xtablo" class="logo-asset size-16 animate-spin-slow light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-16 animate-spin-slow dark-only" src="/logo_white.png"/></div>
<div class="background-logo bg-02 animate-move-right-medium opacity-03"><img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle dark-only" src="/logo_white.png"/></div>
<div class="background-logo bg-03 animate-move-right-fast opacity-05"><img alt="Xtablo" class="logo-asset size-20 animate-pulse-gentle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-20 animate-pulse-gentle dark-only" src="/logo_white.png"/></div>
<div class="background-logo bg-04 animate-move-right-slow opacity-02"><img alt="Xtablo" class="logo-asset size-14 animate-wiggle light-only" src="/logo_dark.png"/><img alt="Xtablo" class="logo-asset size-14 animate-wiggle dark-only" src="/logo_white.png"/></div>
<div class="background-logo bg-01 animate-move-right-slow opacity-04">
<img alt="Xtablo" class="logo-asset size-16 animate-spin-slow light-only" src="/logo_dark.png"/>
<img alt="Xtablo" class="logo-asset size-16 animate-spin-slow dark-only" src="/logo_white.png"/>
</div>
<div class="background-logo bg-02 animate-move-right-medium opacity-03">
<img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle light-only" src="/logo_dark.png"/>
<img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle dark-only" src="/logo_white.png"/>
</div>
<div class="background-logo bg-03 animate-move-right-fast opacity-05">
<img alt="Xtablo" class="logo-asset size-20 animate-pulse-gentle light-only" src="/logo_dark.png"/>
<img alt="Xtablo" class="logo-asset size-20 animate-pulse-gentle dark-only" src="/logo_white.png"/>
</div>
<div class="background-logo bg-04 animate-move-right-slow opacity-02">
<img alt="Xtablo" class="logo-asset size-14 animate-wiggle light-only" src="/logo_dark.png"/>
<img alt="Xtablo" class="logo-asset size-14 animate-wiggle dark-only" src="/logo_white.png"/>
</div>
<div class="background-logo bg-05 animate-move-right-medium opacity-03"><img alt="Xtablo" class="logo-asset size-18 animate-float-gentle" src="/logo_dark.png"/></div>
<div class="background-logo bg-06 animate-move-diagonal-1 opacity-03"><img alt="Xtablo" class="logo-asset size-10 animate-spin-reverse" src="/logo_dark.png"/></div>
<div class="background-logo bg-07 animate-move-diagonal-2 opacity-04"><img alt="Xtablo" class="logo-asset size-16 animate-scale-gentle" src="/logo_dark.png"/></div>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,330 @@
package views
templ DashboardPage(activePath string, content templ.Component) {
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>XTablo</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
<link rel="stylesheet" href="/static/styles.css"/>
</head>
<body>
<div class="dashboard-shell">
@DashboardSidebar(activePath)
@DashboardMainContent(content)
</div>
</body>
</html>
}
templ DashboardNotFoundPage(displayName string, email string) {
@DashboardPage("", NotFoundContent(displayName))
}
templ DashboardMainContent(content templ.Component) {
<main id="app-main-content" class="dashboard-main">
@content
</main>
}
templ DashboardContentSwap(activePath string, content templ.Component) {
@DashboardMainContent(content)
@DashboardNavOOB(activePath)
}
templ DashboardSidebar(activePath string) {
<aside class="dashboard-sidebar">
<nav aria-label="Main navigation" class="sidebar-nav-shell">
<div class="sidebar-brand">
<a class="sidebar-brand-link" aria-label="Home" href="/" hx-get="/" hx-target="#app-main-content" hx-swap="outerHTML" hx-push-url="true">
<img alt="Logo XTablo" class="sidebar-brand-logo" src="/logo_dark.png"/>
<h1 class="sidebar-brand-title">XTablo</h1>
</a>
<button class="sidebar-collapse-button" aria-label="Collapse navigation" aria-expanded="true" type="button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"></path>
</svg>
</button>
</div>
<div class="sidebar-primary">
<ul class="sidebar-list" role="list">
for _, item := range sidebarPrimaryNavItems(activePath) {
<li>@SidebarNavItem(item)
</li>
if item.DividerAfter {
<li class="sidebar-divider"><hr role="separator"/></li>
}
}
</ul>
<div class="sidebar-projects">
<hr role="separator"/>
<div class="sidebar-section-label">Projets</div>
<ul class="sidebar-project-list">
for _, item := range sidebarProjectItems() {
<li>@SidebarProjectItem(item)
</li>
}
</ul>
</div>
<ul class="sidebar-list sidebar-footer-links" role="list">
for _, item := range sidebarFooterNavItems(activePath) {
<li>@SidebarNavItem(item)
</li>
}
</ul>
</div>
@SidebarOrganization()
</nav>
</aside>
}
templ DashboardNavOOB(activePath string) {
for _, item := range sidebarPrimaryNavItems(activePath) {
@SidebarNavItemOOB(item)
}
for _, item := range sidebarFooterNavItems(activePath) {
@SidebarNavItemOOB(item)
}
}
templ SidebarOrganization() {
<div class="sidebar-organization">
<button class="organization-button" aria-label="Organization menu" type="button">
<span class="organization-avatar">
<img alt="Arctic Matrix" src="/logo_dark.png"/>
</span>
<span class="organization-copy">
<span class="organization-name">Arctic Matrix</span>
<span class="organization-meta">1 membre</span>
</span>
</button>
</div>
}
templ OverviewMainContent(displayName string, email string) {
<div class="overview-page">
@OverviewHeader(displayName)
@OverviewActions(overviewQuickActions())
@OverviewProjects(overviewProjects())
@OverviewTasks(overviewTasks())
</div>
}
templ TasksMainContent() {
@AppSectionMainContent("Tâches", "Suivez les tâches de votre équipe, les priorités en cours et ce qui reste à livrer.")
}
templ TablosMainContent() {
@AppSectionMainContent("Projets", "Gardez une vue claire sur vos tablos, leur état d'avancement et les prochaines décisions à prendre.")
}
templ PlanningMainContent() {
@AppSectionMainContent("Planning", "Visualisez le rythme de l'équipe, les jalons à venir et les arbitrages de charge.")
}
templ ChatMainContent() {
@AppSectionMainContent("Discussions", "Retrouvez les conversations importantes, les décisions récentes et les échanges à relancer.")
}
templ FilesMainContent() {
@AppSectionMainContent("Fichiers", "Centralisez les documents utiles, les pièces partagées et les ressources de travail.")
}
templ FeedbackMainContent() {
@AppSectionMainContent("Feedback", "Collectez les retours produit, priorisez les signaux et transformez-les en actions concrètes.")
}
templ AppSectionMainContent(title string, description string) {
<div class="app-section-page">
<div class="app-section-surface">
<div class="app-section-eyebrow">Espace de travail</div>
<h2>{ title }</h2>
<p>{ description }</p>
</div>
</div>
}
templ NotFoundContent(displayName string) {
<div class="not-found-page">
<div class="not-found-surface">
<div class="not-found-eyebrow">Erreur de navigation</div>
<div class="not-found-code">404</div>
<h2>Page introuvable</h2>
<p>Cette page n'existe pas ou n'est plus disponible.</p>
<div class="not-found-actions">
<a class="not-found-primary" href="/">Retour à l'aperçu</a>
<form action="/logout" method="post" class="not-found-secondary-form">
<button type="submit" class="not-found-secondary">Se déconnecter</button>
</form>
</div>
<div class="not-found-meta">
<span>Connecté en tant que</span>
<strong>{ dashboardGreetingName(displayName) }</strong>
</div>
</div>
</div>
}
templ OverviewHeader(displayName string) {
<header class="overview-header">
<p class="overview-date">{ dashboardTodayLabel() }</p>
<div class="overview-header-row">
<h2 class="overview-greeting">Bonjour, <span>{ dashboardGreetingName(displayName) }</span>!</h2>
<div class="overview-header-actions">
<span class="overview-badge">Founder</span>
<form action="/logout" method="post" class="overview-logout-form">
<button type="submit" class="overview-logout-button">Se déconnecter</button>
</form>
</div>
</div>
</header>
}
templ OverviewActions(actions []quickAction) {
<section class="overview-actions">
for _, action := range actions {
@QuickActionCard(action)
}
</section>
}
templ OverviewProjects(projects []dashboardProject) {
<section class="overview-section">
<div class="overview-section-heading">
<h3>Mes Projets</h3>
</div>
<div class="project-grid">
for _, project := range projects {
@ProjectCard(project)
}
</div>
<div class="overview-more-row">
<button type="button" class="overview-more-button">
Voir 11 de plus
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
</div>
</section>
}
templ OverviewTasks(tasks []dashboardTask) {
<section class="overview-section tasks-section">
<div class="tasks-section-header">
<h3>Mes Tâches</h3>
<button type="button" class="tasks-add-button">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
<span>Ajouter</span>
</button>
</div>
<div class="task-list">
for _, task := range tasks {
@TaskRow(task)
}
</div>
</section>
}
templ QuickActionCard(action quickAction) {
<button class="quick-action-card" type="button">
<div class="quick-action-icon">
@ActionIcon(action.Icon)
</div>
<div class="quick-action-copy">
<div class="quick-action-title">{ action.Title }</div>
<p>{ action.Description }</p>
</div>
</button>
}
templ ProjectCard(project dashboardProject) {
<article class="project-card">
<div class="project-card-top">
<span class={ "project-status " + toneClass(project.StatusTone) }>{ project.Status }</span>
<button class="project-delete-button" type="button" aria-label="Supprimer le projet">
@ActionIcon("trash")
</button>
</div>
<div class="project-card-title-row">
<div class={ "project-avatar " + projectAccentClass(project.Accent) }>
<span>{ project.Initial }</span>
</div>
<h4>{ project.Title }</h4>
</div>
<div class="project-date-row">
@ActionIcon("calendar")
<span>{ project.Date }</span>
</div>
<div class="project-progress">
<div class="project-progress-label">
<span>Progression:</span>
<strong>{ progressPercentLabel(project.Progress) }</strong>
</div>
<div class="project-progress-track">
<div class={ "project-progress-bar " + projectAccentClass(project.Accent) } style={ progressInlineStyle(project.Progress) }></div>
</div>
</div>
</article>
}
templ TaskRow(task dashboardTask) {
<div class={ taskRowClass(task.Completed) }>
<button class={ taskCheckClass(task.Completed) } type="button" aria-label="Marquer la tâche">
if task.Completed {
@ActionIcon("check-circle")
}
</button>
<div class="task-body">
<p>{ task.Title }</p>
<div class="task-meta">
<div class={ "task-project-badge " + projectAccentClass(task.ProjectHue) }>
<span>{ task.ProjectKey }</span>
</div>
<span class="task-project-name">{ task.Project }</span>
<span class="task-date">{ task.Date }</span>
</div>
</div>
<span class={ "task-status " + toneClass(task.StatusTone) }>{ task.Status }</span>
</div>
}
templ SidebarNavItem(item sidebarNavItem) {
<div id={ sidebarNavItemID(item.Href) } class={ sidebarNavItemClass(item.Active) }>
<a class="sidebar-nav-link" href={ templ.SafeURL(item.Href) } hx-get={ item.Href } hx-target="#app-main-content" hx-swap="outerHTML" hx-push-url="true">
<div class="sidebar-nav-link-inner">
<span class="sidebar-nav-icon">
@SidebarIcon(item.Icon)
</span>
<div class="sidebar-nav-label">{ item.Label }</div>
</div>
</a>
</div>
}
templ SidebarNavItemOOB(item sidebarNavItem) {
<div id={ sidebarNavItemID(item.Href) } class={ sidebarNavItemClass(item.Active) } hx-swap-oob="outerHTML">
<a class="sidebar-nav-link" href={ templ.SafeURL(item.Href) } hx-get={ item.Href } hx-target="#app-main-content" hx-swap="outerHTML" hx-push-url="true">
<div class="sidebar-nav-link-inner">
<span class="sidebar-nav-icon">
@SidebarIcon(item.Icon)
</span>
<div class="sidebar-nav-label">{ item.Label }</div>
</div>
</a>
</div>
}
templ SidebarProjectItem(item sidebarProjectItem) {
<a class="sidebar-project-link" href={ templ.SafeURL(item.Href) }>
<span class="sidebar-project-icon">
@SidebarIcon(item.Icon)
</span>
<span class="sidebar-project-label">{ item.Label }</span>
</a>
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,195 @@
package views
import (
"fmt"
"strings"
"time"
"github.com/a-h/templ"
)
func sidebarNavItemClass(active bool) string {
if active {
return "sidebar-nav-item is-active"
}
return "sidebar-nav-item"
}
func isActivePath(activePath string, href string) bool {
return strings.TrimSpace(activePath) != "" && activePath == href
}
func sidebarNavItemID(href string) string {
switch href {
case "/":
return "sidebar-nav-home"
default:
slug := strings.Trim(strings.ReplaceAll(href, "/", "-"), "-")
if slug == "" {
slug = "item"
}
return "sidebar-nav-" + slug
}
}
type quickAction struct {
Title string
Description string
Icon string
}
type sidebarNavItem struct {
Href string
Label string
Icon string
Active bool
DividerAfter bool
}
type sidebarProjectItem struct {
Href string
Label string
Icon string
}
type dashboardProject struct {
Title string
Status string
StatusTone string
Initial string
Accent string
Date string
Progress int
}
type dashboardTask struct {
Title string
Project string
ProjectKey string
ProjectHue string
Date string
Status string
StatusTone string
Completed bool
}
func dashboardDateLabel(now time.Time) string {
return now.Format("Monday, January 2")
}
func dashboardTodayLabel() string {
return dashboardDateLabel(time.Now())
}
func dashboardGreetingName(displayName string) string {
displayName = strings.TrimSpace(displayName)
if displayName == "" {
return "Arthur"
}
if len(displayName) == 1 {
return strings.ToUpper(displayName)
}
return strings.ToUpper(displayName[:1]) + displayName[1:]
}
func overviewQuickActions() []quickAction {
return []quickAction{
{Title: "Créer un projet", Description: "Définir les objectifs et le périmètre", Icon: "folder-plus"},
{Title: "Créer une tâche", Description: "Découper le travail en actions", Icon: "circle-plus"},
{Title: "Inviter l'équipe", Description: "Ajouter des collaborateurs", Icon: "user-plus"},
{Title: "Envoyer un message", Description: "Communiquer rapidement", Icon: "chat"},
}
}
func overviewProjects() []dashboardProject {
return []dashboardProject{
{Title: "Hello", Status: "En cours", StatusTone: "warning", Initial: "H", Accent: "blue", Date: "Apr 15, 2026", Progress: 50},
{Title: "Jean Macon interet pour le produit de ta mere", Status: "En cours", StatusTone: "warning", Initial: "J", Accent: "purple", Date: "Nov 18, 2025", Progress: 50},
{Title: "bikip56648 / Arthur Belleville", Status: "En cours", StatusTone: "warning", Initial: "B", Accent: "blue", Date: "Nov 06, 2025", Progress: 50},
{Title: "lsdkfjsl / Arthur Belleville", Status: "À faire", StatusTone: "info", Initial: "L", Accent: "blue", Date: "Oct 26, 2025", Progress: 0},
{Title: "Hello / Arthur Belleville", Status: "À faire", StatusTone: "info", Initial: "H", Accent: "blue", Date: "Oct 26, 2025", Progress: 0},
{Title: "Wes Ocif / Arthur", Status: "À faire", StatusTone: "info", Initial: "W", Accent: "blue", Date: "Oct 20, 2025", Progress: 0},
}
}
func overviewTasks() []dashboardTask {
return []dashboardTask{
{Title: "yo", Project: "Hello", ProjectKey: "H", ProjectHue: "blue", Date: "Apr 16, 2026", Status: "À faire", StatusTone: "info", Completed: false},
{Title: "yo", Project: "Hello", ProjectKey: "H", ProjectHue: "blue", Date: "Apr 16, 2026", Status: "Terminé", StatusTone: "success", Completed: true},
{Title: "hello", Project: "margot", ProjectKey: "M", ProjectHue: "red", Date: "Mar 7, 2026", Status: "Terminé", StatusTone: "success", Completed: true},
{Title: "Bonjour", Project: "Jean Macon interet pour le produit de ta mere", ProjectKey: "J", ProjectHue: "purple", Date: "Feb 24, 2026", Status: "Terminé", StatusTone: "success", Completed: true},
{Title: "Bonjour", Project: "Jean Macon interet pour le produit de ta mere", ProjectKey: "J", ProjectHue: "purple", Date: "Feb 21, 2026", Status: "Terminé", StatusTone: "success", Completed: true},
{Title: "Faire ceci", Project: "bikip56648 / Arthur Belleville", ProjectKey: "B", ProjectHue: "blue", Date: "Nov 18, 2025", Status: "Terminé", StatusTone: "success", Completed: true},
{Title: "Hello monsieur", Project: "bikip56648 / Arthur Belleville", ProjectKey: "B", ProjectHue: "blue", Date: "Nov 18, 2025", Status: "Terminé", StatusTone: "success", Completed: true},
}
}
func sidebarPrimaryNavItems(activePath string) []sidebarNavItem {
return []sidebarNavItem{
{Href: "/", Label: "Aperçu", Icon: "panels", Active: isActivePath(activePath, "/"), DividerAfter: true},
{Href: "/tasks", Label: "Tâches", Icon: "tasks", Active: isActivePath(activePath, "/tasks")},
{Href: "/tablos", Label: "Projets", Icon: "layers", Active: isActivePath(activePath, "/tablos"), DividerAfter: true},
{Href: "/planning", Label: "Planning", Icon: "planning", Active: isActivePath(activePath, "/planning")},
{Href: "/chat", Label: "Discussions", Icon: "chat", Active: isActivePath(activePath, "/chat")},
{Href: "/files", Label: "Fichiers", Icon: "files", Active: isActivePath(activePath, "/files")},
}
}
func sidebarProjectItems() []sidebarProjectItem {
return []sidebarProjectItem{
{Href: "/tablos/hello", Label: "Hello", Icon: "bolt"},
{Href: "/tablos/atelier", Label: "Atelier Produit", Icon: "gem"},
{Href: "/tablos/arthur", Label: "Arthur Belleville", Icon: "bolt"},
{Href: "/tablos/equipe", Label: "Equipe Design", Icon: "bolt"},
}
}
func sidebarFooterNavItems(activePath string) []sidebarNavItem {
return []sidebarNavItem{
{Href: "/feedback", Label: "Feedback", Icon: "send", Active: isActivePath(activePath, "/feedback")},
}
}
func toneClass(tone string) string {
switch tone {
case "warning":
return "tone-warning"
case "success":
return "tone-success"
default:
return "tone-info"
}
}
func projectAccentClass(accent string) string {
switch accent {
case "purple":
return "project-accent-purple"
case "red":
return "project-accent-red"
default:
return "project-accent-blue"
}
}
func taskRowClass(completed bool) string {
if completed {
return "task-row is-complete"
}
return "task-row"
}
func taskCheckClass(completed bool) string {
if completed {
return "task-check is-complete"
}
return "task-check"
}
func progressPercentLabel(progress int) string {
return fmt.Sprintf("%d%%", progress)
}
func progressInlineStyle(progress int) templ.SafeCSS {
return templ.SanitizeCSS("width", templ.SafeCSSProperty(progressPercentLabel(progress)))
}

View file

@ -0,0 +1,102 @@
package views
templ ActionIcon(kind string) {
switch kind {
case "folder-plus":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 10v6"></path>
<path d="M9 13h6"></path>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
</svg>
case "circle-plus":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 12h8"></path>
<path d="M12 8v8"></path>
</svg>
case "user-plus":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<line x1="19" x2="19" y1="8" y2="14"></line>
<line x1="22" x2="16" y1="11" y2="11"></line>
</svg>
case "trash":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" x2="10" y1="11" y2="17"></line>
<line x1="14" x2="14" y1="11" y2="17"></line>
</svg>
case "calendar":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" 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>
case "check-circle":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="m9 12 2 2 4-4"></path>
</svg>
default:
@SidebarIcon(kind)
}
}
templ SidebarIcon(kind string) {
switch kind {
case "panels":
<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="3" rx="2"></rect>
<path d="M3 9h18"></path>
<path d="M9 21V9"></path>
</svg>
case "tasks":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="5" width="6" height="6" rx="1"></rect>
<path d="m3 17 2 2 4-4"></path>
<path d="M13 6h8"></path>
<path d="M13 12h8"></path>
<path d="M13 18h8"></path>
</svg>
case "layers":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"></path>
<path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"></path>
<path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"></path>
</svg>
case "planning":
<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="3" rx="2"></rect>
<path d="M8 7v7"></path>
<path d="M12 7v4"></path>
<path d="M16 7v9"></path>
</svg>
case "chat":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path>
</svg>
case "files":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
</svg>
case "send":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path>
<path d="m21.854 2.147-10.94 10.939"></path>
</svg>
case "gem":
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 3h12l4 6-10 13L2 9Z"></path>
<path d="M11 3 8 9l4 13 4-13-3-6"></path>
<path d="M2 9h20"></path>
</svg>
default:
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<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>
}
}

View file

@ -0,0 +1,145 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package views
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func ActionIcon(kind string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch kind {
case "folder-plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 10v6\"></path> <path d=\"M9 13h6\"></path> <path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "circle-plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"M8 12h8\"></path> <path d=\"M12 8v8\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "user-plus":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2\"></path> <circle cx=\"9\" cy=\"7\" r=\"4\"></circle> <line x1=\"19\" x2=\"19\" y1=\"8\" y2=\"14\"></line> <line x1=\"22\" x2=\"16\" y1=\"11\" y2=\"11\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "trash":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 6h18\"></path> <path d=\"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6\"></path> <path d=\"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2\"></path> <line x1=\"10\" x2=\"10\" y1=\"11\" y2=\"17\"></line> <line x1=\"14\" x2=\"14\" y1=\"11\" y2=\"17\"></line></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "calendar":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" 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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "check-circle":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle> <path d=\"m9 12 2 2 4-4\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = SidebarIcon(kind).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
func SidebarIcon(kind string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
switch kind {
case "panels":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<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=\"3\" rx=\"2\"></rect> <path d=\"M3 9h18\"></path> <path d=\"M9 21V9\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "tasks":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"3\" y=\"5\" width=\"6\" height=\"6\" rx=\"1\"></rect> <path d=\"m3 17 2 2 4-4\"></path> <path d=\"M13 6h8\"></path> <path d=\"M13 12h8\"></path> <path d=\"M13 18h8\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "layers":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z\"></path> <path d=\"m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65\"></path> <path d=\"m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "planning":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<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=\"3\" rx=\"2\"></rect> <path d=\"M8 7v7\"></path> <path d=\"M12 7v4\"></path> <path d=\"M16 7v9\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "chat":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M7.9 20A9 9 0 1 0 4 16.1L2 22Z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "files":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "send":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z\"></path> <path d=\"m21.854 2.147-10.94 10.939\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case "gem":
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M6 3h12l4 6-10 13L2 9Z\"></path> <path d=\"M11 3 8 9l4 13 4-13-3-6\"></path> <path d=\"M2 9h20\"></path></svg>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,70 @@
package views
templ AuthPage(content templ.Component) {
<!doctype html>
<html lang="en" class="light">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/png" sizes="32x32" href="/pwa-icons/favicon-32x32.png"/>
<link rel="icon" type="image/png" sizes="16x16" href="/pwa-icons/favicon-16x16.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/pwa-icons/apple-touch-icon-180x180.png"/>
<link rel="manifest" href="/manifest.webmanifest"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<meta name="theme-color" content="#1e1b2e"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
<title>XTablo</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js"></script>
<link rel="stylesheet" href="/static/styles.css"/>
</head>
<body>
<div id="root">
<section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite" aria-relevant="additions text" aria-atomic="false"></section>
<div class="app-shell">
<div class="login-screen">
@AnimatedBackground()
<div class="card-wrap">
<div class="card-glow"></div>
<div data-testid="auth-card-shell" class="auth-card-shell">
<div class="auth-card-topbar">
<div>
<a href="https://www.xtablo.com" class="back-home-link">
<svg class="back-home-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Retour à l'accueil
</a>
</div>
<button class="theme-toggle-button" aria-label="change theme (system)" type="button">
<svg xmlns="http://www.w3.org/2000/svg" class="theme-toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
<line x1="8" x2="16" y1="21" y2="21"></line>
<line x1="12" x2="12" y1="17" y2="21"></line>
</svg>
</button>
</div>
<div class="brand-header">
<img alt="Xtablo" class="brand-logo light-only" src="/logo_dark.png"/>
<img alt="Xtablo" class="brand-logo dark-only" src="/logo_white.png"/>
</div>
@content
</div>
</div>
</div>
</div>
</div>
</body>
</html>
}
templ LoginPage() {
@AuthPage(LoginScreen())
}
templ SignupPage() {
@AuthPage(SignupScreen())
}
templ HomePage(displayName string, email string) {
@DashboardPage("/", OverviewMainContent(displayName, email))
}

View file

@ -0,0 +1,143 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package views
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func AuthPage(content templ.Component) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" class=\"light\"><head><meta charset=\"UTF-8\"><link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/pwa-icons/favicon-32x32.png\"><link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/pwa-icons/favicon-16x16.png\"><link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/pwa-icons/apple-touch-icon-180x180.png\"><link rel=\"manifest\" href=\"/manifest.webmanifest\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\"><meta name=\"theme-color\" content=\"#1e1b2e\"><meta name=\"apple-mobile-web-app-capable\" content=\"yes\"><meta name=\"apple-mobile-web-app-status-bar-style\" content=\"default\"><title>XTablo</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js\"></script><link rel=\"stylesheet\" href=\"/static/styles.css\"></head><body><div id=\"root\"><section aria-label=\"Notifications alt+T\" tabindex=\"-1\" aria-live=\"polite\" aria-relevant=\"additions text\" aria-atomic=\"false\"></section><div class=\"app-shell\"><div class=\"login-screen\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = AnimatedBackground().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"card-wrap\"><div class=\"card-glow\"></div><div data-testid=\"auth-card-shell\" class=\"auth-card-shell\"><div class=\"auth-card-topbar\"><div><a href=\"https://www.xtablo.com\" class=\"back-home-link\"><svg class=\"back-home-icon\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\"></path></svg> Retour à l'accueil</a></div><button class=\"theme-toggle-button\" aria-label=\"change theme (system)\" type=\"button\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"theme-toggle-icon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\"></rect> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\"></line> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\"></line></svg></button></div><div class=\"brand-header\"><img alt=\"Xtablo\" class=\"brand-logo light-only\" src=\"/logo_dark.png\"> <img alt=\"Xtablo\" class=\"brand-logo dark-only\" src=\"/logo_white.png\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div></div></div></div></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func LoginPage() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
if templ_7745c5c3_Var2 == nil {
templ_7745c5c3_Var2 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AuthPage(LoginScreen()).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func SignupPage() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = AuthPage(SignupScreen()).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func HomePage(displayName string, email string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = DashboardPage("/", OverviewMainContent(displayName, email)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -31,6 +31,12 @@ func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler {
// Views
mux.Get("/", authHandler.GetHome())
mux.Get("/tasks", authHandler.GetTasksPage())
mux.Get("/tablos", authHandler.GetTablosPage())
mux.Get("/planning", authHandler.GetPlanningPage())
mux.Get("/chat", authHandler.GetChatPage())
mux.Get("/files", authHandler.GetFilesPage())
mux.Get("/feedback", authHandler.GetFeedbackPage())
mux.Get("/login", authHandler.GetLoginPage())
mux.Get("/signup", authHandler.GetSignupPage())
mux.Post("/login", authHandler.PostLogin())
@ -42,6 +48,7 @@ func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler {
mux.HandleFunc("/logo_dark.png", serveStaticFile(staticFS, "logo_dark.png", "image/png"))
mux.HandleFunc("/logo_white.png", serveStaticFile(staticFS, "logo_white.png", "image/png"))
mux.HandleFunc("/manifest.webmanifest", serveStaticFile(staticFS, "manifest.webmanifest", "application/manifest+json"))
mux.NotFound(authHandler.GetNotFound())
return mux
}

View file

@ -6,6 +6,8 @@ import (
"net/url"
"strings"
"testing"
"xtablo-backend/internal/web/handlers"
)
func TestRootRedirectsToLoginWhenUnauthenticated(t *testing.T) {
@ -23,6 +25,21 @@ func TestRootRedirectsToLoginWhenUnauthenticated(t *testing.T) {
}
}
func TestUnknownRouteRedirectsToLoginWhenUnauthenticated(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/missing", nil)
rec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected status 303, got %d", rec.Code)
}
if location := rec.Header().Get("Location"); location != "/login" {
t.Fatalf("expected redirect to /login, got %q", location)
}
}
func TestLoginPageRenders(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rec := httptest.NewRecorder()
@ -68,7 +85,7 @@ func TestSignupPageRenders(t *testing.T) {
body := rec.Body.String()
for _, want := range []string{
"S'inscrire à Xtablo",
"S&#39;inscrire à Xtablo",
`hx-post="/signup"`,
"Vous avez déjà un compte ?",
} {
@ -154,11 +171,115 @@ func TestLoginCreatesSessionAndRedirects(t *testing.T) {
if homeRec.Code != http.StatusOK {
t.Fatalf("expected authenticated root status 200, got %d", homeRec.Code)
}
if !strings.Contains(homeRec.Body.String(), "Bienvenue") {
t.Fatalf("expected authenticated home page, got %q", homeRec.Body.String())
for _, want := range []string{
"Bonjour,",
"Aperçu",
"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta2/dist/htmx.min.js",
`hx-get="/tasks"`,
`hx-target="#app-main-content"`,
`hx-swap="outerHTML"`,
`hx-push-url="true"`,
"Tâches",
"Projets",
"Planning",
"Discussions",
"Fichiers",
"Feedback",
"Arctic Matrix",
"Créer un projet",
"Créer une tâche",
"Inviter l&#39;équipe",
"Envoyer un message",
"Mes Projets",
"Mes Tâches",
`action="/logout"`,
} {
if !strings.Contains(homeRec.Body.String(), want) {
t.Fatalf("expected authenticated home page to contain %q, got %q", want, homeRec.Body.String())
}
}
if !strings.Contains(homeRec.Body.String(), `action="/logout"`) {
t.Fatalf("expected authenticated home page to include logout form, got %q", homeRec.Body.String())
}
func TestTasksPageRendersFullDashboardPage(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`class="sidebar-nav-shell"`,
`id="app-main-content"`,
"Tâches",
"Suivez les tâches de votre équipe",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected tasks page to contain %q", want)
}
}
}
func TestTasksPageReturnsHTMXMainContentSwap(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`id="app-main-content"`,
`hx-swap-oob="outerHTML"`,
`id="sidebar-nav-tasks"`,
"Tâches",
"Suivez les tâches de votre équipe",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected HTMX tasks response to contain %q", want)
}
}
if strings.Contains(body, `class="sidebar-nav-shell"`) {
t.Fatalf("expected HTMX tasks response to avoid rerendering the full sidebar")
}
}
@ -244,6 +365,77 @@ func TestLogoutClearsSessionAndRedirectsToLogin(t *testing.T) {
}
}
func TestUnknownRouteShowsDashboard404WhenAuthenticated(t *testing.T) {
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
router := newTestRouter()
router.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
req := httptest.NewRequest(http.MethodGet, "/missing", nil)
req.AddCookie(sessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Aperçu",
"Page introuvable",
"Cette page n'existe pas",
`href="/"`,
`action="/logout"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected authenticated 404 page to contain %q", want)
}
}
}
func TestSessionSurvivesHandlerRecreation(t *testing.T) {
repo := handlers.NewInMemoryAuthRepository()
loginRouter := newRouterWithHandler(handlers.NewAuthHandler(repo))
form := url.Values{}
form.Set("email", "demo@xtablo.com")
form.Set("password", "xtablo-demo")
loginReq := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginRec := httptest.NewRecorder()
loginRouter.ServeHTTP(loginRec, loginReq)
sessionCookie := findCookie(loginRec.Result().Cookies(), "xtablo_session")
if sessionCookie == nil {
t.Fatalf("expected session cookie to be set")
}
reloadedRouter := newRouterWithHandler(handlers.NewAuthHandler(repo))
homeReq := httptest.NewRequest(http.MethodGet, "/", nil)
homeReq.AddCookie(sessionCookie)
homeRec := httptest.NewRecorder()
reloadedRouter.ServeHTTP(homeRec, homeReq)
if homeRec.Code != http.StatusOK {
t.Fatalf("expected session to survive handler recreation, got status %d", homeRec.Code)
}
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, cookie := range cookies {
if cookie.Name == name {

View file

@ -518,15 +518,888 @@ input {
}
.home-shell {
align-items: center;
background:
linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%),
linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%),
linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04));
min-height: 100vh;
}
.dashboard-shell {
background:
linear-gradient(135deg, rgba(128, 78, 236, 0.08), transparent 30%),
linear-gradient(160deg, rgba(30, 27, 46, 0.05), transparent 42%),
linear-gradient(to bottom right, rgba(30, 27, 46, 0.08), var(--background), rgba(128, 78, 236, 0.04));
color: var(--foreground);
display: grid;
grid-template-columns: minmax(16rem, 18rem) 1fr;
min-height: 100vh;
}
.dashboard-sidebar {
padding-left: env(safe-area-inset-left, 0px);
}
.sidebar-nav-shell {
background: rgba(255, 255, 255, 0.92);
border-right: 1px solid rgba(30, 27, 46, 0.08);
box-shadow: 20px 0 45px rgba(30, 27, 46, 0.06);
display: flex;
flex-direction: column;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
padding: env(safe-area-inset-top, 0px) 0.75rem env(safe-area-inset-bottom, 0px) 0;
position: sticky;
top: 0;
}
.sidebar-brand {
align-items: center;
display: flex;
justify-content: center;
padding: 0.75rem 0.5rem;
position: relative;
}
.sidebar-brand-link {
align-items: center;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.sidebar-brand-logo {
border-radius: 0.75rem;
height: 4rem;
object-fit: cover;
width: 4rem;
}
.sidebar-brand-title {
color: #1f2937;
font-size: 1.125rem;
font-weight: 700;
margin: 0;
}
.sidebar-collapse-button {
align-items: center;
background: rgba(255, 255, 255, 0.95);
border: 0;
border-radius: 999px;
box-shadow: 0 10px 24px rgba(30, 27, 46, 0.14);
color: #6b7280;
cursor: pointer;
display: inline-flex;
height: 1.5rem;
justify-content: center;
padding: 0.25rem;
position: absolute;
right: 0.75rem;
top: 0.5rem;
width: 1.5rem;
}
.sidebar-collapse-button svg {
height: 1rem;
width: 1rem;
}
.sidebar-primary {
display: flex;
flex: 1;
flex-direction: column;
}
.sidebar-list {
display: grid;
gap: 0;
list-style: none;
margin: 0;
padding: 0.75rem 0 0;
}
.sidebar-divider {
margin: 0.5rem 0;
padding: 0 0.875rem;
}
.sidebar-divider hr,
.sidebar-projects hr {
border: 0;
border-top: 1px solid rgba(107, 114, 128, 0.22);
margin: 0;
}
.sidebar-nav-item {
border-radius: 0.9rem;
color: #6b7280;
font-weight: 500;
margin: 0 0.5rem;
padding: 0.15rem 0;
transition: background-color 0.2s ease, color 0.2s ease;
}
.sidebar-nav-item:hover {
background: rgba(30, 27, 46, 0.05);
color: #111827;
}
.sidebar-nav-item.is-active {
background: rgba(128, 78, 236, 0.14);
color: #804eec;
font-weight: 600;
}
.sidebar-nav-link {
display: block;
width: 100%;
}
.sidebar-nav-link-inner {
align-items: center;
display: flex;
gap: 0.75rem;
padding: 0.6rem 0.95rem;
}
.sidebar-nav-icon {
align-items: center;
display: inline-flex;
justify-content: center;
}
.sidebar-nav-icon svg {
height: 1.35rem;
width: 1.35rem;
}
.sidebar-nav-label {
font-size: 1rem;
}
.sidebar-projects {
margin-top: 0.4rem;
padding: 0 0.75rem 0.75rem;
}
.sidebar-section-label {
color: #6b7280;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.14em;
margin: 0.9rem 0 0.65rem;
padding: 0 0.5rem;
text-transform: uppercase;
}
.sidebar-project-list {
display: grid;
gap: 0.1rem;
list-style: none;
margin: 0;
padding: 0;
}
.sidebar-project-link {
align-items: center;
border-radius: 0.85rem;
color: #6b7280;
display: flex;
gap: 0.65rem;
padding: 0.48rem 0.5rem;
transition: background-color 0.2s ease, color 0.2s ease;
}
.sidebar-project-link:hover {
background: rgba(30, 27, 46, 0.05);
color: #111827;
}
.sidebar-project-icon {
align-items: center;
border: 1px solid rgba(107, 114, 128, 0.35);
border-radius: 999px;
display: inline-flex;
flex-shrink: 0;
height: 1.55rem;
justify-content: center;
width: 1.55rem;
}
.sidebar-project-icon svg {
height: 0.9rem;
width: 0.9rem;
}
.sidebar-project-label {
flex: 1;
font-size: 0.9rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-footer-links {
margin-top: auto;
padding-bottom: 0.25rem;
}
.sidebar-organization {
background: rgba(255, 255, 255, 0.92);
padding: 0 0.5rem 0.9rem;
}
.organization-button {
align-items: center;
background: transparent;
border: 0;
border-radius: 0.95rem;
cursor: pointer;
display: flex;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
text-align: left;
transition: background-color 0.2s ease;
width: 100%;
}
.organization-button:hover {
background: rgba(30, 27, 46, 0.05);
}
.organization-avatar {
border-radius: 999px;
display: inline-flex;
flex-shrink: 0;
height: 1.75rem;
overflow: hidden;
width: 1.75rem;
}
.organization-avatar img {
aspect-ratio: 1;
height: 100%;
object-fit: cover;
width: 100%;
}
.organization-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.organization-name {
color: #374151;
font-size: 0.95rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.organization-meta {
color: #6b7280;
font-size: 0.75rem;
}
.dashboard-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
padding: 2rem;
}
.overview-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 100%;
}
.overview-header {
padding: 0 0 0.25rem;
}
.overview-date {
color: #475467;
font-size: 1rem;
font-weight: 500;
margin: 0 0 0.5rem;
}
.overview-header-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
}
.overview-greeting {
color: #475467;
font-size: clamp(1.4rem, 2vw, 1.75rem);
font-weight: 500;
margin: 0;
}
.overview-greeting span {
color: #111827;
}
.overview-header-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.overview-badge {
align-items: center;
background: linear-gradient(to right, #a855f7, #3b82f6);
border: 0;
border-radius: 999px;
color: #fff;
display: inline-flex;
font-size: 0.75rem;
font-weight: 600;
justify-content: center;
min-height: 1.85rem;
padding: 0.25rem 0.75rem;
}
.overview-logout-form {
margin: 0;
}
.overview-logout-button {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(234, 236, 240, 1);
border-radius: 0.85rem;
color: #475467;
cursor: pointer;
font-weight: 600;
min-height: 2.75rem;
padding: 0.65rem 1rem;
}
.overview-actions {
display: grid;
gap: 1rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.quick-action-card {
align-items: center;
background: #fff;
border: 1px solid #eaecf0;
border-radius: 1rem;
cursor: pointer;
display: flex;
gap: 0.9rem;
min-height: 5rem;
padding: 0.9rem;
text-align: left;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.quick-action-card:hover,
.project-card:hover {
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.quick-action-icon {
align-items: center;
background: #f4f3ff;
border-radius: 0.7rem;
color: #7f56d9;
display: inline-flex;
flex-shrink: 0;
height: 2.5rem;
justify-content: center;
width: 2.5rem;
}
.quick-action-icon svg {
height: 1.5rem;
width: 1.5rem;
}
.quick-action-copy {
flex: 1;
min-width: 0;
}
.quick-action-title {
color: #111827;
font-size: 1.05rem;
font-weight: 600;
line-height: 1.2;
}
.quick-action-copy p {
color: #6b7280;
font-size: 0.9rem;
margin: 0.25rem 0 0;
}
.overview-section {
padding: 0.25rem 0 0;
}
.overview-section-heading {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.overview-section-heading h3,
.tasks-section-header h3 {
color: #111827;
font-size: 1.6rem;
font-weight: 600;
margin: 0;
}
.project-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.project-card {
background: #fff;
border: 1px solid #eaecf0;
border-radius: 1rem;
cursor: pointer;
padding: 1rem;
transition: box-shadow 0.2s ease;
}
.project-card-top {
align-items: flex-start;
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.project-status,
.task-status {
border: 1px solid transparent;
border-radius: 999px;
display: inline-flex;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.7rem;
}
.tone-warning {
background: #fffbeb;
border-color: #fde68a;
color: #ca8a04;
}
.tone-info {
background: #eff6ff;
border-color: #bfdbfe;
color: #2563eb;
}
.tone-success {
background: #ecfdf3;
border-color: #bbf7d0;
color: #16a34a;
}
.project-delete-button {
background: transparent;
border: 0;
color: #9ca3af;
cursor: pointer;
padding: 0;
}
.project-delete-button:hover {
color: #ef4444;
}
.project-delete-button svg,
.project-date-row svg,
.overview-more-button svg,
.tasks-add-button svg,
.task-check svg {
height: 1rem;
width: 1rem;
}
.project-card-title-row {
align-items: center;
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.project-avatar {
align-items: center;
border-radius: 0.85rem;
color: #fff;
display: inline-flex;
flex-shrink: 0;
font-size: 1.1rem;
font-weight: 700;
height: 3rem;
justify-content: center;
width: 3rem;
}
.project-accent-blue {
background: #3b82f6;
}
.project-accent-purple {
background: #a855f7;
}
.project-accent-red {
background: #ef4444;
}
.project-card-title-row h4 {
color: #111827;
flex: 1;
font-size: 1rem;
font-weight: 600;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.project-date-row {
align-items: center;
color: #6b7280;
display: flex;
font-size: 0.875rem;
gap: 0.5rem;
margin-bottom: 1rem;
}
.project-progress-label {
align-items: center;
color: #6b7280;
display: flex;
font-size: 0.875rem;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.project-progress-label strong {
color: #111827;
}
.project-progress-track {
background: #f3f4f6;
border-radius: 999px;
height: 0.5rem;
overflow: hidden;
}
.project-progress-bar {
border-radius: 999px;
height: 100%;
}
.overview-more-row {
display: flex;
justify-content: center;
margin-top: 1.5rem;
}
.overview-more-button {
align-items: center;
background: transparent;
border: 0;
color: #7c3aed;
cursor: pointer;
display: inline-flex;
font-size: 0.875rem;
font-weight: 600;
gap: 0.4rem;
}
.app-section-page {
min-height: 100vh;
padding: 2rem;
}
.app-section-surface {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, #ffffff 100%);
border: 1px solid #eaecf0;
border-radius: 1.5rem;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.08);
max-width: 52rem;
padding: 2rem;
}
.app-section-eyebrow {
color: #7c3aed;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
margin-bottom: 0.9rem;
text-transform: uppercase;
}
.app-section-surface h2 {
color: #111827;
font-size: clamp(1.8rem, 4vw, 2.6rem);
line-height: 1.05;
margin: 0;
}
.app-section-surface p {
color: #475467;
font-size: 1rem;
line-height: 1.8;
margin: 1rem 0 0;
max-width: 40rem;
}
.not-found-page {
align-items: center;
background:
radial-gradient(circle at top, rgba(124, 58, 237, 0.14), transparent 35%),
linear-gradient(180deg, #f8f7ff 0%, #f4f7fb 100%);
display: flex;
justify-content: center;
min-height: 100vh;
padding: 1.5rem;
padding: 3rem 1.5rem;
}
.not-found-surface {
backdrop-filter: blur(18px);
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 2rem;
box-shadow: 0 32px 70px rgba(15, 23, 42, 0.12);
padding: 3rem;
width: min(100%, 44rem);
}
.not-found-eyebrow {
align-items: center;
background: rgba(124, 58, 237, 0.1);
border-radius: 999px;
color: #7c3aed;
display: inline-flex;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.45rem 0.85rem;
text-transform: uppercase;
}
.not-found-code {
color: #111827;
font-size: clamp(4.75rem, 11vw, 7.5rem);
font-weight: 800;
letter-spacing: -0.08em;
line-height: 0.95;
margin-top: 1.4rem;
}
.not-found-surface h2 {
color: #111827;
font-size: clamp(1.9rem, 4vw, 2.8rem);
line-height: 1.05;
margin: 1rem 0 0;
}
.not-found-surface p {
color: #475467;
font-size: 1rem;
line-height: 1.75;
margin: 1rem 0 0;
max-width: 36rem;
}
.not-found-actions {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
margin-top: 2rem;
}
.not-found-primary,
.not-found-secondary {
align-items: center;
border: 1px solid transparent;
border-radius: 0.95rem;
display: inline-flex;
font-size: 0.95rem;
font-weight: 600;
justify-content: center;
min-height: 2.9rem;
padding: 0.85rem 1.25rem;
text-decoration: none;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
background-color 0.18s ease,
border-color 0.18s ease;
}
.not-found-primary {
background: linear-gradient(135deg, #7c3aed 0%, #2563eb 100%);
box-shadow: 0 18px 35px rgba(124, 58, 237, 0.25);
color: #fff;
}
.not-found-primary:hover,
.not-found-secondary:hover {
transform: translateY(-1px);
}
.not-found-secondary-form {
margin: 0;
}
.not-found-secondary {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(148, 163, 184, 0.3);
color: #344054;
cursor: pointer;
}
.not-found-meta {
align-items: center;
color: #667085;
display: flex;
font-size: 0.95rem;
gap: 0.5rem;
margin-top: 1.5rem;
}
.not-found-meta strong {
color: #111827;
}
.tasks-section {
background: #fff;
border: 1px solid #eaecf0;
border-radius: 1rem;
overflow: hidden;
}
.tasks-section-header {
align-items: center;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
padding: 1.2rem 1rem;
}
.tasks-add-button {
align-items: center;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.7rem;
color: #475467;
cursor: pointer;
display: inline-flex;
font-weight: 500;
gap: 0.5rem;
min-height: 2.75rem;
padding: 0.7rem 1rem;
}
.task-list {
display: flex;
flex-direction: column;
}
.task-row {
align-items: center;
border-bottom: 1px solid #e5e7eb;
cursor: pointer;
display: flex;
gap: 0.75rem;
padding: 0.9rem 1rem;
transition: background-color 0.2s ease;
}
.task-row:hover {
background: rgba(249, 250, 251, 0.9);
}
.task-check {
align-items: center;
background: #fff;
border: 2px solid #d1d5db;
border-radius: 999px;
color: #fff;
cursor: pointer;
display: inline-flex;
flex-shrink: 0;
height: 2rem;
justify-content: center;
width: 2rem;
}
.task-check.is-complete {
background: #7c3aed;
border-color: #7c3aed;
}
.task-body {
flex: 1;
min-width: 0;
}
.task-body p {
color: #111827;
font-size: 0.95rem;
font-weight: 500;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-row.is-complete .task-body p {
color: #9ca3af;
text-decoration: line-through;
}
.task-meta {
align-items: center;
color: #6b7280;
display: flex;
flex-wrap: wrap;
font-size: 0.75rem;
gap: 0.45rem;
margin-top: 0.3rem;
}
.task-project-badge {
align-items: center;
border-radius: 0.35rem;
color: #fff;
display: inline-flex;
font-size: 0.5rem;
font-weight: 700;
height: 1rem;
justify-content: center;
width: 1rem;
}
.task-date {
white-space: nowrap;
}
.home-card {
@ -564,6 +1437,75 @@ input {
.logout-button {
max-width: 14rem;
width: 100%;
}
@media (max-width: 980px) {
.dashboard-shell {
grid-template-columns: 1fr;
}
.sidebar-nav-shell {
border-bottom: 1px solid rgba(30, 27, 46, 0.08);
border-right: 0;
box-shadow: 0 12px 30px rgba(30, 27, 46, 0.08);
height: auto;
position: static;
}
.overview-actions,
.project-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.dashboard-main {
padding: 1rem;
}
.overview-header-row,
.tasks-section-header {
align-items: flex-start;
flex-direction: column;
}
.overview-actions,
.project-grid {
grid-template-columns: 1fr;
}
.task-row {
align-items: flex-start;
flex-wrap: wrap;
}
.task-status {
margin-left: 2.75rem;
}
.not-found-surface {
border-radius: 1.5rem;
padding: 2rem;
}
.not-found-actions {
flex-direction: column;
}
.not-found-primary,
.not-found-secondary,
.not-found-secondary-form {
width: 100%;
}
.app-section-page {
padding: 1rem;
}
.app-section-surface {
padding: 1.5rem;
}
}
@keyframes move-right-slow {