feat: Add comprehensive backend validation, toast notifications, and Tokyo Night theme

- Backend: Add server-side validation with go-playground/validator/v10
- Frontend: Add toast notifications for API errors with @zerodevx/svelte-toast
- UI: Complete Tokyo Night theme redesign with modern animations
- Docs: Update COMPLETE_DOCUMENTATION.md and README.md with all enhancements
- Validation: Email format, password strength, and input sanitization
- UX: Real-time error feedback, loading states, and responsive design
This commit is contained in:
2026-01-21 20:01:18 -05:00
parent 7448dfff30
commit 55c42f1f99
84 changed files with 7749 additions and 4 deletions
+20
View File
@@ -0,0 +1,20 @@
package database
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
func NewConnection(databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(context.Background(), databaseURL)
if err != nil {
return nil, err
}
if err := pool.Ping(context.Background()); err != nil {
return nil, err
}
return pool, nil
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
+41
View File
@@ -0,0 +1,41 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"github.com/jackc/pgx/v5/pgtype"
)
type Ebooks struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type ReadingProgress struct {
ID pgtype.UUID `db:"id" json:"id"`
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
+29
View File
@@ -0,0 +1,29 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error)
CreateUser(ctx context.Context, arg CreateUserParams) (Users, error)
DeleteEbook(ctx context.Context, id pgtype.UUID) error
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error)
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (Users, error)
GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error)
GetUserByUsername(ctx context.Context, username string) (Users, error)
ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error)
UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error)
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
}
var _ Querier = (*Queries)(nil)
+356
View File
@@ -0,0 +1,356 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: queries.sql
package database
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateEbook = `-- name: CreateEbook :one
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at
`
type CreateEbookParams struct {
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
}
func (q *Queries) CreateEbook(ctx context.Context, arg CreateEbookParams) (Ebooks, error) {
row := q.db.QueryRow(ctx, CreateEbook,
arg.Title,
arg.Author,
arg.Isbn,
arg.Description,
arg.FilePath,
arg.FileSize,
arg.MimeType,
arg.CoverImagePath,
)
var i Ebooks
err := row.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const CreateUser = `-- name: CreateUser :one
INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
RETURNING id, email, username, password_hash, created_at, updated_at
`
type CreateUserParams struct {
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (Users, error) {
row := q.db.QueryRow(ctx, CreateUser, arg.Email, arg.Username, arg.PasswordHash)
var i Users
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteEbook = `-- name: DeleteEbook :exec
DELETE FROM ebooks WHERE id = $1
`
func (q *Queries) DeleteEbook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, DeleteEbook, id)
return err
}
const DeleteReadingProgress = `-- name: DeleteReadingProgress :exec
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
`
type DeleteReadingProgressParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error {
_, err := q.db.Exec(ctx, DeleteReadingProgress, arg.EbookID, arg.UserID)
return err
}
const GetEbook = `-- name: GetEbook :one
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at FROM ebooks WHERE id = $1
`
func (q *Queries) GetEbook(ctx context.Context, id pgtype.UUID) (Ebooks, error) {
row := q.db.QueryRow(ctx, GetEbook, id)
var i Ebooks
err := row.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetReadingProgress = `-- name: GetReadingProgress :one
SELECT id, ebook_id, user_id, current_page, total_pages, last_read_at FROM reading_progress WHERE ebook_id = $1 AND user_id = $2
`
type GetReadingProgressParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
func (q *Queries) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) {
row := q.db.QueryRow(ctx, GetReadingProgress, arg.EbookID, arg.UserID)
var i ReadingProgress
err := row.Scan(
&i.ID,
&i.EbookID,
&i.UserID,
&i.CurrentPage,
&i.TotalPages,
&i.LastReadAt,
)
return i, err
}
const GetUser = `-- name: GetUser :one
SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1
`
type GetUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) {
row := q.db.QueryRow(ctx, GetUser, id)
var i GetUserRow
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (Users, error) {
row := q.db.QueryRow(ctx, GetUserByEmail, email)
var i Users
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE email = $1 OR username = $1
`
func (q *Queries) GetUserByEmailOrUsername(ctx context.Context, email string) (Users, error) {
row := q.db.QueryRow(ctx, GetUserByEmailOrUsername, email)
var i Users
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const GetUserByUsername = `-- name: GetUserByUsername :one
SELECT id, email, username, password_hash, created_at, updated_at FROM users WHERE username = $1
`
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (Users, error) {
row := q.db.QueryRow(ctx, GetUserByUsername, username)
var i Users
err := row.Scan(
&i.ID,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListEbooks = `-- name: ListEbooks :many
SELECT id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2
`
type ListEbooksParams struct {
Limit int32 `db:"limit" json:"limit"`
Offset int32 `db:"offset" json:"offset"`
}
func (q *Queries) ListEbooks(ctx context.Context, arg ListEbooksParams) ([]Ebooks, error) {
rows, err := q.db.Query(ctx, ListEbooks, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Ebooks{}
for rows.Next() {
var i Ebooks
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateEbook = `-- name: UpdateEbook :one
UPDATE ebooks SET
title = $2,
author = $3,
isbn = $4,
description = $5,
cover_image_path = $6,
updated_at = NOW()
WHERE id = $1
RETURNING id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, created_at, updated_at
`
type UpdateEbookParams struct {
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
}
func (q *Queries) UpdateEbook(ctx context.Context, arg UpdateEbookParams) (Ebooks, error) {
row := q.db.QueryRow(ctx, UpdateEbook,
arg.ID,
arg.Title,
arg.Author,
arg.Isbn,
arg.Description,
arg.CoverImagePath,
)
var i Ebooks
err := row.Scan(
&i.ID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const UpdateReadingProgress = `-- name: UpdateReadingProgress :one
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (ebook_id, user_id)
DO UPDATE SET
current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages,
last_read_at = NOW()
RETURNING id, ebook_id, user_id, current_page, total_pages, last_read_at
`
type UpdateReadingProgressParams struct {
EbookID pgtype.UUID `db:"ebook_id" json:"ebook_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
}
func (q *Queries) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) {
row := q.db.QueryRow(ctx, UpdateReadingProgress,
arg.EbookID,
arg.UserID,
arg.CurrentPage,
arg.TotalPages,
)
var i ReadingProgress
err := row.Scan(
&i.ID,
&i.EbookID,
&i.UserID,
&i.CurrentPage,
&i.TotalPages,
&i.LastReadAt,
)
return i, err
}
@@ -0,0 +1,57 @@
-- name: CreateUser :one
INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;
-- name: GetUserByEmailOrUsername :one
SELECT * FROM users WHERE email = $1 OR username = $1;
-- name: GetUser :one
SELECT id, email, username, created_at, updated_at FROM users WHERE id = $1;
-- name: GetEbook :one
SELECT * FROM ebooks WHERE id = $1;
-- name: ListEbooks :many
SELECT * FROM ebooks ORDER BY created_at DESC LIMIT $1 OFFSET $2;
-- name: CreateEbook :one
INSERT INTO ebooks (title, author, isbn, description, file_path, file_size, mime_type, cover_image_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: UpdateEbook :one
UPDATE ebooks SET
title = $2,
author = $3,
isbn = $4,
description = $5,
cover_image_path = $6,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: DeleteEbook :exec
DELETE FROM ebooks WHERE id = $1;
-- name: GetReadingProgress :one
SELECT * FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;
-- name: UpdateReadingProgress :one
INSERT INTO reading_progress (ebook_id, user_id, current_page, total_pages, last_read_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (ebook_id, user_id)
DO UPDATE SET
current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages,
last_read_at = NOW()
RETURNING *;
-- name: DeleteReadingProgress :exec
DELETE FROM reading_progress WHERE ebook_id = $1 AND user_id = $2;