Split components, add sessions to the DB and make the apercu page
This commit is contained in:
parent
0a38442d88
commit
c4eb878b0e
22 changed files with 4748 additions and 617 deletions
11
.zed/settings.json
Normal file
11
.zed/settings.json
Normal 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"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
132
go-backend/internal/web/handlers/in_memory_auth_repository.go
Normal file
132
go-backend/internal/web/handlers/in_memory_auth_repository.go
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
575
go-backend/internal/web/views/auth_components_templ.go
Normal file
575
go-backend/internal/web/views/auth_components_templ.go
Normal file
File diff suppressed because one or more lines are too long
330
go-backend/internal/web/views/dashboard_components.templ
Normal file
330
go-backend/internal/web/views/dashboard_components.templ
Normal 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>
|
||||
}
|
||||
1523
go-backend/internal/web/views/dashboard_components_templ.go
Normal file
1523
go-backend/internal/web/views/dashboard_components_templ.go
Normal file
File diff suppressed because it is too large
Load diff
195
go-backend/internal/web/views/home.go
Normal file
195
go-backend/internal/web/views/home.go
Normal 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)))
|
||||
}
|
||||
102
go-backend/internal/web/views/icons.templ
Normal file
102
go-backend/internal/web/views/icons.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
145
go-backend/internal/web/views/icons_templ.go
Normal file
145
go-backend/internal/web/views/icons_templ.go
Normal 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
70
go-backend/internal/web/views/pages.templ
Normal file
70
go-backend/internal/web/views/pages.templ
Normal 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))
|
||||
}
|
||||
143
go-backend/internal/web/views/pages_templ.go
Normal file
143
go-backend/internal/web/views/pages_templ.go
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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'é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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue