feat: add cards templete
This commit is contained in:
+134
-11
@@ -3,12 +3,18 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -20,14 +26,15 @@ import (
|
||||
var tplFS embed.FS
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
eco *economy.Service
|
||||
tpl *template.Template
|
||||
db *sql.DB
|
||||
eco *economy.Service
|
||||
tpl *template.Template
|
||||
cardsDir string // پوشهی دیسکیِ آپلودِ اسکینها (همان مسیری که static از آن سرو میکند)
|
||||
}
|
||||
|
||||
func New(db *sql.DB, eco *economy.Service) *Handler {
|
||||
func New(db *sql.DB, eco *economy.Service, cardsDir string) *Handler {
|
||||
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
|
||||
return &Handler{db: db, eco: eco, tpl: tpl}
|
||||
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir}
|
||||
}
|
||||
|
||||
// Routes زیرروترِ /admin را با Basic Auth برمیگرداند.
|
||||
@@ -38,10 +45,12 @@ func (h *Handler) Routes(user, pass string) http.Handler {
|
||||
r.Get("/shop", h.shop)
|
||||
r.Post("/shop/{kind}/add", h.addItem)
|
||||
r.Post("/shop/{kind}/delete", h.deleteItem)
|
||||
r.Post("/shop/card/{id}/upload", h.uploadDeck)
|
||||
r.Post("/shop/{kind}", h.updateItem)
|
||||
r.Get("/users", h.users)
|
||||
r.Post("/users/coins", h.adjustCoins)
|
||||
r.Post("/users/grant", h.grantToUser)
|
||||
r.Post("/season/reset", h.resetSeason)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -66,6 +75,8 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
"Purchases": count(`SELECT COUNT(*) FROM purchases WHERE status='verified'`),
|
||||
"Games": count(`SELECT COUNT(*) FROM game_history`),
|
||||
"Coins": count(`SELECT COALESCE(SUM(coins),0) FROM users`),
|
||||
"Season": h.eco.Season(ctx),
|
||||
"Saved": r.URL.Query().Get("saved") == "1",
|
||||
"Nav": "dashboard",
|
||||
}
|
||||
h.render(w, "dashboard.html", data)
|
||||
@@ -87,8 +98,8 @@ func (h *Handler) shop(w http.ResponseWriter, r *http.Request) {
|
||||
"id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"),
|
||||
"VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sort,enabled FROM vip_packages ORDER BY sort`,
|
||||
"id", "title", "months", "price_toman", "sort", "enabled"),
|
||||
"Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,sort,enabled FROM table_tiers ORDER BY sort`,
|
||||
"id", "title", "hands", "entry", "prize", "xp", "trophy", "sort", "enabled"),
|
||||
"Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled FROM table_tiers ORDER BY sort`,
|
||||
"id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"),
|
||||
"Saved": r.URL.Query().Get("saved") == "1",
|
||||
}
|
||||
h.render(w, "shop.html", data)
|
||||
@@ -127,8 +138,8 @@ func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) {
|
||||
q = `UPDATE vip_packages SET title=?,months=?,price_toman=?,sort=?,enabled=? WHERE id=?`
|
||||
args = []any{r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en, id}
|
||||
case "tier":
|
||||
q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,sort=?,enabled=? WHERE id=?`
|
||||
args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en, id}
|
||||
q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,rank_reward=?,sort=?,enabled=? WHERE id=?`
|
||||
args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en, id}
|
||||
default:
|
||||
http.Error(w, "unknown kind", http.StatusBadRequest)
|
||||
return
|
||||
@@ -179,8 +190,8 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) {
|
||||
q = `INSERT INTO vip_packages (id,title,months,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en}
|
||||
case "tier":
|
||||
q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en}
|
||||
q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?,?)`
|
||||
args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en}
|
||||
default:
|
||||
http.Error(w, "unknown kind", http.StatusBadRequest)
|
||||
return
|
||||
@@ -194,6 +205,109 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// validCardFile نامِ کانونیِ خروجی را برای یک ورودیِ zip برمیگرداند (یا ok=false).
|
||||
// مجاز: back.(jpg|png) → "back.jpg" و <کد>.png → "<کدِ بزرگ>.png"
|
||||
// که <کد> = رتبه (A,2..10,J,Q,K) + خال (S,H,D,C)، مثل AS.png یا 10H.png.
|
||||
// خروجی همیشه «کدِ بزرگ + پسوندِ کوچک» است تا با درخواستهای اپ روی فایلسیستمِ
|
||||
// حساسبهحروف (لینوکس) دقیقاً مطابقت کند.
|
||||
func validCardFile(name string) (string, bool) {
|
||||
base := filepath.Base(name)
|
||||
low := strings.ToLower(base)
|
||||
if low == "back.jpg" || low == "back.png" {
|
||||
return "back.jpg", true // اپ پشتِ کارت را با نامِ back.jpg میخواهد
|
||||
}
|
||||
if !strings.HasSuffix(low, ".png") {
|
||||
return "", false
|
||||
}
|
||||
code := strings.ToUpper(base[:len(base)-len(".png")])
|
||||
suits := "SHDC"
|
||||
if len(code) < 2 || !strings.Contains(suits, code[len(code)-1:]) {
|
||||
return "", false
|
||||
}
|
||||
ranks := map[string]bool{"A": true, "2": true, "3": true, "4": true, "5": true,
|
||||
"6": true, "7": true, "8": true, "9": true, "10": true, "J": true, "Q": true, "K": true}
|
||||
if !ranks[code[:len(code)-1]] {
|
||||
return "", false
|
||||
}
|
||||
return code + ".png", true
|
||||
}
|
||||
|
||||
// uploadDeck یک فایلِ zip شاملِ تصاویرِ کارت را برای یک اسکین آپلود میکند و
|
||||
// در پوشهی دیسکیِ ماندگار (cardsDir/<id>/) استخراج میکند تا بدونِ کامپایلِ
|
||||
// دوباره از همان مسیرِ /cards/<id>/... سرو شود.
|
||||
func (h *Handler) uploadDeck(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" || strings.ContainsAny(id, "/\\.") {
|
||||
http.Error(w, "invalid skin id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if h.cardsDir == "" {
|
||||
http.Error(w, "CARDS_DIR تنظیم نشده است", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil { // حداکثر ۶۴MB
|
||||
http.Error(w, "bad upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("deck")
|
||||
if err != nil {
|
||||
http.Error(w, "deck (zip) لازم است", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
buf, err := io.ReadAll(io.LimitReader(file, 64<<20))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
zr, err := zip.NewReader(strings.NewReader(string(buf)), int64(len(buf)))
|
||||
if err != nil {
|
||||
http.Error(w, "فایل zip معتبر نیست", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Join(h.cardsDir, id)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
written := 0
|
||||
for _, zf := range zr.File {
|
||||
if zf.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
out, ok := validCardFile(zf.Name)
|
||||
if !ok {
|
||||
continue // فایلهای نامربوط/ناقص نادیده گرفته میشوند
|
||||
}
|
||||
if err := extractZipEntry(zf, filepath.Join(dir, out)); err != nil {
|
||||
http.Error(w, fmt.Sprintf("استخراج %s: %v", zf.Name, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
written++
|
||||
}
|
||||
if written == 0 {
|
||||
http.Error(w, "هیچ تصویرِ کارتِ معتبری در zip یافت نشد (مثل AS.png، 10H.png، back.jpg)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func extractZipEntry(zf *zip.File, dst string) error {
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(rc, 16<<20)) // سقفِ هر فایل ۱۶MB
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteItem یک ردیفِ کاتالوگ را حذف میکند.
|
||||
func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -218,6 +332,15 @@ func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// resetSeason امتیازِ رتبهی همه را صفر کرده و فصلِ جدید را آغاز میکند.
|
||||
func (h *Handler) resetSeason(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.eco.ResetSeason(r.Context()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/?saved=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- کاربران ---
|
||||
|
||||
func (h *Handler) users(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -5,12 +5,21 @@
|
||||
{{template "nav" .}}
|
||||
<div class="wrap">
|
||||
<h1>داشبورد</h1>
|
||||
{{if .Saved}}<div class="saved">انجام شد ✓</div>{{end}}
|
||||
<div class="stats">
|
||||
<div class="stat">کاربران<b>{{.Users}}</b></div>
|
||||
<div class="stat">خریدهای موفق<b>{{.Purchases}}</b></div>
|
||||
<div class="stat">بازیهای ثبتشده<b>{{.Games}}</b></div>
|
||||
<div class="stat">مجموع سکهی کاربران<b>{{.Coins}}</b></div>
|
||||
<div class="stat">فصلِ رتبهبندی<b>{{.Season}}</b></div>
|
||||
</div>
|
||||
|
||||
<h2>فصلِ رتبهبندی</h2>
|
||||
<p style="color:#888">شروعِ فصلِ جدید، امتیازِ رتبهی همهی کاربران را صفر میکند.</p>
|
||||
<form method="post" action="/admin/season/reset"
|
||||
onsubmit="return confirm('فصلِ جدید؟ امتیازِ رتبهی همه صفر میشود.');">
|
||||
<button class="del">شروعِ فصلِ جدید (ریست رتبهها)</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -65,8 +65,11 @@
|
||||
</table>
|
||||
|
||||
<h2>اسکین کارتها</h2>
|
||||
<p style="color:#9aa;font-size:13px">
|
||||
آپلودِ تصاویرِ هر اسکین: یک فایلِ <b>zip</b> شاملِ ۵۲ کارت با نامِ <code>AS.png</code>، <code>10H.png</code>، <code>KD.png</code> … و یک <code>back.jpg</code> (پشتِ کارت). نامها بزرگوکوچک مهم نیست.
|
||||
</p>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th><th>تصاویر</th></tr>
|
||||
{{range .Cards}}
|
||||
<tr><form method="post" action="/admin/shop/card">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
@@ -76,7 +79,12 @@
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
<td><button formaction="/admin/shop/card/delete" formnovalidate class="del">حذف</button></td>
|
||||
</form></tr>
|
||||
</form>
|
||||
<td><form method="post" action="/admin/shop/card/{{.id}}/upload" enctype="multipart/form-data" style="display:flex;gap:4px">
|
||||
<input type="file" name="deck" accept=".zip" required>
|
||||
<button>آپلود zip</button>
|
||||
</form></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr class="addrow"><form method="post" action="/admin/shop/card/add">
|
||||
<td><input name="id" placeholder="شناسه" required></td>
|
||||
@@ -143,8 +151,9 @@
|
||||
</table>
|
||||
|
||||
<h2>انواع میز</h2>
|
||||
<p style="color:#888">«امتیاز رتبه» اگر بزرگتر از ۰ باشد، آن میز رتبهبندی است و به برنده امتیازِ رتبه میدهد.</p>
|
||||
<table>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>دست</th><th>ورودی</th><th>جایزه</th><th>XP</th><th>جام</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
<tr><th>شناسه</th><th>عنوان</th><th>دست</th><th>ورودی</th><th>جایزه</th><th>XP</th><th>جام</th><th>امتیاز رتبه</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
|
||||
{{range .Tiers}}
|
||||
<tr><form method="post" action="/admin/shop/tier">
|
||||
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
|
||||
@@ -154,6 +163,7 @@
|
||||
<td><input name="prize" value="{{.prize}}"></td>
|
||||
<td><input name="xp" value="{{.xp}}"></td>
|
||||
<td><input name="trophy" value="{{.trophy}}"></td>
|
||||
<td><input name="rank_reward" value="{{.rank_reward}}"></td>
|
||||
<td><input name="sort" value="{{.sort}}"></td>
|
||||
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
|
||||
<td><button>ذخیره</button></td>
|
||||
@@ -168,6 +178,7 @@
|
||||
<td><input name="prize" value="0"></td>
|
||||
<td><input name="xp" value="0"></td>
|
||||
<td><input name="trophy" value="0"></td>
|
||||
<td><input name="rank_reward" value="0"></td>
|
||||
<td><input name="sort" value="0"></td>
|
||||
<td><input type="checkbox" name="enabled" checked></td>
|
||||
<td colspan="2"><button>افزودن</button></td>
|
||||
|
||||
Reference in New Issue
Block a user