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 @@ -
-
- -
- {{.Email}} -
- -
-
-
-
+ {{template "admin_nav" .}}

Панель управления

-
- {{.ProductCount}} - Товаров в каталоге +
+ {{.TotalVisits}} + Всего посещений главной +
+
+ {{.TodayVisits}} + Сегодня
- {{len .Categories}} + {{.ProductCount}} + Товаров +
+
+ {{.CategoryCount}} Категорий
- {{if .Categories}} + + + {{if .Products}}
-

Категории

+

Последние товары

+ - + + + + - {{range .Categories}} + {{range .Products}} + - + + + + {{end}}
Фото НазваниеТоваровКатегорияЦенаСтатус
+ {{if .ImageURL}} + + {{else}} + + {{end}} + {{.Name}}{{.Count}}{{.Category}}{{printf "%.0f" .Price}} ₽ + {{if .IsActive}} + Активен + {{else}} + Скрыт + {{end}} + Изменить
{{end}} - -

Администратор создаётся автоматически из переменных ADMIN_EMAIL и ADMIN_PASSWORD в .env.

diff --git a/internal/handlers/templates/admin_nav.html b/internal/handlers/templates/admin_nav.html new file mode 100644 index 0000000..54bafb4 --- /dev/null +++ b/internal/handlers/templates/admin_nav.html @@ -0,0 +1,21 @@ +{{define "admin_nav"}} +
+
+ + +
+ {{.Email}} +
+ +
+
+
+
+{{end}} diff --git a/internal/handlers/templates/admin_product_form.html b/internal/handlers/templates/admin_product_form.html new file mode 100644 index 0000000..195f7a6 --- /dev/null +++ b/internal/handlers/templates/admin_product_form.html @@ -0,0 +1,154 @@ +{{define "admin_product_form.html"}} + + + + + + {{.Title}} + + + + + + {{template "admin_nav" .}} + +
+
+

{{.Title}}

+ ← К списку +
+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ + + + + + +
+
+

Основное

+ + + +
+ + +
+ + +
+ +
+

Изображения

+ + {{if .Product.ImageURL}} +
+

Главное фото:

+ +
+ {{end}} + + + + + + {{if .Product.Images}} +
+

Загруженные скриншоты:

+
+ {{range .Product.Images}} +
+ + + + +
+ {{end}} +
+
+ {{end}} +
+
+ +
+

Краткое описание

+

Отображается в карточке товара на главной

+
{{.Product.Description}}
+
+ +
+

Полное описание

+

Подробная информация о товаре, характеристики, комплектация

+
{{.Product.Details}}
+
+ +
+ + Отмена +
+ + + {{if not .IsNew}} +
+ +
+ {{end}} +
+ + + + + +{{end}} diff --git a/internal/handlers/templates/admin_products.html b/internal/handlers/templates/admin_products.html new file mode 100644 index 0000000..fab533c --- /dev/null +++ b/internal/handlers/templates/admin_products.html @@ -0,0 +1,65 @@ +{{define "admin_products.html"}} + + + + + + {{.Title}} + + + + + {{template "admin_nav" .}} + +
+
+

Товары

+ + Добавить товар +
+ +
+ + + + + + + + + + + + + {{range .Products}} + + + + + + + + + {{else}} + + {{end}} + +
ФотоНазваниеКатегорияЦенаСтатусДействия
+ {{if .ImageURL}} + + {{else}} + + {{end}} + {{.Name}}{{.Category}}{{printf "%.0f" .Price}} ₽ + {{if .IsActive}}Активен + {{else}}Скрыт{{end}} + + Изменить +
+ +
+
Товаров пока нет. Добавить первый
+
+
+ + +{{end}} diff --git a/internal/handlers/templates/home.html b/internal/handlers/templates/home.html index b2c3ca3..399a950 100644 --- a/internal/handlers/templates/home.html +++ b/internal/handlers/templates/home.html @@ -89,7 +89,7 @@

{{.Name}}

-

{{.Description}}

+

{{plain .Description}}