Add admin panel: visit counter, product editor, screenshots upload

This commit is contained in:
shop
2026-06-25 16:59:21 +03:00
parent 6322aee714
commit 08727a1995
21 changed files with 1209 additions and 67 deletions
+1
View File
@@ -2,3 +2,4 @@
*.exe *.exe
/shop /shop
/vendor/ /vendor/
data/
+19 -4
View File
@@ -15,6 +15,7 @@ import (
"shop/internal/handlers" "shop/internal/handlers"
"shop/internal/middleware" "shop/internal/middleware"
"shop/internal/repository" "shop/internal/repository"
"shop/internal/upload"
) )
func main() { func main() {
@@ -33,6 +34,12 @@ func main() {
productRepo := repository.NewProductRepository(pool) productRepo := repository.NewProductRepository(pool)
adminRepo := repository.NewAdminRepository(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 cfg.AdminEmail != "" && cfg.AdminPassword != "" {
if err := adminRepo.Upsert(ctx, cfg.AdminEmail, cfg.AdminPassword); err != nil { 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") 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 { if err != nil {
log.Fatalf("handler: %v", err) log.Fatalf("handler: %v", err)
} }
session := auth.NewSession(cfg.SessionSecret) 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 := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", home.Static())) mux.Handle("GET /static/", http.StripPrefix("/static/", home.Static()))
mux.Handle("GET /uploads/", storage.FileServer())
mux.HandleFunc("GET /health", home.Health) mux.HandleFunc("GET /health", home.Health)
mux.HandleFunc("GET /{$}", home.Home) mux.HandleFunc("GET /{$}", home.Home)
@@ -61,12 +69,19 @@ func main() {
mux.HandleFunc("POST /admin/logout", admin.Logout) mux.HandleFunc("POST /admin/logout", admin.Logout)
mux.HandleFunc("GET /admin/{$}", admin.Dashboard) 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") addr := ":" + getEnv("APP_PORT", "8080")
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: middleware.TrustProxy(mux), Handler: middleware.TrustProxy(mux),
ReadTimeout: 10 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
+5
View File
@@ -12,6 +12,7 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
- ./migrations/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro - ./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/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: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"]
interval: 5s interval: 5s
@@ -31,6 +32,9 @@ services:
DB_HOST: db DB_HOST: db
DB_PORT: "5432" DB_PORT: "5432"
APP_PORT: "8080" APP_PORT: "8080"
UPLOAD_DIR: /app/data/uploads
volumes:
- uploads:/app/data/uploads
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -61,6 +65,7 @@ services:
volumes: volumes:
pgdata: pgdata:
uploads:
caddy_data: caddy_data:
caddy_config: caddy_config:
+1
View File
@@ -3,6 +3,7 @@ module shop
go 1.23 go 1.23
require ( require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.4 github.com/jackc/pgx/v5 v5.7.4
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
) )
+2
View File
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+6
View File
@@ -11,6 +11,7 @@ type Config struct {
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
SessionSecret string SessionSecret string
UploadDir string
} }
func Load() Config { func Load() Config {
@@ -18,6 +19,11 @@ func Load() Config {
AdminEmail: os.Getenv("ADMIN_EMAIL"), AdminEmail: os.Getenv("ADMIN_EMAIL"),
AdminPassword: os.Getenv("ADMIN_PASSWORD"), AdminPassword: os.Getenv("ADMIN_PASSWORD"),
SessionSecret: os.Getenv("SESSION_SECRET"), SessionSecret: os.Getenv("SESSION_SECRET"),
UploadDir: os.Getenv("UPLOAD_DIR"),
}
if cfg.UploadDir == "" {
cfg.UploadDir = "/app/data/uploads"
} }
if cfg.SessionSecret == "" { if cfg.SessionSecret == "" {
+30 -5
View File
@@ -7,13 +7,38 @@ import (
) )
func Migrate(ctx context.Context, pool *pgxpool.Pool) error { func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
_, err := pool.Exec(ctx, ` queries := []string{
CREATE TABLE IF NOT EXISTS admins ( `CREATE TABLE IF NOT EXISTS admins (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); )`,
`) `ALTER TABLE products ADD COLUMN IF NOT EXISTS details TEXT NOT NULL DEFAULT ''`,
return err `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
} }
+72 -20
View File
@@ -4,18 +4,30 @@ import (
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"strconv"
"shop/internal/auth" "shop/internal/auth"
"shop/internal/models"
"shop/internal/repository" "shop/internal/repository"
"shop/internal/upload"
) )
type AdminHandler struct { type AdminHandler struct {
repo *repository.AdminRepository repo *repository.AdminRepository
products *repository.ProductRepository products *repository.ProductRepository
stats *repository.StatsRepository
storage *upload.Storage
session *auth.Session session *auth.Session
templates *template.Template templates *template.Template
} }
type adminLayoutData struct {
Title string
Email string
Active string
Content template.HTML
}
type adminLoginData struct { type adminLoginData struct {
Title string Title string
Error string Error string
@@ -26,32 +38,67 @@ type adminDashboardData struct {
Title string Title string
Email string Email string
ProductCount int 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( func NewAdminHandler(
adminRepo *repository.AdminRepository, adminRepo *repository.AdminRepository,
productRepo *repository.ProductRepository, productRepo *repository.ProductRepository,
statsRepo *repository.StatsRepository,
storage *upload.Storage,
session *auth.Session, session *auth.Session,
templates *template.Template, templates *template.Template,
) *AdminHandler { ) *AdminHandler {
return &AdminHandler{ return &AdminHandler{
repo: adminRepo, repo: adminRepo,
products: productRepo, products: productRepo,
stats: statsRepo,
storage: storage,
session: session, session: session,
templates: templates, 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) { func (h *AdminHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
if email, ok := h.session.FromRequest(r); ok && email != "" { if email, ok := h.session.FromRequest(r); ok && email != "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther) http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return return
} }
h.render(w, "admin_login.html", adminLoginData{Title: "Вход в админку"})
data := adminLoginData{Title: "Вход в админку"}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = h.templates.ExecuteTemplate(w, "admin_login.html", data)
} }
func (h *AdminHandler) Login(w http.ResponseWriter, r *http.Request) { 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) { func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
email, ok := h.session.FromRequest(r) email, ok := h.requireAuth(w, r)
if !ok { if !ok {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return return
} }
ctx := r.Context() ctx := r.Context()
products, err := h.products.Featured(ctx, 100) products, err := h.products.All(ctx)
if err != nil { if err != nil {
log.Printf("admin products: %v", err) log.Printf("admin products: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -112,24 +158,30 @@ func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
return return
} }
data := adminDashboardData{ stats, err := h.stats.Get(ctx)
Title: "Админ-панель", if err != nil {
Email: email, log.Printf("admin stats: %v", err)
ProductCount: len(products),
Categories: categories,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") h.render(w, "admin_dashboard.html", adminDashboardData{
_ = h.templates.ExecuteTemplate(w, "admin_dashboard.html", data) 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) { func (h *AdminHandler) renderLoginError(w http.ResponseWriter, email, msg string) {
data := adminLoginData{ data := adminLoginData{Title: "Вход в админку", Error: msg, Email: email}
Title: "Вход в админку",
Error: msg,
Email: email,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
_ = h.templates.ExecuteTemplate(w, "admin_login.html", data) _ = h.templates.ExecuteTemplate(w, "admin_login.html", data)
} }
func parseProductID(r *http.Request) (int, error) {
return strconv.Atoi(r.PathValue("id"))
}
+238
View File
@@ -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
}
+18 -3
View File
@@ -6,6 +6,7 @@ import (
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"regexp"
"shop/internal/repository" "shop/internal/repository"
) )
@@ -18,6 +19,7 @@ var staticFS embed.FS
type HomeHandler struct { type HomeHandler struct {
repo *repository.ProductRepository repo *repository.ProductRepository
stats *repository.StatsRepository
templates *template.Template templates *template.Template
} }
@@ -27,12 +29,21 @@ type homePageData struct {
Categories interface{} Categories interface{}
} }
func NewHomeHandler(repo *repository.ProductRepository) (*HomeHandler, error) { func NewHomeHandler(repo *repository.ProductRepository, stats *repository.StatsRepository) (*HomeHandler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html") funcMap := template.FuncMap{
"plain": stripHTML,
}
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
if err != nil { if err != nil {
return nil, err 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 { func (h *HomeHandler) Templates() *template.Template {
@@ -55,6 +66,10 @@ func (h *HomeHandler) Home(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
if err := h.stats.IncrementHomeVisit(ctx); err != nil {
log.Printf("visit counter: %v", err)
}
products, err := h.repo.Featured(ctx, 8) products, err := h.repo.Featured(ctx, 8)
if err != nil { if err != nil {
log.Printf("featured products: %v", err) log.Printf("featured products: %v", err)
+220
View File
@@ -194,3 +194,223 @@
border-radius: 4px; border-radius: 4px;
font-size: 0.8rem; 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;
}
}
@@ -9,58 +9,77 @@
<link rel="stylesheet" href="/static/css/admin.css"> <link rel="stylesheet" href="/static/css/admin.css">
</head> </head>
<body class="admin-body"> <body class="admin-body">
<header class="admin-header"> {{template "admin_nav" .}}
<div class="container admin-header__inner">
<a href="/admin/" class="logo">
<span class="logo__icon"></span>
<span class="logo__text">Shop Admin</span>
</a>
<div class="admin-header__user">
<span>{{.Email}}</span>
<form method="POST" action="/admin/logout">
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
</form>
</div>
</div>
</header>
<main class="admin-main container"> <main class="admin-main container">
<h1 class="admin-page-title">Панель управления</h1> <h1 class="admin-page-title">Панель управления</h1>
<div class="admin-stats"> <div class="admin-stats">
<div class="admin-stat"> <div class="admin-stat admin-stat--highlight">
<span class="admin-stat__value">{{.ProductCount}}</span> <span class="admin-stat__value">{{.TotalVisits}}</span>
<span class="admin-stat__label">Товаров в каталоге</span> <span class="admin-stat__label">Всего посещений главной</span>
</div>
<div class="admin-stat admin-stat--highlight">
<span class="admin-stat__value">{{.TodayVisits}}</span>
<span class="admin-stat__label">Сегодня</span>
</div> </div>
<div class="admin-stat"> <div class="admin-stat">
<span class="admin-stat__value">{{len .Categories}}</span> <span class="admin-stat__value">{{.ProductCount}}</span>
<span class="admin-stat__label">Товаров</span>
</div>
<div class="admin-stat">
<span class="admin-stat__value">{{.CategoryCount}}</span>
<span class="admin-stat__label">Категорий</span> <span class="admin-stat__label">Категорий</span>
</div> </div>
</div> </div>
{{if .Categories}} <div class="admin-actions">
<a href="/admin/products/new" class="btn btn--primary">+ Добавить товар</a>
<a href="/admin/products" class="btn btn--outline">Все товары</a>
<a href="/" class="btn btn--ghost" target="_blank">Открыть магазин ↗</a>
</div>
{{if .Products}}
<section class="admin-section"> <section class="admin-section">
<h2 class="admin-section__title">Категории</h2> <h2 class="admin-section__title">Последние товары</h2>
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
<th>Фото</th>
<th>Название</th> <th>Название</th>
<th>Товаров</th> <th>Категория</th>
<th>Цена</th>
<th>Статус</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .Categories}} {{range .Products}}
<tr> <tr>
<td>
{{if .ImageURL}}
<img src="{{.ImageURL}}" alt="" class="admin-thumb">
{{else}}
<span class="admin-thumb admin-thumb--empty"></span>
{{end}}
</td>
<td>{{.Name}}</td> <td>{{.Name}}</td>
<td>{{.Count}}</td> <td>{{.Category}}</td>
<td>{{printf "%.0f" .Price}} ₽</td>
<td>
{{if .IsActive}}
<span class="admin-badge admin-badge--ok">Активен</span>
{{else}}
<span class="admin-badge admin-badge--off">Скрыт</span>
{{end}}
</td>
<td><a href="/admin/products/{{.ID}}/edit" class="link">Изменить</a></td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</section> </section>
{{end}} {{end}}
<p class="admin-hint">Администратор создаётся автоматически из переменных <code>ADMIN_EMAIL</code> и <code>ADMIN_PASSWORD</code> в <code>.env</code>.</p>
</main> </main>
</body> </body>
</html> </html>
@@ -0,0 +1,21 @@
{{define "admin_nav"}}
<header class="admin-header">
<div class="container admin-header__inner">
<a href="/admin/" class="logo">
<span class="logo__icon"></span>
<span class="logo__text">Shop Admin</span>
</a>
<nav class="admin-nav">
<a href="/admin/" class="admin-nav__link">Главная</a>
<a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/admin/products/new" class="admin-nav__link admin-nav__link--accent">+ Добавить</a>
</nav>
<div class="admin-header__user">
<span>{{.Email}}</span>
<form method="POST" action="/admin/logout">
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
</form>
</div>
</div>
</header>
{{end}}
@@ -0,0 +1,154 @@
{{define "admin_product_form.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.snow.css" rel="stylesheet">
</head>
<body class="admin-body">
{{template "admin_nav" .}}
<main class="admin-main container">
<div class="admin-page-header">
<h1 class="admin-page-title">{{.Title}}</h1>
<a href="/admin/products" class="btn btn--ghost">К списку</a>
</div>
{{if .Error}}
<div class="admin-alert admin-alert--error">{{.Error}}</div>
{{end}}
<form method="POST" action="/admin/products/save" enctype="multipart/form-data" class="admin-product-form">
<input type="hidden" name="is_new" value="{{if .IsNew}}1{{else}}0{{end}}">
<input type="hidden" name="id" value="{{.Product.ID}}">
<input type="hidden" name="image_url" value="{{.Product.ImageURL}}">
<input type="hidden" name="description" id="description-input" value="">
<input type="hidden" name="details" id="details-input" value="">
<div class="admin-form-grid">
<section class="admin-section admin-form-col">
<h2 class="admin-section__title">Основное</h2>
<label class="admin-form__label">
Название *
<input type="text" name="name" class="admin-form__input" value="{{.Product.Name}}" required>
</label>
<div class="admin-form-row">
<label class="admin-form__label">
Цена (₽) *
<input type="number" name="price" class="admin-form__input" value="{{printf "%.0f" .Product.Price}}" min="0" step="1" required>
</label>
<label class="admin-form__label">
Категория
<input type="text" name="category" class="admin-form__input" value="{{.Product.Category}}" list="categories">
<datalist id="categories">
<option value="Электроника">
<option value="Одежда">
<option value="Дом и сад">
<option value="Аксессуары">
<option value="Разное">
</datalist>
</label>
</div>
<label class="admin-form__checkbox">
<input type="checkbox" name="is_active" {{if .Product.IsActive}}checked{{end}}>
Показывать в каталоге
</label>
</section>
<section class="admin-section admin-form-col">
<h2 class="admin-section__title">Изображения</h2>
{{if .Product.ImageURL}}
<div class="admin-current-image">
<p class="admin-form__hint">Главное фото:</p>
<img src="{{.Product.ImageURL}}" alt="" class="admin-preview">
</div>
{{end}}
<label class="admin-form__label">
Главное фото
<input type="file" name="main_image" class="admin-form__input admin-form__file" accept="image/jpeg,image/png,image/webp,image/gif">
</label>
<label class="admin-form__label">
Скриншоты (можно несколько)
<input type="file" name="screenshots" class="admin-form__input admin-form__file" accept="image/jpeg,image/png,image/webp,image/gif" multiple>
</label>
{{if .Product.Images}}
<div class="admin-screenshots">
<p class="admin-form__hint">Загруженные скриншоты:</p>
<div class="admin-screenshots__grid">
{{range .Product.Images}}
<div class="admin-screenshot">
<img src="{{.FilePath}}" alt="">
<form method="POST" action="/admin/products/{{.ProductID}}/images/{{.ID}}/delete">
<button type="submit" class="admin-screenshot__delete" title="Удалить">×</button>
</form>
</div>
{{end}}
</div>
</div>
{{end}}
</section>
</div>
<section class="admin-section">
<h2 class="admin-section__title">Краткое описание</h2>
<p class="admin-form__hint">Отображается в карточке товара на главной</p>
<div id="editor-description" class="admin-editor">{{.Product.Description}}</div>
</section>
<section class="admin-section">
<h2 class="admin-section__title">Полное описание</h2>
<p class="admin-form__hint">Подробная информация о товаре, характеристики, комплектация</p>
<div id="editor-details" class="admin-editor">{{.Product.Details}}</div>
</section>
<div class="admin-form-actions">
<button type="submit" class="btn btn--primary btn--lg">Сохранить товар</button>
<a href="/admin/products" class="btn btn--ghost">Отмена</a>
</div>
</form>
{{if not .IsNew}}
<form method="POST" action="/admin/products/{{.Product.ID}}/delete" class="admin-delete-form" onsubmit="return confirm('Удалить товар навсегда?')">
<button type="submit" class="btn btn--danger">Удалить товар</button>
</form>
{{end}}
</main>
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js"></script>
<script>
const descEditor = new Quill('#editor-description', {
theme: 'snow',
placeholder: 'Краткое описание товара...',
modules: { toolbar: [['bold', 'italic'], ['link'], [{ list: 'ordered' }, { list: 'bullet' }]] }
});
const detailsEditor = new Quill('#editor-details', {
theme: 'snow',
placeholder: 'Полное описание, характеристики, условия...',
modules: { toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'blockquote'],
['clean']
]}
});
document.querySelector('.admin-product-form').addEventListener('submit', () => {
document.getElementById('description-input').value = descEditor.root.innerHTML;
document.getElementById('details-input').value = detailsEditor.root.innerHTML;
});
</script>
</body>
</html>
{{end}}
@@ -0,0 +1,65 @@
{{define "admin_products.html"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body class="admin-body">
{{template "admin_nav" .}}
<main class="admin-main container">
<div class="admin-page-header">
<h1 class="admin-page-title">Товары</h1>
<a href="/admin/products/new" class="btn btn--primary">+ Добавить товар</a>
</div>
<section class="admin-section">
<table class="admin-table">
<thead>
<tr>
<th>Фото</th>
<th>Название</th>
<th>Категория</th>
<th>Цена</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{{range .Products}}
<tr>
<td>
{{if .ImageURL}}
<img src="{{.ImageURL}}" alt="" class="admin-thumb">
{{else}}
<span class="admin-thumb admin-thumb--empty"></span>
{{end}}
</td>
<td><strong>{{.Name}}</strong></td>
<td>{{.Category}}</td>
<td>{{printf "%.0f" .Price}} ₽</td>
<td>
{{if .IsActive}}<span class="admin-badge admin-badge--ok">Активен</span>
{{else}}<span class="admin-badge admin-badge--off">Скрыт</span>{{end}}
</td>
<td class="admin-table__actions">
<a href="/admin/products/{{.ID}}/edit" class="btn btn--ghost btn--sm">Изменить</a>
<form method="POST" action="/admin/products/{{.ID}}/delete" onsubmit="return confirm('Удалить товар?')">
<button type="submit" class="btn btn--danger btn--sm">Удалить</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="6" class="admin-empty">Товаров пока нет. <a href="/admin/products/new">Добавить первый</a></td></tr>
{{end}}
</tbody>
</table>
</section>
</main>
</body>
</html>
{{end}}
+1 -1
View File
@@ -89,7 +89,7 @@
</div> </div>
<div class="product-card__body"> <div class="product-card__body">
<h3 class="product-card__title">{{.Name}}</h3> <h3 class="product-card__title">{{.Name}}</h3>
<p class="product-card__desc">{{.Description}}</p> <p class="product-card__desc">{{plain .Description}}</p>
<div class="product-card__footer"> <div class="product-card__footer">
<span class="product-card__price">{{printf "%.0f" .Price}} ₽</span> <span class="product-card__price">{{printf "%.0f" .Price}} ₽</span>
<button class="btn btn--primary btn--sm">В корзину</button> <button class="btn btn--primary btn--sm">В корзину</button>
+18
View File
@@ -6,10 +6,23 @@ type Product struct {
ID int ID int
Name string Name string
Description string Description string
Details string
Price float64 Price float64
ImageURL string ImageURL string
Category string Category string
IsActive bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time
Images []ProductImage
}
type ProductImage struct {
ID int
ProductID int
FilePath string
IsPrimary bool
SortOrder int
CreatedAt time.Time
} }
type Category struct { type Category struct {
@@ -17,3 +30,8 @@ type Category struct {
Slug string Slug string
Count int Count int
} }
type SiteStats struct {
TotalVisits int64
TodayVisits int
}
+134 -9
View File
@@ -2,7 +2,10 @@ package repository
import ( import (
"context" "context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models" "shop/internal/models"
@@ -16,10 +19,22 @@ func NewProductRepository(pool *pgxpool.Pool) *ProductRepository {
return &ProductRepository{pool: pool} return &ProductRepository{pool: pool}
} }
func (r *ProductRepository) scanProduct(row pgx.Row) (models.Product, error) {
var p models.Product
err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price,
&p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
)
return p, err
}
const productColumns = `id, name, description, details, price, image_url, category, is_active, created_at, updated_at`
func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) { func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, name, description, price, image_url, category, created_at SELECT `+productColumns+`
FROM products FROM products
WHERE is_active = true
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 LIMIT $1
`, limit) `, limit)
@@ -27,16 +42,66 @@ func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.P
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
return r.collectProducts(rows)
}
var products []models.Product func (r *ProductRepository) All(ctx context.Context) ([]models.Product, error) {
for rows.Next() { rows, err := r.pool.Query(ctx, `
var p models.Product SELECT `+productColumns+`
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Price, &p.ImageURL, &p.Category, &p.CreatedAt); err != nil { FROM products
return nil, err ORDER BY created_at DESC
} `)
products = append(products, p) if err != nil {
return nil, err
} }
return products, rows.Err() defer rows.Close()
return r.collectProducts(rows)
}
func (r *ProductRepository) GetByID(ctx context.Context, id int) (models.Product, error) {
row := r.pool.QueryRow(ctx, `SELECT `+productColumns+` FROM products WHERE id = $1`, id)
p, err := r.scanProduct(row)
if err != nil {
return models.Product{}, err
}
p.Images, err = r.ImagesByProduct(ctx, id)
return p, err
}
func (r *ProductRepository) Create(ctx context.Context, p *models.Product) error {
return r.pool.QueryRow(ctx, `
INSERT INTO products (name, description, details, price, image_url, category, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
func (r *ProductRepository) Update(ctx context.Context, p *models.Product) error {
tag, err := r.pool.Exec(ctx, `
UPDATE products
SET name = $1, description = $2, details = $3, price = $4,
image_url = $5, category = $6, is_active = $7, updated_at = NOW()
WHERE id = $8
`, p.Name, p.Description, p.Details, p.Price, p.ImageURL, p.Category, p.IsActive, p.ID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (r *ProductRepository) Delete(ctx context.Context, id int) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM products WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
} }
func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category, error) { func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category, error) {
@@ -62,3 +127,63 @@ func (r *ProductRepository) Categories(ctx context.Context) ([]models.Category,
} }
return categories, rows.Err() return categories, rows.Err()
} }
func (r *ProductRepository) ImagesByProduct(ctx context.Context, productID int) ([]models.ProductImage, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, product_id, file_path, is_primary, sort_order, created_at
FROM product_images
WHERE product_id = $1
ORDER BY is_primary DESC, sort_order ASC, id ASC
`, productID)
if err != nil {
return nil, err
}
defer rows.Close()
var images []models.ProductImage
for rows.Next() {
var img models.ProductImage
if err := rows.Scan(&img.ID, &img.ProductID, &img.FilePath, &img.IsPrimary, &img.SortOrder, &img.CreatedAt); err != nil {
return nil, err
}
images = append(images, img)
}
return images, rows.Err()
}
func (r *ProductRepository) AddImage(ctx context.Context, img *models.ProductImage) error {
return r.pool.QueryRow(ctx, `
INSERT INTO product_images (product_id, file_path, is_primary, sort_order)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at
`, img.ProductID, img.FilePath, img.IsPrimary, img.SortOrder,
).Scan(&img.ID, &img.CreatedAt)
}
func (r *ProductRepository) DeleteImage(ctx context.Context, imageID, productID int) (string, error) {
var path string
err := r.pool.QueryRow(ctx, `
DELETE FROM product_images
WHERE id = $1 AND product_id = $2
RETURNING file_path
`, imageID, productID).Scan(&path)
if errors.Is(err, pgx.ErrNoRows) {
return "", fmt.Errorf("image not found")
}
return path, err
}
func (r *ProductRepository) collectProducts(rows pgx.Rows) ([]models.Product, error) {
var products []models.Product
for rows.Next() {
var p models.Product
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Details, &p.Price,
&p.ImageURL, &p.Category, &p.IsActive, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
products = append(products, p)
}
return products, rows.Err()
}
+50
View File
@@ -0,0 +1,50 @@
package repository
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type StatsRepository struct {
pool *pgxpool.Pool
}
func NewStatsRepository(pool *pgxpool.Pool) *StatsRepository {
return &StatsRepository{pool: pool}
}
func (r *StatsRepository) IncrementHomeVisit(ctx context.Context) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO site_stats (id, total_visits, today_visits, visit_date)
VALUES (1, 1, 1, CURRENT_DATE)
ON CONFLICT (id) DO UPDATE SET
total_visits = site_stats.total_visits + 1,
today_visits = CASE
WHEN site_stats.visit_date = CURRENT_DATE THEN site_stats.today_visits + 1
ELSE 1
END,
visit_date = CURRENT_DATE
`)
return err
}
func (r *StatsRepository) Get(ctx context.Context) (models.SiteStats, error) {
var s models.SiteStats
err := r.pool.QueryRow(ctx, `
SELECT total_visits,
CASE WHEN visit_date = CURRENT_DATE THEN today_visits ELSE 0 END
FROM site_stats WHERE id = 1
`).Scan(&s.TotalVisits, &s.TodayVisits)
if errors.Is(err, pgx.ErrNoRows) {
return models.SiteStats{}, nil
}
if err != nil {
return models.SiteStats{}, err
}
return s, nil
}
+85
View File
@@ -0,0 +1,85 @@
package upload
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
var allowedTypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
}
type Storage struct {
RootDir string
}
func NewStorage(root string) (*Storage, error) {
if err := os.MkdirAll(root, 0755); err != nil {
return nil, err
}
return &Storage{RootDir: root}, nil
}
func (s *Storage) SaveProductImage(productID int, file multipart.File, header *multipart.FileHeader) (string, error) {
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
contentType := http.DetectContentType(buf[:n])
ext, ok := allowedTypes[contentType]
if !ok {
return "", fmt.Errorf("недопустимый тип файла: %s", contentType)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
dir := filepath.Join(s.RootDir, "products", fmt.Sprintf("%d", productID))
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
name := uuid.New().String() + ext
fullPath := filepath.Join(dir, name)
dst, err := os.Create(fullPath)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, io.MultiReader(bytes.NewReader(buf[:n]), file)); err != nil {
os.Remove(fullPath)
return "", err
}
webPath := "/uploads/products/" + fmt.Sprintf("%d", productID) + "/" + name
_ = header
return webPath, nil
}
func (s *Storage) Remove(webPath string) error {
if !strings.HasPrefix(webPath, "/uploads/") {
return nil
}
rel := strings.TrimPrefix(webPath, "/uploads/")
full := filepath.Join(s.RootDir, rel)
return os.Remove(full)
}
func (s *Storage) FileServer() http.Handler {
return http.StripPrefix("/uploads/", http.FileServer(http.Dir(s.RootDir)))
}
+25
View File
@@ -0,0 +1,25 @@
-- Расширение товаров, скриншоты, статистика посещений
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;