diff --git a/.gitignore b/.gitignore
index 4b7ea6b..7399efc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
*.exe
/shop
/vendor/
+data/
diff --git a/cmd/shop/main.go b/cmd/shop/main.go
index 0bdb94a..72e6fcb 100644
--- a/cmd/shop/main.go
+++ b/cmd/shop/main.go
@@ -15,6 +15,7 @@ import (
"shop/internal/handlers"
"shop/internal/middleware"
"shop/internal/repository"
+ "shop/internal/upload"
)
func main() {
@@ -33,6 +34,12 @@ func main() {
productRepo := repository.NewProductRepository(pool)
adminRepo := repository.NewAdminRepository(pool)
+ statsRepo := repository.NewStatsRepository(pool)
+
+ storage, err := upload.NewStorage(cfg.UploadDir)
+ if err != nil {
+ log.Fatalf("upload storage: %v", err)
+ }
if cfg.AdminEmail != "" && cfg.AdminPassword != "" {
if err := adminRepo.Upsert(ctx, cfg.AdminEmail, cfg.AdminPassword); err != nil {
@@ -43,16 +50,17 @@ func main() {
log.Println("warning: set ADMIN_EMAIL and ADMIN_PASSWORD in .env to create admin")
}
- home, err := handlers.NewHomeHandler(productRepo)
+ home, err := handlers.NewHomeHandler(productRepo, statsRepo)
if err != nil {
log.Fatalf("handler: %v", err)
}
session := auth.NewSession(cfg.SessionSecret)
- admin := handlers.NewAdminHandler(adminRepo, productRepo, session, home.Templates())
+ admin := handlers.NewAdminHandler(adminRepo, productRepo, statsRepo, storage, session, home.Templates())
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", home.Static()))
+ mux.Handle("GET /uploads/", storage.FileServer())
mux.HandleFunc("GET /health", home.Health)
mux.HandleFunc("GET /{$}", home.Home)
@@ -61,12 +69,19 @@ func main() {
mux.HandleFunc("POST /admin/logout", admin.Logout)
mux.HandleFunc("GET /admin/{$}", admin.Dashboard)
+ mux.HandleFunc("GET /admin/products", admin.ProductList)
+ mux.HandleFunc("GET /admin/products/new", admin.ProductNew)
+ mux.HandleFunc("POST /admin/products/save", admin.ProductSave)
+ mux.HandleFunc("GET /admin/products/{id}/edit", admin.ProductEdit)
+ mux.HandleFunc("POST /admin/products/{id}/delete", admin.ProductDelete)
+ mux.HandleFunc("POST /admin/products/{id}/images/{imageId}/delete", admin.ImageDelete)
+
addr := ":" + getEnv("APP_PORT", "8080")
srv := &http.Server{
Addr: addr,
Handler: middleware.TrustProxy(mux),
- ReadTimeout: 10 * time.Second,
- WriteTimeout: 30 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}
diff --git a/docker-compose.yml b/docker-compose.yml
index eecfd16..abd6ebd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,7 @@ services:
- pgdata:/var/lib/postgresql/data
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
- ./migrations/002_admins.sql:/docker-entrypoint-initdb.d/002_admins.sql:ro
+ - ./migrations/003_admin_features.sql:/docker-entrypoint-initdb.d/003_admin_features.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"]
interval: 5s
@@ -31,6 +32,9 @@ services:
DB_HOST: db
DB_PORT: "5432"
APP_PORT: "8080"
+ UPLOAD_DIR: /app/data/uploads
+ volumes:
+ - uploads:/app/data/uploads
depends_on:
db:
condition: service_healthy
@@ -61,6 +65,7 @@ services:
volumes:
pgdata:
+ uploads:
caddy_data:
caddy_config:
diff --git a/go.mod b/go.mod
index 1c59945..ccf4a12 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module shop
go 1.23
require (
+ github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.4
golang.org/x/crypto v0.31.0
)
diff --git a/go.sum b/go.sum
index fa0f7db..d95dea0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,8 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
diff --git a/internal/config/config.go b/internal/config/config.go
index 67f1fee..b21533a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -11,6 +11,7 @@ type Config struct {
AdminEmail string
AdminPassword string
SessionSecret string
+ UploadDir string
}
func Load() Config {
@@ -18,6 +19,11 @@ func Load() Config {
AdminEmail: os.Getenv("ADMIN_EMAIL"),
AdminPassword: os.Getenv("ADMIN_PASSWORD"),
SessionSecret: os.Getenv("SESSION_SECRET"),
+ UploadDir: os.Getenv("UPLOAD_DIR"),
+ }
+
+ if cfg.UploadDir == "" {
+ cfg.UploadDir = "/app/data/uploads"
}
if cfg.SessionSecret == "" {
diff --git a/internal/database/migrate.go b/internal/database/migrate.go
index 38f0acc..0ca232b 100644
--- a/internal/database/migrate.go
+++ b/internal/database/migrate.go
@@ -7,13 +7,38 @@ import (
)
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
- _, err := pool.Exec(ctx, `
- CREATE TABLE IF NOT EXISTS admins (
+ queries := []string{
+ `CREATE TABLE IF NOT EXISTS admins (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
- );
- `)
- return err
+ )`,
+ `ALTER TABLE products ADD COLUMN IF NOT EXISTS details TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE products ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true`,
+ `ALTER TABLE products ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`,
+ `CREATE TABLE IF NOT EXISTS product_images (
+ id SERIAL PRIMARY KEY,
+ product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
+ file_path VARCHAR(512) NOT NULL,
+ is_primary BOOLEAN NOT NULL DEFAULT false,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_product_images_product ON product_images (product_id)`,
+ `CREATE TABLE IF NOT EXISTS site_stats (
+ id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+ total_visits BIGINT NOT NULL DEFAULT 0,
+ today_visits INT NOT NULL DEFAULT 0,
+ visit_date DATE NOT NULL DEFAULT CURRENT_DATE
+ )`,
+ `INSERT INTO site_stats (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
+ }
+
+ for _, q := range queries {
+ if _, err := pool.Exec(ctx, q); err != nil {
+ return err
+ }
+ }
+ return nil
}
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index ebc8bb6..ebdc8dc 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -4,18 +4,30 @@ import (
"html/template"
"log"
"net/http"
+ "strconv"
"shop/internal/auth"
+ "shop/internal/models"
"shop/internal/repository"
+ "shop/internal/upload"
)
type AdminHandler struct {
repo *repository.AdminRepository
products *repository.ProductRepository
+ stats *repository.StatsRepository
+ storage *upload.Storage
session *auth.Session
templates *template.Template
}
+type adminLayoutData struct {
+ Title string
+ Email string
+ Active string
+ Content template.HTML
+}
+
type adminLoginData struct {
Title string
Error string
@@ -26,32 +38,67 @@ type adminDashboardData struct {
Title string
Email string
ProductCount int
- Categories interface{}
+ CategoryCount int
+ TotalVisits int64
+ TodayVisits int
+ Categories []models.Category
+ Products []models.Product
+}
+
+type adminProductsData struct {
+ Title string
+ Email string
+ Products []models.Product
+}
+
+type adminProductFormData struct {
+ Title string
+ Email string
+ Product models.Product
+ IsNew bool
+ Error string
}
func NewAdminHandler(
adminRepo *repository.AdminRepository,
productRepo *repository.ProductRepository,
+ statsRepo *repository.StatsRepository,
+ storage *upload.Storage,
session *auth.Session,
templates *template.Template,
) *AdminHandler {
return &AdminHandler{
repo: adminRepo,
products: productRepo,
+ stats: statsRepo,
+ storage: storage,
session: session,
templates: templates,
}
}
+func (h *AdminHandler) requireAuth(w http.ResponseWriter, r *http.Request) (string, bool) {
+ email, ok := h.session.FromRequest(r)
+ if !ok {
+ http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
+ return "", false
+ }
+ return email, true
+}
+
+func (h *AdminHandler) render(w http.ResponseWriter, name string, data any) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := h.templates.ExecuteTemplate(w, name, data); err != nil {
+ log.Printf("render %s: %v", name, err)
+ }
+}
+
func (h *AdminHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
if email, ok := h.session.FromRequest(r); ok && email != "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
-
- data := adminLoginData{Title: "Вход в админку"}
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _ = h.templates.ExecuteTemplate(w, "admin_login.html", data)
+ h.render(w, "admin_login.html", adminLoginData{Title: "Вход в админку"})
}
func (h *AdminHandler) Login(w http.ResponseWriter, r *http.Request) {
@@ -91,14 +138,13 @@ func (h *AdminHandler) Logout(w http.ResponseWriter, r *http.Request) {
}
func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
- email, ok := h.session.FromRequest(r)
+ email, ok := h.requireAuth(w, r)
if !ok {
- http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
ctx := r.Context()
- products, err := h.products.Featured(ctx, 100)
+ products, err := h.products.All(ctx)
if err != nil {
log.Printf("admin products: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -112,24 +158,30 @@ func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
return
}
- data := adminDashboardData{
- Title: "Админ-панель",
- Email: email,
- ProductCount: len(products),
- Categories: categories,
+ stats, err := h.stats.Get(ctx)
+ if err != nil {
+ log.Printf("admin stats: %v", err)
}
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _ = h.templates.ExecuteTemplate(w, "admin_dashboard.html", data)
+ h.render(w, "admin_dashboard.html", adminDashboardData{
+ Title: "Админ-панель",
+ Email: email,
+ ProductCount: len(products),
+ CategoryCount: len(categories),
+ TotalVisits: stats.TotalVisits,
+ TodayVisits: stats.TodayVisits,
+ Categories: categories,
+ Products: products,
+ })
}
func (h *AdminHandler) renderLoginError(w http.ResponseWriter, email, msg string) {
- data := adminLoginData{
- Title: "Вход в админку",
- Error: msg,
- Email: email,
- }
+ data := adminLoginData{Title: "Вход в админку", Error: msg, Email: email}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_ = h.templates.ExecuteTemplate(w, "admin_login.html", data)
}
+
+func parseProductID(r *http.Request) (int, error) {
+ return strconv.Atoi(r.PathValue("id"))
+}
diff --git a/internal/handlers/admin_products.go b/internal/handlers/admin_products.go
new file mode 100644
index 0000000..43b5b22
--- /dev/null
+++ b/internal/handlers/admin_products.go
@@ -0,0 +1,238 @@
+package handlers
+
+import (
+ "log"
+ "net/http"
+ "strconv"
+
+ "shop/internal/models"
+)
+
+func (h *AdminHandler) ProductList(w http.ResponseWriter, r *http.Request) {
+ email, ok := h.requireAuth(w, r)
+ if !ok {
+ return
+ }
+
+ products, err := h.products.All(r.Context())
+ if err != nil {
+ log.Printf("product list: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ h.render(w, "admin_products.html", adminProductsData{
+ Title: "Товары",
+ Email: email,
+ Products: products,
+ })
+}
+
+func (h *AdminHandler) ProductNew(w http.ResponseWriter, r *http.Request) {
+ email, ok := h.requireAuth(w, r)
+ if !ok {
+ return
+ }
+
+ h.render(w, "admin_product_form.html", adminProductFormData{
+ Title: "Новый товар",
+ Email: email,
+ IsNew: true,
+ Product: models.Product{IsActive: true, Category: "Разное"},
+ })
+}
+
+func (h *AdminHandler) ProductEdit(w http.ResponseWriter, r *http.Request) {
+ email, ok := h.requireAuth(w, r)
+ if !ok {
+ return
+ }
+
+ id, err := parseProductID(r)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ product, err := h.products.GetByID(r.Context(), id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ h.render(w, "admin_product_form.html", adminProductFormData{
+ Title: "Редактирование товара",
+ Email: email,
+ Product: product,
+ })
+}
+
+func (h *AdminHandler) ProductSave(w http.ResponseWriter, r *http.Request) {
+ if _, ok := h.requireAuth(w, r); !ok {
+ return
+ }
+
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ return
+ }
+
+ ctx := r.Context()
+ isNew := r.FormValue("is_new") == "1"
+ productID, _ := strconv.Atoi(r.FormValue("id"))
+
+ price, _ := strconv.ParseFloat(r.FormValue("price"), 64)
+ product := models.Product{
+ ID: productID,
+ Name: r.FormValue("name"),
+ Description: r.FormValue("description"),
+ Details: r.FormValue("details"),
+ Price: price,
+ Category: r.FormValue("category"),
+ IsActive: r.FormValue("is_active") == "on",
+ ImageURL: r.FormValue("image_url"),
+ }
+
+ if product.Name == "" || product.Price < 0 {
+ email, _ := h.requireAuth(w, r)
+ h.render(w, "admin_product_form.html", adminProductFormData{
+ Title: "Ошибка",
+ Email: email,
+ Product: product,
+ IsNew: isNew,
+ Error: "Заполните название и цену",
+ })
+ return
+ }
+
+ if isNew {
+ if err := h.products.Create(ctx, &product); err != nil {
+ log.Printf("product create: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ } else {
+ existing, err := h.products.GetByID(ctx, product.ID)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ if product.ImageURL == "" {
+ product.ImageURL = existing.ImageURL
+ }
+ if err := h.products.Update(ctx, &product); err != nil {
+ log.Printf("product update: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ }
+
+ if err := h.saveUploadedImages(r, product.ID, true); err != nil {
+ log.Printf("upload main image: %v", err)
+ }
+ if err := h.saveUploadedImages(r, product.ID, false); err != nil {
+ log.Printf("upload screenshots: %v", err)
+ }
+
+ http.Redirect(w, r, "/admin/products/"+strconv.Itoa(product.ID)+"/edit", http.StatusSeeOther)
+}
+
+func (h *AdminHandler) ProductDelete(w http.ResponseWriter, r *http.Request) {
+ if _, ok := h.requireAuth(w, r); !ok {
+ return
+ }
+
+ id, err := parseProductID(r)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ ctx := r.Context()
+ product, err := h.products.GetByID(ctx, id)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ for _, img := range product.Images {
+ _ = h.storage.Remove(img.FilePath)
+ }
+ if product.ImageURL != "" && len(product.Images) == 0 {
+ _ = h.storage.Remove(product.ImageURL)
+ }
+
+ if err := h.products.Delete(ctx, id); err != nil {
+ log.Printf("product delete: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(w, r, "/admin/products", http.StatusSeeOther)
+}
+
+func (h *AdminHandler) ImageDelete(w http.ResponseWriter, r *http.Request) {
+ if _, ok := h.requireAuth(w, r); !ok {
+ return
+ }
+
+ productID, err := parseProductID(r)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ imageID, err := strconv.Atoi(r.PathValue("imageId"))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ path, err := h.products.DeleteImage(r.Context(), imageID, productID)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ _ = h.storage.Remove(path)
+
+ http.Redirect(w, r, "/admin/products/"+strconv.Itoa(productID)+"/edit", http.StatusSeeOther)
+}
+
+func (h *AdminHandler) saveUploadedImages(r *http.Request, productID int, primary bool) error {
+ ctx := r.Context()
+ field := "screenshots"
+ if primary {
+ field = "main_image"
+ }
+
+ files := r.MultipartForm.File[field]
+ for i, fh := range files {
+ file, err := fh.Open()
+ if err != nil {
+ return err
+ }
+
+ webPath, err := h.storage.SaveProductImage(productID, file, fh)
+ file.Close()
+ if err != nil {
+ return err
+ }
+
+ img := models.ProductImage{
+ ProductID: productID,
+ FilePath: webPath,
+ IsPrimary: primary && i == 0,
+ SortOrder: i,
+ }
+ if err := h.products.AddImage(ctx, &img); err != nil {
+ return err
+ }
+
+ if primary && i == 0 {
+ product, _ := h.products.GetByID(ctx, productID)
+ product.ImageURL = webPath
+ _ = h.products.Update(ctx, &product)
+ }
+ }
+ return nil
+}
diff --git a/internal/handlers/home.go b/internal/handlers/home.go
index d4be74b..c825f09 100644
--- a/internal/handlers/home.go
+++ b/internal/handlers/home.go
@@ -6,6 +6,7 @@ import (
"io/fs"
"log"
"net/http"
+ "regexp"
"shop/internal/repository"
)
@@ -18,6 +19,7 @@ var staticFS embed.FS
type HomeHandler struct {
repo *repository.ProductRepository
+ stats *repository.StatsRepository
templates *template.Template
}
@@ -27,12 +29,21 @@ type homePageData struct {
Categories interface{}
}
-func NewHomeHandler(repo *repository.ProductRepository) (*HomeHandler, error) {
- tmpl, err := template.ParseFS(templateFS, "templates/*.html")
+func NewHomeHandler(repo *repository.ProductRepository, stats *repository.StatsRepository) (*HomeHandler, error) {
+ funcMap := template.FuncMap{
+ "plain": stripHTML,
+ }
+ tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
}
- return &HomeHandler{repo: repo, templates: tmpl}, nil
+ return &HomeHandler{repo: repo, stats: stats, templates: tmpl}, nil
+}
+
+var tagRe = regexp.MustCompile(`<[^>]*>`)
+
+func stripHTML(s string) string {
+ return tagRe.ReplaceAllString(s, "")
}
func (h *HomeHandler) Templates() *template.Template {
@@ -55,6 +66,10 @@ func (h *HomeHandler) Home(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+ if err := h.stats.IncrementHomeVisit(ctx); err != nil {
+ log.Printf("visit counter: %v", err)
+ }
+
products, err := h.repo.Featured(ctx, 8)
if err != nil {
log.Printf("featured products: %v", err)
diff --git a/internal/handlers/static/css/admin.css b/internal/handlers/static/css/admin.css
index d2820e5..fa7f5a6 100644
--- a/internal/handlers/static/css/admin.css
+++ b/internal/handlers/static/css/admin.css
@@ -194,3 +194,223 @@
border-radius: 4px;
font-size: 0.8rem;
}
+
+.admin-nav {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.admin-nav__link {
+ padding: 8px 14px;
+ border-radius: 8px;
+ text-decoration: none;
+ color: #6b7280;
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+
+.admin-nav__link:hover {
+ background: #f3f4f6;
+ color: #4f46e5;
+}
+
+.admin-nav__link--accent {
+ color: #4f46e5;
+ background: #eef2ff;
+}
+
+.admin-stat--highlight .admin-stat__value {
+ color: #059669;
+}
+
+.admin-actions {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 32px;
+}
+
+.admin-page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.admin-page-header .admin-page-title {
+ margin-bottom: 0;
+}
+
+.admin-thumb {
+ width: 48px;
+ height: 48px;
+ object-fit: cover;
+ border-radius: 8px;
+ border: 1px solid #e5e7eb;
+}
+
+.admin-thumb--empty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: #f3f4f6;
+ color: #9ca3af;
+ font-size: 0.8rem;
+}
+
+.admin-badge {
+ display: inline-block;
+ padding: 4px 10px;
+ border-radius: 100px;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+.admin-badge--ok {
+ background: #d1fae5;
+ color: #065f46;
+}
+
+.admin-badge--off {
+ background: #f3f4f6;
+ color: #6b7280;
+}
+
+.admin-table__actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.admin-table__actions form {
+ margin: 0;
+}
+
+.btn--danger {
+ background: #fef2f2;
+ color: #b91c1c;
+ border: 1px solid #fecaca;
+}
+
+.btn--danger:hover {
+ background: #fee2e2;
+}
+
+.admin-empty {
+ text-align: center;
+ color: #9ca3af;
+ padding: 32px !important;
+}
+
+.admin-form-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ margin-bottom: 24px;
+}
+
+.admin-form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.admin-form__checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.admin-form__file {
+ padding: 8px;
+}
+
+.admin-form__hint {
+ font-size: 0.85rem;
+ color: #9ca3af;
+ margin-bottom: 8px;
+}
+
+.admin-editor {
+ min-height: 160px;
+ background: #fff;
+}
+
+.admin-editor .ql-editor {
+ min-height: 140px;
+}
+
+.admin-preview {
+ max-width: 200px;
+ border-radius: 8px;
+ border: 1px solid #e5e7eb;
+ margin-bottom: 12px;
+}
+
+.admin-screenshots__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.admin-screenshot {
+ position: relative;
+ aspect-ratio: 1;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid #e5e7eb;
+}
+
+.admin-screenshot img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.admin-screenshot__delete {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 24px;
+ height: 24px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(0,0,0,0.6);
+ color: #fff;
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+}
+
+.admin-form-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+.admin-delete-form {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid #e5e7eb;
+}
+
+.admin-product-form {
+ margin-bottom: 40px;
+}
+
+@media (max-width: 768px) {
+ .admin-form-grid,
+ .admin-form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-nav {
+ display: none;
+ }
+}
diff --git a/internal/handlers/templates/admin_dashboard.html b/internal/handlers/templates/admin_dashboard.html
index c332b24..c2be61e 100644
--- a/internal/handlers/templates/admin_dashboard.html
+++ b/internal/handlers/templates/admin_dashboard.html
@@ -9,58 +9,77 @@
-
+ {{template "admin_nav" .}}
Панель управления
-
-
{{.ProductCount}}
-
Товаров в каталоге
+
+ {{.TotalVisits}}
+ Всего посещений главной
+
+
+ {{.TodayVisits}}
+ Сегодня
- {{len .Categories}}
+ {{.ProductCount}}
+ Товаров
+
+
+ {{.CategoryCount}}
Категорий
- {{if .Categories}}
+
+
+ {{if .Products}}
- Категории
+ Последние товары
+ | Фото |
Название |
- Товаров |
+ Категория |
+ Цена |
+ Статус |
+ |
- {{range .Categories}}
+ {{range .Products}}
+
+ {{if .ImageURL}}
+
+ {{else}}
+ —
+ {{end}}
+ |
{{.Name}} |
- {{.Count}} |
+ {{.Category}} |
+ {{printf "%.0f" .Price}} ₽ |
+
+ {{if .IsActive}}
+ Активен
+ {{else}}
+ Скрыт
+ {{end}}
+ |
+ Изменить |
{{end}}
{{end}}
-
-
Администратор создаётся автоматически из переменных ADMIN_EMAIL и ADMIN_PASSWORD в .env.