{{.Name}}
+{{.Description}}
+ +diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f729dc6 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Database +DB_USER=shop +DB_PASSWORD=shop_secret +DB_NAME=shop + +# Admin (создаётся автоматически при старте) +ADMIN_EMAIL=admin@shop.local +ADMIN_PASSWORD=change_me + +# Секрет для сессий (обязательно смените в продакшене) +SESSION_SECRET=your_random_secret_key_here + +# Domain for Caddy SSL (production) +DOMAIN=shop.example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b7ea6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +*.exe +/shop +/vendor/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..d2ff5cc --- /dev/null +++ b/Caddyfile @@ -0,0 +1,14 @@ +{ + admin localhost:2019 +} + +# Локальная разработка — HTTP на порту 80 +:80 { + reverse_proxy app:8080 +} + +# Продакшен — автоматический SSL от Let's Encrypt +# Раскомментируйте и закомментируйте блок :80 выше +# {$DOMAIN} { +# reverse_proxy app:8080 +# } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8365482 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache ca-certificates git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /shop ./cmd/shop + +# Runtime stage +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /shop /app/shop + +EXPOSE 8080 + +USER nobody + +ENTRYPOINT ["/app/shop"] diff --git a/cmd/shop/main.go b/cmd/shop/main.go new file mode 100644 index 0000000..3e4902a --- /dev/null +++ b/cmd/shop/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "shop/internal/auth" + "shop/internal/config" + "shop/internal/database" + "shop/internal/handlers" + "shop/internal/repository" +) + +func main() { + ctx := context.Background() + cfg := config.Load() + + pool, err := database.Connect(ctx) + if err != nil { + log.Fatalf("database: %v", err) + } + defer pool.Close() + + if err := database.Migrate(ctx, pool); err != nil { + log.Fatalf("migrate: %v", err) + } + + productRepo := repository.NewProductRepository(pool) + adminRepo := repository.NewAdminRepository(pool) + + if cfg.AdminEmail != "" && cfg.AdminPassword != "" { + if err := adminRepo.Upsert(ctx, cfg.AdminEmail, cfg.AdminPassword); err != nil { + log.Fatalf("admin seed: %v", err) + } + log.Printf("admin ready: %s", cfg.AdminEmail) + } else { + log.Println("warning: set ADMIN_EMAIL and ADMIN_PASSWORD in .env to create admin") + } + + home, err := handlers.NewHomeHandler(productRepo) + if err != nil { + log.Fatalf("handler: %v", err) + } + + session := auth.NewSession(cfg.SessionSecret) + admin := handlers.NewAdminHandler(adminRepo, productRepo, session, home.Templates()) + + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", home.Static())) + mux.HandleFunc("GET /health", home.Health) + mux.HandleFunc("GET /{$}", home.Home) + + mux.HandleFunc("GET /admin/login", admin.LoginPage) + mux.HandleFunc("POST /admin/login", admin.Login) + mux.HandleFunc("POST /admin/logout", admin.Logout) + mux.HandleFunc("GET /admin/{$}", admin.Dashboard) + + addr := ":" + getEnv("APP_PORT", "8080") + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("server listening on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9fad432 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,70 @@ +services: + db: + image: postgres:17-alpine + container_name: shop-db + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER:-shop} + POSTGRES_PASSWORD: ${DB_PASSWORD:-shop_secret} + POSTGRES_DB: ${DB_NAME:-shop} + volumes: + - 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 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-shop} -d ${DB_NAME:-shop}"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - shop-net + + app: + build: + context: . + dockerfile: Dockerfile + container_name: shop-app + restart: unless-stopped + environment: + DB_HOST: db + DB_PORT: "5432" + DB_USER: ${DB_USER:-shop} + DB_PASSWORD: ${DB_PASSWORD:-shop_secret} + DB_NAME: ${DB_NAME:-shop} + APP_PORT: "8080" + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@shop.local} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change_me} + SESSION_SECRET: ${SESSION_SECRET:-dev_session_secret} + depends_on: + db: + condition: service_healthy + networks: + - shop-net + + caddy: + image: caddy:2-alpine + container_name: shop-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "2019:2019" + environment: + DOMAIN: ${DOMAIN:-localhost} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - app + networks: + - shop-net + +volumes: + pgdata: + caddy_data: + caddy_config: + +networks: + shop-net: + driver: bridge diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1c59945 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module shop + +go 1.23 + +require ( + github.com/jackc/pgx/v5 v5.7.4 + golang.org/x/crypto v0.31.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa0f7db --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +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/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= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..ad85287 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,94 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + cookieName = "shop_session" + sessionTTL = 24 * time.Hour +) + +type Session struct { + secret []byte +} + +func NewSession(secret string) *Session { + return &Session{secret: []byte(secret)} +} + +func (s *Session) Create(email string) (string, error) { + exp := time.Now().Add(sessionTTL).Unix() + payload := fmt.Sprintf("%s|%d", email, exp) + sig := s.sign(payload) + token := base64.URLEncoding.EncodeToString([]byte(payload + "|" + sig)) + return token, nil +} + +func (s *Session) Validate(token string) (string, bool) { + raw, err := base64.URLEncoding.DecodeString(token) + if err != nil { + return "", false + } + + parts := strings.Split(string(raw), "|") + if len(parts) != 3 { + return "", false + } + + email, expStr, sig := parts[0], parts[1], parts[2] + payload := email + "|" + expStr + + if !hmac.Equal([]byte(sig), []byte(s.sign(payload))) { + return "", false + } + + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil || time.Now().Unix() > exp { + return "", false + } + + return email, true +} + +func (s *Session) SetCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/admin", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(sessionTTL.Seconds()), + }) +} + +func (s *Session) ClearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/admin", + HttpOnly: true, + MaxAge: -1, + }) +} + +func (s *Session) FromRequest(r *http.Request) (string, bool) { + c, err := r.Cookie(cookieName) + if err != nil { + return "", false + } + return s.Validate(c.Value) +} + +func (s *Session) sign(payload string) string { + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(payload)) + return base64.URLEncoding.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..67f1fee --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,37 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "log" + "os" +) + +type Config struct { + AdminEmail string + AdminPassword string + SessionSecret string +} + +func Load() Config { + cfg := Config{ + AdminEmail: os.Getenv("ADMIN_EMAIL"), + AdminPassword: os.Getenv("ADMIN_PASSWORD"), + SessionSecret: os.Getenv("SESSION_SECRET"), + } + + if cfg.SessionSecret == "" { + cfg.SessionSecret = randomSecret() + log.Println("warning: SESSION_SECRET not set, using random value (sessions reset on restart)") + } + + return cfg +} + +func randomSecret() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..57556bf --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,51 @@ +package database + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Connect(ctx context.Context) (*pgxpool.Pool, error) { + dsn := fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=disable", + getEnv("DB_USER", "shop"), + getEnv("DB_PASSWORD", "shop_secret"), + getEnv("DB_HOST", "localhost"), + getEnv("DB_PORT", "5432"), + getEnv("DB_NAME", "shop"), + ) + + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + cfg.MaxConns = 10 + cfg.MinConns = 2 + cfg.MaxConnLifetime = time.Hour + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("connect: %w", err) + } + + for i := 0; i < 30; i++ { + if err := pool.Ping(ctx); err == nil { + return pool, nil + } + time.Sleep(time.Second) + } + + return nil, fmt.Errorf("database not ready after 30s") +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..38f0acc --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,19 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + _, err := pool.Exec(ctx, ` + 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 +} diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go new file mode 100644 index 0000000..259c7d9 --- /dev/null +++ b/internal/handlers/admin.go @@ -0,0 +1,135 @@ +package handlers + +import ( + "html/template" + "log" + "net/http" + + "shop/internal/auth" + "shop/internal/repository" +) + +type AdminHandler struct { + repo *repository.AdminRepository + products *repository.ProductRepository + session *auth.Session + templates *template.Template +} + +type adminLoginData struct { + Title string + Error string + Email string +} + +type adminDashboardData struct { + Title string + Email string + ProductCount int + Categories interface{} +} + +func NewAdminHandler( + adminRepo *repository.AdminRepository, + productRepo *repository.ProductRepository, + session *auth.Session, + templates *template.Template, +) *AdminHandler { + return &AdminHandler{ + repo: adminRepo, + products: productRepo, + session: session, + templates: templates, + } +} + +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) +} + +func (h *AdminHandler) Login(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + email := r.FormValue("email") + password := r.FormValue("password") + + ok, err := h.repo.Authenticate(r.Context(), email, password) + if err != nil { + log.Printf("admin auth: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if !ok { + h.renderLoginError(w, email, "Неверный email или пароль") + return + } + + token, err := h.session.Create(email) + if err != nil { + log.Printf("session: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + h.session.SetCookie(w, token) + http.Redirect(w, r, "/admin/", http.StatusSeeOther) +} + +func (h *AdminHandler) Logout(w http.ResponseWriter, r *http.Request) { + h.session.ClearCookie(w) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} + +func (h *AdminHandler) Dashboard(w http.ResponseWriter, r *http.Request) { + email, ok := h.session.FromRequest(r) + if !ok { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + ctx := r.Context() + products, err := h.products.Featured(ctx, 100) + if err != nil { + log.Printf("admin products: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + categories, err := h.products.Categories(ctx) + if err != nil { + log.Printf("admin categories: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + data := adminDashboardData{ + Title: "Админ-панель", + Email: email, + ProductCount: len(products), + Categories: categories, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = h.templates.ExecuteTemplate(w, "admin_dashboard.html", data) +} + +func (h *AdminHandler) renderLoginError(w http.ResponseWriter, email, msg string) { + 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) +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go new file mode 100644 index 0000000..d4be74b --- /dev/null +++ b/internal/handlers/home.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "embed" + "html/template" + "io/fs" + "log" + "net/http" + + "shop/internal/repository" +) + +//go:embed templates/* +var templateFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +type HomeHandler struct { + repo *repository.ProductRepository + templates *template.Template +} + +type homePageData struct { + Title string + Products interface{} + Categories interface{} +} + +func NewHomeHandler(repo *repository.ProductRepository) (*HomeHandler, error) { + tmpl, err := template.ParseFS(templateFS, "templates/*.html") + if err != nil { + return nil, err + } + return &HomeHandler{repo: repo, templates: tmpl}, nil +} + +func (h *HomeHandler) Templates() *template.Template { + return h.templates +} + +func (h *HomeHandler) Static() http.Handler { + sub, err := fs.Sub(staticFS, "static") + if err != nil { + log.Fatal(err) + } + return http.FileServer(http.FS(sub)) +} + +func (h *HomeHandler) Home(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + ctx := r.Context() + + products, err := h.repo.Featured(ctx, 8) + if err != nil { + log.Printf("featured products: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + categories, err := h.repo.Categories(ctx) + if err != nil { + log.Printf("categories: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + data := homePageData{ + Title: "Shop — Интернет-магазин", + Products: products, + Categories: categories, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "home.html", data); err != nil { + log.Printf("render: %v", err) + } +} + +func (h *HomeHandler) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) +} diff --git a/internal/handlers/static/css/admin.css b/internal/handlers/static/css/admin.css new file mode 100644 index 0000000..d2820e5 --- /dev/null +++ b/internal/handlers/static/css/admin.css @@ -0,0 +1,196 @@ +.admin-body { + background: #f3f4f6; + min-height: 100vh; +} + +.admin-login { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.admin-login__card { + background: #fff; + border-radius: 16px; + padding: 40px; + width: 100%; + max-width: 420px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); +} + +.admin-login__logo { + justify-content: center; + margin-bottom: 24px; +} + +.admin-login__title { + font-size: 1.5rem; + font-weight: 700; + text-align: center; + margin-bottom: 24px; +} + +.admin-login__back { + display: block; + text-align: center; + margin-top: 20px; + color: #6b7280; + text-decoration: none; + font-size: 0.9rem; +} + +.admin-login__back:hover { + color: #4f46e5; +} + +.admin-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.admin-form__label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +.admin-form__input { + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 10px; + font-size: 1rem; + font-family: inherit; + transition: border-color 0.2s; +} + +.admin-form__input:focus { + outline: none; + border-color: #4f46e5; + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.admin-form__submit { + width: 100%; + justify-content: center; + margin-top: 8px; +} + +.admin-alert { + padding: 12px 16px; + border-radius: 10px; + font-size: 0.9rem; + margin-bottom: 16px; +} + +.admin-alert--error { + background: #fef2f2; + color: #b91c1c; + border: 1px solid #fecaca; +} + +.admin-header { + background: #fff; + border-bottom: 1px solid #e5e7eb; +} + +.admin-header__inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 64px; +} + +.admin-header__user { + display: flex; + align-items: center; + gap: 16px; + font-size: 0.9rem; + color: #6b7280; +} + +.admin-main { + padding: 40px 24px; +} + +.admin-page-title { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 32px; +} + +.admin-stats { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 40px; +} + +.admin-stat { + background: #fff; + border-radius: 12px; + padding: 24px; + border: 1px solid #e5e7eb; +} + +.admin-stat__value { + display: block; + font-size: 2rem; + font-weight: 700; + color: #4f46e5; +} + +.admin-stat__label { + font-size: 0.875rem; + color: #6b7280; +} + +.admin-section { + background: #fff; + border-radius: 12px; + padding: 24px; + border: 1px solid #e5e7eb; + margin-bottom: 24px; +} + +.admin-section__title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 16px; +} + +.admin-table { + width: 100%; + border-collapse: collapse; +} + +.admin-table th, +.admin-table td { + text-align: left; + padding: 12px; + border-bottom: 1px solid #f3f4f6; +} + +.admin-table th { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; +} + +.admin-hint { + font-size: 0.875rem; + color: #9ca3af; +} + +.admin-hint code { + background: #e5e7eb; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8rem; +} diff --git a/internal/handlers/static/css/style.css b/internal/handlers/static/css/style.css new file mode 100644 index 0000000..ac13f2c --- /dev/null +++ b/internal/handlers/static/css/style.css @@ -0,0 +1,495 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --color-bg: #fafafa; + --color-surface: #ffffff; + --color-text: #1a1a2e; + --color-text-muted: #6b7280; + --color-primary: #4f46e5; + --color-primary-hover: #4338ca; + --color-border: #e5e7eb; + --color-hero-bg: linear-gradient(135deg, #eef2ff 0%, #faf5ff 50%, #fdf2f8 100%); + --radius: 12px; + --radius-lg: 20px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.08); + --font: 'Inter', system-ui, -apple-system, sans-serif; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--font); + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; +} + +/* Header */ +.header { + position: sticky; + top: 0; + z-index: 100; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--color-border); +} + +.header__inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 72px; + gap: 24px; +} + +.logo { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: var(--color-text); + font-weight: 700; + font-size: 1.25rem; +} + +.logo__icon { + color: var(--color-primary); + font-size: 1.5rem; +} + +.nav { + display: flex; + gap: 32px; +} + +.nav__link { + text-decoration: none; + color: var(--color-text-muted); + font-weight: 500; + font-size: 0.95rem; + transition: color 0.2s; +} + +.nav__link:hover { + color: var(--color-primary); +} + +.header__actions { + display: flex; + align-items: center; + gap: 12px; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border-radius: var(--radius); + font-family: var(--font); + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + border: none; + text-decoration: none; + transition: all 0.2s; +} + +.btn--primary { + background: var(--color-primary); + color: #fff; +} + +.btn--primary:hover { + background: var(--color-primary-hover); + transform: translateY(-1px); +} + +.btn--ghost { + background: transparent; + color: var(--color-text-muted); + padding: 10px; +} + +.btn--ghost:hover { + color: var(--color-primary); + background: #f3f4f6; +} + +.btn--outline { + background: transparent; + color: var(--color-text); + border: 2px solid var(--color-border); +} + +.btn--outline:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.btn--lg { + padding: 14px 28px; + font-size: 1rem; +} + +.btn--sm { + padding: 8px 16px; + font-size: 0.85rem; +} + +/* Hero */ +.hero { + background: var(--color-hero-bg); + padding: 80px 0 100px; +} + +.hero__inner { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 60px; + align-items: center; +} + +.badge { + display: inline-block; + padding: 6px 14px; + background: rgba(79, 70, 229, 0.1); + color: var(--color-primary); + border-radius: 100px; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 20px; +} + +.hero__title { + font-size: clamp(2.5rem, 5vw, 3.5rem); + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.02em; + margin-bottom: 20px; +} + +.hero__text { + font-size: 1.125rem; + color: var(--color-text-muted); + max-width: 480px; + margin-bottom: 32px; +} + +.hero__cta { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.hero__visual { + position: relative; + height: 320px; +} + +.hero__card { + position: absolute; + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: 24px 32px; + box-shadow: var(--shadow-md); + display: flex; + flex-direction: column; + gap: 4px; +} + +.hero__card--1 { + top: 40px; + right: 20px; +} + +.hero__card--2 { + bottom: 40px; + left: 20px; +} + +.hero__card-label { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.hero__card-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-primary); +} + +/* Sections */ +.section { + padding: 80px 0; +} + +.section--alt { + background: var(--color-surface); +} + +.section__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 40px; +} + +.section__title { + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.link { + color: var(--color-primary); + text-decoration: none; + font-weight: 600; +} + +.link:hover { + text-decoration: underline; +} + +/* Categories */ +.categories { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.category-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 24px; + text-decoration: none; + color: inherit; + transition: all 0.2s; +} + +.category-card:hover { + border-color: var(--color-primary); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.category-card__name { + display: block; + font-weight: 600; + font-size: 1.1rem; + margin-bottom: 4px; +} + +.category-card__count { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +/* Products */ +.products { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 24px; +} + +.product-card { + background: var(--color-bg); + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--color-border); + transition: all 0.25s; +} + +.product-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-4px); +} + +.product-card__image { + position: relative; + aspect-ratio: 4/3; + overflow: hidden; + background: #f3f4f6; +} + +.product-card__image img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s; +} + +.product-card:hover .product-card__image img { + transform: scale(1.05); +} + +.product-card__badge { + position: absolute; + top: 12px; + left: 12px; + background: rgba(255, 255, 255, 0.95); + padding: 4px 10px; + border-radius: 100px; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-primary); +} + +.product-card__body { + padding: 20px; +} + +.product-card__title { + font-size: 1.05rem; + font-weight: 600; + margin-bottom: 6px; +} + +.product-card__desc { + font-size: 0.875rem; + color: var(--color-text-muted); + margin-bottom: 16px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.product-card__footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.product-card__price { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text); +} + +.empty { + grid-column: 1 / -1; + text-align: center; + color: var(--color-text-muted); + padding: 48px; +} + +/* Features */ +.features { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 32px; +} + +.feature { + text-align: center; + padding: 32px 24px; + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); +} + +.feature__icon { + font-size: 2.5rem; + margin-bottom: 16px; +} + +.feature__title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 8px; +} + +.feature__text { + font-size: 0.9rem; + color: var(--color-text-muted); +} + +/* Footer */ +.footer { + background: var(--color-text); + color: #9ca3af; + padding: 48px 0; +} + +.footer__inner { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 32px; +} + +.footer__brand .logo__icon, +.footer__brand .logo__text { + color: #fff; +} + +.footer__copy { + margin-top: 12px; + font-size: 0.875rem; +} + +.footer__links { + display: flex; + gap: 24px; +} + +.footer__links a { + color: #9ca3af; + text-decoration: none; + font-size: 0.9rem; +} + +.footer__links a:hover { + color: #fff; +} + +/* Responsive */ +@media (max-width: 768px) { + .nav { + display: none; + } + + .hero__inner { + grid-template-columns: 1fr; + text-align: center; + } + + .hero__text { + margin-left: auto; + margin-right: auto; + } + + .hero__cta { + justify-content: center; + } + + .hero__visual { + display: none; + } + + .section__header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } +} diff --git a/internal/handlers/templates/admin_dashboard.html b/internal/handlers/templates/admin_dashboard.html new file mode 100644 index 0000000..c332b24 --- /dev/null +++ b/internal/handlers/templates/admin_dashboard.html @@ -0,0 +1,67 @@ +{{define "admin_dashboard.html"}} + + +
+ + +| Название | +Товаров | +
|---|---|
| {{.Name}} | +{{.Count}} | +
Администратор создаётся автоматически из переменных ADMIN_EMAIL и ADMIN_PASSWORD в .env.
Электроника, одежда, дом и сад. Быстрая доставка по всей стране, гарантия качества и удобная оплата.
+ +{{.Description}}
+ +Товары скоро появятся в каталоге.
+ {{end}} +Доставим заказ за 1–3 дня в любой город России
+Карты, СБП и оплата при получении
+14 дней на возврат без лишних вопросов
+Ответим на любой вопрос в чате или по телефону
+