Мод-менеджер с галочками и коллекции мастерской
Вместо двух отдельных списков (Workshop ID и Mod ID) — один список модов: галочка включает мод, перетаскивание задаёт порядок загрузки, кнопка убирает пакет вместе со всеми его модами. Чтобы галочкам было за что зацепиться, панель читает mod.info уже скачанных модов: серверу в Mods нужен именно Mod ID, а он лежит внутри пакета мастерской и вручную его приходилось искать в описании мода. Пока мод не скачан, строка помечена и галочка недоступна — Mod ID неизвестен. Один пакет мастерской может содержать несколько модов; теперь они видны отдельными строками и включаются по отдельности. Коллекции: кнопка разворачивает коллекцию Steam в список входящих в неё модов, сохраняя порядок со страницы коллекции. И мод, и коллекция принимаются как ссылкой, так и голым идентификатором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,8 @@
|
|||||||
ОЗУ и диск на хосте, список игроков онлайн.
|
ОЗУ и диск на хосте, список игроков онлайн.
|
||||||
- **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в
|
- **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в
|
||||||
браузере, с поиском по параметрам и сохранением комментариев в файле.
|
браузере, с поиском по параметрам и сохранением комментариев в файле.
|
||||||
- **Моды** — списки Workshop ID и Mod ID с порядком загрузки, названия модов
|
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
||||||
подтягиваются из мастерской Steam.
|
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
||||||
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
||||||
ротацией, скачиванием и восстановлением (перед восстановлением
|
ротацией, скачиванием и восстановлением (перед восстановлением
|
||||||
автоматически снимается страховочная копия).
|
автоматически снимается страховочная копия).
|
||||||
@@ -93,6 +93,37 @@ sudo ufw allow 16261/udp
|
|||||||
sudo ufw allow 16262/udp
|
sudo ufw allow 16262/udp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Моды
|
||||||
|
|
||||||
|
Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что
|
||||||
|
скачать из мастерской, `Mods` — что включить и в каком порядке. Панель
|
||||||
|
показывает их одним списком, где каждая строка — мод:
|
||||||
|
|
||||||
|
- **галочка** включает мод (добавляет его Mod ID в `Mods`);
|
||||||
|
- **перетаскивание** (или стрелки) задаёт порядок загрузки — для модов,
|
||||||
|
которые переопределяют одни и те же файлы, он решает, чья версия победит;
|
||||||
|
- **«Убрать»** выбрасывает пакет из `WorkshopItems` вместе со всеми его модами.
|
||||||
|
|
||||||
|
Добавить мод можно ссылкой на страницу мастерской или голым ID. Кнопка
|
||||||
|
**«Добавить коллекцию»** разворачивает коллекцию в список входящих в неё модов
|
||||||
|
— порядок берётся тот же, что на странице коллекции.
|
||||||
|
|
||||||
|
Mod ID, который нужен серверу, лежит внутри самого мода (`mod.info`), поэтому
|
||||||
|
панель читает уже скачанные моды с диска:
|
||||||
|
|
||||||
|
```
|
||||||
|
<server_dir>/steamapps/workshop/content/108600/<workshop id>/mods/<мод>/mod.info
|
||||||
|
~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную
|
||||||
|
```
|
||||||
|
|
||||||
|
Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка
|
||||||
|
недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по
|
||||||
|
списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и
|
||||||
|
запустить сервер, после чего они станут доступны для включения.
|
||||||
|
|
||||||
|
Один пакет мастерской может содержать несколько модов — в списке они идут
|
||||||
|
отдельными строками, и включать их можно по отдельности.
|
||||||
|
|
||||||
## Профили серверов
|
## Профили серверов
|
||||||
|
|
||||||
Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки
|
Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки
|
||||||
|
|||||||
@@ -3,13 +3,20 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleGetMods отдаёт списки модов из servertest.ini. Названия модов не
|
// idFromInput вытаскивает числовой идентификатор из того, что ввёл человек:
|
||||||
// запрашиваются здесь: поход в Steam медленный, для него есть отдельный
|
// это может быть сам id или ссылка вида
|
||||||
// эндпоинт lookup.
|
// https://steamcommunity.com/sharedfiles/filedetails/?id=2392709985.
|
||||||
|
var idFromInput = regexp.MustCompile(`(\d{5,})`)
|
||||||
|
|
||||||
|
// handleGetMods отдаёт списки модов профиля и каталог того, что уже скачано на
|
||||||
|
// диск. Названия из мастерской здесь не запрашиваются: поход в Steam медленный,
|
||||||
|
// для него есть отдельный эндпоинт lookup.
|
||||||
func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) {
|
||||||
path := s.cfg.ServerConfigPath()
|
path := s.cfg.ServerConfigPath()
|
||||||
ini, err := pzconfig.LoadINI(path)
|
ini, err := pzconfig.LoadINI(path)
|
||||||
@@ -25,16 +32,28 @@ func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mods, workshop := ini.Mods()
|
mods, workshop := ini.Mods()
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"exists": true,
|
"exists": true,
|
||||||
"path": path,
|
"path": path,
|
||||||
|
"profile": s.cfg.ActiveProfile().ID,
|
||||||
"mods": mods,
|
"mods": mods,
|
||||||
"workshop_items": workshop,
|
"workshop_items": workshop,
|
||||||
"map": pzconfig.SplitList(ini.Get(pzconfig.KeyMap)),
|
"map": pzconfig.SplitList(ini.Get(pzconfig.KeyMap)),
|
||||||
|
"installed": s.installedMods(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// installedMods собирает каталог скачанных модов. Сервер кладёт мастерскую
|
||||||
|
// рядом с собой, но встречается и раскладка в ~/Zomboid.
|
||||||
|
func (s *Server) installedMods() []pzconfig.InstalledMod {
|
||||||
|
return pzconfig.ScanInstalledMods(
|
||||||
|
[]string{s.cfg.ServerDir, s.cfg.ZomboidDir},
|
||||||
|
filepath.Join(s.cfg.ZomboidDir, "mods"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Mods []string `json:"mods"`
|
Mods []string `json:"mods"`
|
||||||
@@ -82,3 +101,32 @@ func (s *Server) handleLookupMods(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := s.workshop.Details(r.Context(), body.IDs)
|
items := s.workshop.Details(r.Context(), body.IDs)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleExpandCollection разворачивает коллекцию мастерской в список модов.
|
||||||
|
// Принимает и голый id, и ссылку на страницу коллекции.
|
||||||
|
func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
match := idFromInput.FindString(body.ID)
|
||||||
|
if match == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.workshop.Collection(r.Context(), match)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Названия подтягиваем сразу: иначе панель показала бы десяток голых
|
||||||
|
// чисел и потребовала отдельного запроса.
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"collection": match,
|
||||||
|
"items": s.workshop.Details(r.Context(), items),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
"GET /api/mods": s.handleGetMods,
|
"GET /api/mods": s.handleGetMods,
|
||||||
"PUT /api/mods": s.handlePutMods,
|
"PUT /api/mods": s.handlePutMods,
|
||||||
"POST /api/mods/lookup": s.handleLookupMods,
|
"POST /api/mods/lookup": s.handleLookupMods,
|
||||||
|
"POST /api/mods/collection": s.handleExpandCollection,
|
||||||
"GET /api/backups": s.handleListBackups,
|
"GET /api/backups": s.handleListBackups,
|
||||||
"POST /api/backups": s.handleCreateBackup,
|
"POST /api/backups": s.handleCreateBackup,
|
||||||
"DELETE /api/backups/{name}": s.handleDeleteBackup,
|
"DELETE /api/backups/{name}": s.handleDeleteBackup,
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package pzconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pzAppID — идентификатор Project Zomboid в Steam. По нему сервер
|
||||||
|
// раскладывает скачанные моды мастерской.
|
||||||
|
const pzAppID = "108600"
|
||||||
|
|
||||||
|
// InstalledMod — мод, найденный на диске. Именно ModID сервер ждёт в списке
|
||||||
|
// Mods, а Workshop ID — это пакет мастерской, внутри которого мод лежит.
|
||||||
|
type InstalledMod struct {
|
||||||
|
ModID string `json:"mod_id"`
|
||||||
|
WorkshopID string `json:"workshop_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanInstalledMods обходит места, куда попадают моды, и читает их mod.info.
|
||||||
|
// Панель по этому каталогу показывает настоящие названия и, главное, знает
|
||||||
|
// Mod ID: без него мод нельзя включить, а вручную его приходится искать в
|
||||||
|
// описании на странице мастерской.
|
||||||
|
//
|
||||||
|
// roots — директории, внутри которых ищем steamapps/workshop (директория
|
||||||
|
// сервера и ~/Zomboid), localMods — папка с модами, положенными руками.
|
||||||
|
func ScanInstalledMods(roots []string, localMods string) []InstalledMod {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []InstalledMod
|
||||||
|
|
||||||
|
add := func(mod InstalledMod) {
|
||||||
|
// Один и тот же мод может лежать и в мастерской, и в локальной папке.
|
||||||
|
if mod.ModID == "" || seen[mod.ModID] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[mod.ModID] = true
|
||||||
|
out = append(out, mod)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, root := range roots {
|
||||||
|
if root == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := filepath.Join(root, "steamapps", "workshop", "content", pzAppID)
|
||||||
|
entries, err := os.ReadDir(content)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
workshopID := entry.Name()
|
||||||
|
// Внутри пакета моды лежат в mods/<имя>/mod.info, но встречается
|
||||||
|
// и раскладка без промежуточной папки mods.
|
||||||
|
for _, modsDir := range []string{
|
||||||
|
filepath.Join(content, workshopID, "mods"),
|
||||||
|
filepath.Join(content, workshopID),
|
||||||
|
} {
|
||||||
|
for _, mod := range readModsDir(modsDir, workshopID) {
|
||||||
|
add(mod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mod := range readModsDir(localMods, "") {
|
||||||
|
add(mod)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readModsDir читает mod.info во всех поддиректориях dir.
|
||||||
|
func readModsDir(dir, workshopID string) []InstalledMod {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []InstalledMod
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
modDir := filepath.Join(dir, entry.Name())
|
||||||
|
mod, ok := readModInfo(filepath.Join(modDir, "mod.info"))
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mod.WorkshopID = workshopID
|
||||||
|
mod.Dir = modDir
|
||||||
|
if mod.Name == "" {
|
||||||
|
mod.Name = mod.ModID
|
||||||
|
}
|
||||||
|
out = append(out, mod)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
|
||||||
|
// комментариями. Нас интересуют id, name и description.
|
||||||
|
func readModInfo(path string) (InstalledMod, bool) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return InstalledMod{}, false
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var mod InstalledMod
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 0, 8*1024), 256*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, found := strings.Cut(line, "=")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||||
|
case "id":
|
||||||
|
mod.ModID = value
|
||||||
|
case "name":
|
||||||
|
mod.Name = value
|
||||||
|
case "description":
|
||||||
|
if len(value) > 300 {
|
||||||
|
value = value[:300] + "…"
|
||||||
|
}
|
||||||
|
mod.Description = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mod, mod.ModID != ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package pzconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestScanInstalledMods проверяет обе раскладки мастерской и локальную папку
|
||||||
|
// модов, а также то, что дубликаты схлопываются.
|
||||||
|
func TestScanInstalledMods(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
content := filepath.Join(root, "server", "steamapps", "workshop", "content", pzAppID)
|
||||||
|
|
||||||
|
// Обычная раскладка: <workshop id>/mods/<папка>/mod.info
|
||||||
|
writeModInfo(t, filepath.Join(content, "111", "mods", "BetterSorting"),
|
||||||
|
"name=Better Sorting\nid=BetterSorting\ndescription=Сортировка\n")
|
||||||
|
// Второй мод в том же пакете мастерской.
|
||||||
|
writeModInfo(t, filepath.Join(content, "111", "mods", "BetterSortingCore"),
|
||||||
|
"name=Better Sorting Core\nid=BetterSortingCore\n")
|
||||||
|
// Раскладка без промежуточной папки mods.
|
||||||
|
writeModInfo(t, filepath.Join(content, "222", "ModOptions"),
|
||||||
|
"name=Mod Options\nid=ModOptions\n")
|
||||||
|
// Локальный мод, положенный руками.
|
||||||
|
local := filepath.Join(root, "Zomboid", "mods")
|
||||||
|
writeModInfo(t, filepath.Join(local, "MyTweak"), "name=My Tweak\nid=MyTweak\n")
|
||||||
|
// Тот же мод, что и в мастерской: должен попасть в список один раз.
|
||||||
|
writeModInfo(t, filepath.Join(local, "BetterSortingCopy"), "name=Better Sorting\nid=BetterSorting\n")
|
||||||
|
// Папка без mod.info игнорируется.
|
||||||
|
if err := os.MkdirAll(filepath.Join(content, "333", "mods", "empty"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := ScanInstalledMods([]string{filepath.Join(root, "server")}, local)
|
||||||
|
|
||||||
|
byID := make(map[string]InstalledMod, len(mods))
|
||||||
|
for _, m := range mods {
|
||||||
|
if _, dup := byID[m.ModID]; dup {
|
||||||
|
t.Errorf("мод %s встретился дважды", m.ModID)
|
||||||
|
}
|
||||||
|
byID[m.ModID] = m
|
||||||
|
}
|
||||||
|
if len(byID) != 4 {
|
||||||
|
t.Fatalf("найдено %d модов, ожидалось 4: %v", len(byID), byID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := byID["BetterSorting"]; got.WorkshopID != "111" || got.Name != "Better Sorting" {
|
||||||
|
t.Errorf("BetterSorting разобран неверно: %+v", got)
|
||||||
|
}
|
||||||
|
if got := byID["ModOptions"]; got.WorkshopID != "222" {
|
||||||
|
t.Errorf("раскладка без папки mods не распознана: %+v", got)
|
||||||
|
}
|
||||||
|
if got := byID["MyTweak"]; got.WorkshopID != "" {
|
||||||
|
t.Errorf("локальному моду проставлен Workshop ID: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadModInfoRequiresID(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "mod.info")
|
||||||
|
if err := os.WriteFile(path, []byte("name=Без идентификатора\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := readModInfo(path); ok {
|
||||||
|
t.Error("мод без id принят")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeModInfo(t *testing.T, dir, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "mod.info"), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// workshopAPI — публичный метод Steam, ключ доступа не требуется.
|
// Публичные методы Steam, ключ доступа не требуется.
|
||||||
const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
const (
|
||||||
|
workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||||
|
collectionAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"
|
||||||
|
)
|
||||||
|
|
||||||
// WorkshopItem — то, что панель показывает про мод из мастерской.
|
// WorkshopItem — то, что панель показывает про мод из мастерской.
|
||||||
type WorkshopItem struct {
|
type WorkshopItem struct {
|
||||||
@@ -151,3 +155,71 @@ func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopIte
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collection разворачивает коллекцию мастерской в список входящих в неё
|
||||||
|
// модов. Порядок сохраняется тот же, что и на странице коллекции.
|
||||||
|
func (c *WorkshopClient) Collection(ctx context.Context, id string) ([]string, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("collectioncount", "1")
|
||||||
|
form.Set("publishedfileids[0]", id)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, collectionAPI,
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Steam недоступен: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Steam ответил %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Response struct {
|
||||||
|
CollectionDetails []struct {
|
||||||
|
PublishedFileID string `json:"publishedfileid"`
|
||||||
|
Result int `json:"result"`
|
||||||
|
Children []struct {
|
||||||
|
PublishedFileID string `json:"publishedfileid"`
|
||||||
|
SortOrder int `json:"sortorder"`
|
||||||
|
FileType int `json:"filetype"`
|
||||||
|
} `json:"children"`
|
||||||
|
} `json:"collectiondetails"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("ответ Steam не разобран: %w", err)
|
||||||
|
}
|
||||||
|
if len(payload.Response.CollectionDetails) == 0 {
|
||||||
|
return nil, fmt.Errorf("коллекция %s не найдена", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
details := payload.Response.CollectionDetails[0]
|
||||||
|
// result == 1 означает "найдено"; всё остальное — не коллекция, скрыта
|
||||||
|
// или удалена.
|
||||||
|
if details.Result != 1 {
|
||||||
|
return nil, fmt.Errorf("коллекция %s недоступна — проверьте ссылку и что она открыта всем", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
children := details.Children
|
||||||
|
sort.Slice(children, func(i, j int) bool { return children[i].SortOrder < children[j].SortOrder })
|
||||||
|
|
||||||
|
items := make([]string, 0, len(children))
|
||||||
|
for _, child := range children {
|
||||||
|
// filetype 0 — обычный элемент мастерской; вложенные коллекции
|
||||||
|
// (filetype 2) пропускаем, иначе в список попадут их id вместо модов.
|
||||||
|
if child.FileType != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, child.PublishedFileID)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, fmt.Errorf("в коллекции %s нет модов", id)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|||||||
+252
-53
@@ -612,7 +612,17 @@ $("#btn-sandbox-save").addEventListener("click", async () => {
|
|||||||
// Моды
|
// Моды
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
let mods = { workshop: [], modIDs: [], maps: [] };
|
// В Project Zomboid список модов задаётся двумя строками конфига:
|
||||||
|
// WorkshopItems — что скачать из мастерской, Mods — что включить и в каком
|
||||||
|
// порядке. Панель показывает их одним списком: строка на мод, галочка =
|
||||||
|
// присутствие в Mods, порядок строк = порядок в Mods.
|
||||||
|
let mods = {
|
||||||
|
workshop: [], // WorkshopItems
|
||||||
|
enabled: [], // Mods, порядок важен
|
||||||
|
maps: [],
|
||||||
|
installed: [], // что реально лежит на диске (из mod.info)
|
||||||
|
details: {}, // сведения из мастерской по Workshop ID
|
||||||
|
};
|
||||||
|
|
||||||
async function loadMods() {
|
async function loadMods() {
|
||||||
const data = await get("/api/mods");
|
const data = await get("/api/mods");
|
||||||
@@ -625,100 +635,289 @@ async function loadMods() {
|
|||||||
|
|
||||||
mods = {
|
mods = {
|
||||||
workshop: data.workshop_items || [],
|
workshop: data.workshop_items || [],
|
||||||
modIDs: data.mods || [],
|
enabled: data.mods || [],
|
||||||
maps: data.map || [],
|
maps: data.map || [],
|
||||||
|
installed: data.installed || [],
|
||||||
|
details: mods.details,
|
||||||
};
|
};
|
||||||
$("#map-list").value = mods.maps.join("\n");
|
$("#map-list").value = mods.maps.join("\n");
|
||||||
renderMods();
|
renderMods();
|
||||||
lookupWorkshopTitles().catch(() => {
|
lookupWorkshopTitles().catch(() => {
|
||||||
/* без интернета останутся одни идентификаторы — это не ошибка панели */
|
/* без интернета останутся идентификаторы — это не ошибка панели */
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMods(details = {}) {
|
// buildModRows сводит три источника в один список строк: каталог с диска,
|
||||||
$("#workshop-list").innerHTML = mods.workshop
|
// включённые моды и пакеты мастерской, файлы которых ещё не скачаны.
|
||||||
.map((id, index) => {
|
function buildModRows() {
|
||||||
const info = details[id] || {};
|
const rows = [];
|
||||||
const title = info.error ? `<span class="error">${escapeHTML(info.error)}</span>` : escapeHTML(info.title || "…");
|
const byModID = new Map();
|
||||||
|
|
||||||
|
const push = (row) => {
|
||||||
|
byModID.set(row.modID, row);
|
||||||
|
rows.push(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Моды, найденные на диске. Показываем только те, чей пакет есть в
|
||||||
|
// списке скачиваемых, плюс локальные (без Workshop ID).
|
||||||
|
const wanted = new Set(mods.workshop);
|
||||||
|
mods.installed.forEach((mod) => {
|
||||||
|
if (mod.workshop_id && !wanted.has(mod.workshop_id)) return;
|
||||||
|
push({
|
||||||
|
modID: mod.mod_id,
|
||||||
|
workshopID: mod.workshop_id,
|
||||||
|
name: mod.name || mod.mod_id,
|
||||||
|
note: mod.workshop_id ? "" : "локальный мод",
|
||||||
|
installed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Включённые моды, файлов которых мы не нашли: либо сервер их ещё не
|
||||||
|
// скачал, либо мод пропал из мастерской.
|
||||||
|
mods.enabled.forEach((modID) => {
|
||||||
|
if (byModID.has(modID)) return;
|
||||||
|
push({
|
||||||
|
modID,
|
||||||
|
workshopID: "",
|
||||||
|
name: modID,
|
||||||
|
note: "файлы не найдены — сервер скачает их при запуске",
|
||||||
|
installed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Пакеты мастерской, из которых пока ничего не установлено.
|
||||||
|
const covered = new Set(rows.map((row) => row.workshopID).filter(Boolean));
|
||||||
|
mods.workshop.forEach((workshopID) => {
|
||||||
|
if (covered.has(workshopID)) return;
|
||||||
|
const info = mods.details[workshopID] || {};
|
||||||
|
rows.push({
|
||||||
|
modID: "",
|
||||||
|
workshopID,
|
||||||
|
name: info.title || `Мод ${workshopID}`,
|
||||||
|
note: info.error || "ещё не скачан — включить можно после запуска сервера",
|
||||||
|
installed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Включённые идут первыми и в том порядке, в каком их грузит игра.
|
||||||
|
const order = (row) => {
|
||||||
|
const index = mods.enabled.indexOf(row.modID);
|
||||||
|
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||||
|
};
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const diff = order(a) - order(b);
|
||||||
|
return diff !== 0 ? diff : a.name.localeCompare(b.name, "ru");
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMods() {
|
||||||
|
const filter = $("#mod-filter").value.trim().toLowerCase();
|
||||||
|
const onlyEnabled = $("#mod-only-enabled").checked;
|
||||||
|
const rows = buildModRows();
|
||||||
|
|
||||||
|
const visible = rows.filter((row) => {
|
||||||
|
if (onlyEnabled && !mods.enabled.includes(row.modID)) return false;
|
||||||
|
if (!filter) return true;
|
||||||
|
return `${row.name} ${row.modID} ${row.workshopID}`.toLowerCase().includes(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#mod-summary").textContent =
|
||||||
|
`Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`;
|
||||||
|
|
||||||
|
$("#mod-list").innerHTML = visible
|
||||||
|
.map((row) => {
|
||||||
|
const on = mods.enabled.includes(row.modID);
|
||||||
|
const info = mods.details[row.workshopID] || {};
|
||||||
const preview = info.preview
|
const preview = info.preview
|
||||||
? `<img src="${escapeHTML(info.preview)}" alt="">`
|
? `<img src="${escapeHTML(info.preview)}" alt="">`
|
||||||
|
: `<span class="mod-thumb"></span>`;
|
||||||
|
const noteClass = row.note.includes("не найден") ? "error" : "muted";
|
||||||
|
const workshopLink = row.workshopID
|
||||||
|
? `<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=${escapeHTML(row.workshopID)}"
|
||||||
|
target="_blank" rel="noopener noreferrer">${escapeHTML(row.workshopID)}</a>`
|
||||||
: "";
|
: "";
|
||||||
return `<li>
|
|
||||||
|
// Перетаскивать можно только включённые моды: порядок хранится в Mods.
|
||||||
|
return `<li class="mod-row${on ? " enabled" : ""}" data-mod="${escapeHTML(row.modID)}"
|
||||||
|
data-workshop="${escapeHTML(row.workshopID)}" ${on ? 'draggable="true"' : ""}>
|
||||||
|
<span class="drag-handle" title="${on ? "Перетащите, чтобы изменить порядок" : "Порядок задаётся у включённых модов"}">⠿</span>
|
||||||
|
<input type="checkbox" class="mod-toggle" ${on ? "checked" : ""}
|
||||||
|
${row.modID ? "" : "disabled"} title="Включить мод на сервере">
|
||||||
${preview}
|
${preview}
|
||||||
<div class="mod-title"><b>${title}</b><small>${escapeHTML(id)}</small></div>
|
<div class="mod-title">
|
||||||
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="-1">↑</button>
|
<b>${escapeHTML(row.name)}</b>
|
||||||
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="1">↓</button>
|
<small>${row.modID ? escapeHTML(row.modID) : "Mod ID неизвестен"}${workshopLink ? " · " + workshopLink : ""}</small>
|
||||||
<button class="btn btn-sm btn-danger" data-remove="workshop" data-index="${index}">Убрать</button>
|
${row.note ? `<small class="${noteClass}">${escapeHTML(row.note)}</small>` : ""}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-ghost" data-move="-1" ${on ? "" : "disabled"} title="Выше">↑</button>
|
||||||
|
<button class="btn btn-sm btn-ghost" data-move="1" ${on ? "" : "disabled"} title="Ниже">↓</button>
|
||||||
|
<button class="btn btn-sm btn-danger" data-remove="1">Убрать</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("") || `<li class="muted">Ничего не найдено</li>`;
|
||||||
|
|
||||||
$("#modid-list").innerHTML = mods.modIDs
|
|
||||||
.map(
|
|
||||||
(id, index) => `<li>
|
|
||||||
<div class="mod-title"><b>${escapeHTML(id)}</b></div>
|
|
||||||
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="-1">↑</button>
|
|
||||||
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="1">↓</button>
|
|
||||||
<button class="btn btn-sm btn-danger" data-remove="mod" data-index="${index}">Убрать</button>
|
|
||||||
</li>`
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lookupWorkshopTitles() {
|
async function lookupWorkshopTitles() {
|
||||||
if (!mods.workshop.length) return;
|
if (!mods.workshop.length) return;
|
||||||
const data = await post("/api/mods/lookup", { ids: mods.workshop });
|
const data = await post("/api/mods/lookup", { ids: mods.workshop });
|
||||||
const details = {};
|
|
||||||
(data.items || []).forEach((item) => {
|
(data.items || []).forEach((item) => {
|
||||||
details[item.id] = item;
|
mods.details[item.id] = item;
|
||||||
});
|
});
|
||||||
renderMods(details);
|
renderMods();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обе кнопки — перемещение и удаление — обрабатываем одним слушателем на списке.
|
// moveEnabled двигает мод в списке загрузки на delta позиций.
|
||||||
$("#mods-body").addEventListener("click", (event) => {
|
function moveEnabled(modID, delta) {
|
||||||
const button = event.target.closest("button[data-move], button[data-remove]");
|
const from = mods.enabled.indexOf(modID);
|
||||||
if (!button) return;
|
const to = from + delta;
|
||||||
|
if (from === -1 || to < 0 || to >= mods.enabled.length) return;
|
||||||
|
mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]);
|
||||||
|
renderMods();
|
||||||
|
}
|
||||||
|
|
||||||
const index = Number(button.dataset.index);
|
$("#mod-list").addEventListener("change", (event) => {
|
||||||
const list = (button.dataset.move || button.dataset.remove) === "workshop" ? mods.workshop : mods.modIDs;
|
const toggle = event.target.closest(".mod-toggle");
|
||||||
|
if (!toggle) return;
|
||||||
|
|
||||||
if (button.dataset.move) {
|
const modID = toggle.closest(".mod-row").dataset.mod;
|
||||||
const target = index + Number(button.dataset.dir);
|
if (!modID) return;
|
||||||
if (target < 0 || target >= list.length) return;
|
if (toggle.checked) {
|
||||||
[list[index], list[target]] = [list[target], list[index]];
|
// Новый мод грузится последним: обычно так и надо, а порядок правится
|
||||||
|
// перетаскиванием.
|
||||||
|
if (!mods.enabled.includes(modID)) mods.enabled.push(modID);
|
||||||
} else {
|
} else {
|
||||||
list.splice(index, 1);
|
mods.enabled = mods.enabled.filter((id) => id !== modID);
|
||||||
}
|
}
|
||||||
renderMods();
|
renderMods();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#mod-list").addEventListener("click", (event) => {
|
||||||
|
const row = event.target.closest(".mod-row");
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const move = event.target.closest("button[data-move]");
|
||||||
|
if (move) {
|
||||||
|
moveEnabled(row.dataset.mod, Number(move.dataset.move));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest("button[data-remove]")) return;
|
||||||
|
|
||||||
|
const { mod: modID, workshop: workshopID } = row.dataset;
|
||||||
|
// Убираем и пакет мастерской, и все включённые моды из него: иначе сервер
|
||||||
|
// продолжит скачивать то, что уже не нужно.
|
||||||
|
if (workshopID) {
|
||||||
|
mods.workshop = mods.workshop.filter((id) => id !== workshopID);
|
||||||
|
const fromPackage = mods.installed
|
||||||
|
.filter((mod) => mod.workshop_id === workshopID)
|
||||||
|
.map((mod) => mod.mod_id);
|
||||||
|
mods.enabled = mods.enabled.filter((id) => id !== modID && !fromPackage.includes(id));
|
||||||
|
} else {
|
||||||
|
mods.enabled = mods.enabled.filter((id) => id !== modID);
|
||||||
|
}
|
||||||
|
renderMods();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Перетаскивание порядка загрузки.
|
||||||
|
let dragged = null;
|
||||||
|
|
||||||
|
$("#mod-list").addEventListener("dragstart", (event) => {
|
||||||
|
const row = event.target.closest(".mod-row");
|
||||||
|
if (!row || !row.classList.contains("enabled")) return;
|
||||||
|
dragged = row.dataset.mod;
|
||||||
|
row.classList.add("dragging");
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#mod-list").addEventListener("dragend", () => {
|
||||||
|
dragged = null;
|
||||||
|
$$(".mod-row").forEach((row) => row.classList.remove("dragging", "drop-target"));
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#mod-list").addEventListener("dragover", (event) => {
|
||||||
|
if (!dragged) return;
|
||||||
|
const row = event.target.closest(".mod-row.enabled");
|
||||||
|
if (!row || row.dataset.mod === dragged) return;
|
||||||
|
event.preventDefault();
|
||||||
|
$$(".mod-row").forEach((el) => el.classList.toggle("drop-target", el === row));
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#mod-list").addEventListener("drop", (event) => {
|
||||||
|
if (!dragged) return;
|
||||||
|
const row = event.target.closest(".mod-row.enabled");
|
||||||
|
if (!row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const from = mods.enabled.indexOf(dragged);
|
||||||
|
const to = mods.enabled.indexOf(row.dataset.mod);
|
||||||
|
if (from === -1 || to === -1) return;
|
||||||
|
mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]);
|
||||||
|
dragged = null;
|
||||||
|
renderMods();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#mod-filter").addEventListener("input", renderMods);
|
||||||
|
$("#mod-only-enabled").addEventListener("change", renderMods);
|
||||||
|
|
||||||
|
// extractWorkshopID принимает как голый идентификатор, так и ссылку на
|
||||||
|
// страницу мастерской.
|
||||||
|
function extractWorkshopID(input) {
|
||||||
|
const match = input.match(/(\d{5,})/);
|
||||||
|
return match ? match[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#mod-add-form").addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const id = extractWorkshopID($("#mod-input").value.trim());
|
||||||
|
if (!id) {
|
||||||
|
toast("Не похоже на ссылку или ID мода", "err");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mods.workshop.includes(id)) {
|
||||||
|
toast("Этот мод уже в списке");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mods.workshop.push(id);
|
||||||
|
$("#mod-input").value = "";
|
||||||
|
renderMods();
|
||||||
lookupWorkshopTitles().catch(() => {});
|
lookupWorkshopTitles().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#mod-add-form").addEventListener("submit", (event) => {
|
$("#btn-add-collection").addEventListener("click", async () => {
|
||||||
event.preventDefault();
|
const input = $("#mod-input").value.trim();
|
||||||
const workshopID = $("#mod-workshop-id").value.trim();
|
if (!input) {
|
||||||
const modID = $("#mod-mod-id").value.trim();
|
toast("Вставьте ссылку на коллекцию или её ID", "err");
|
||||||
|
|
||||||
if (workshopID && !/^\d+$/.test(workshopID)) {
|
|
||||||
toast("Workshop ID — это число из ссылки на мод", "err");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!workshopID && !modID) return;
|
try {
|
||||||
|
const data = await post("/api/mods/collection", { id: input });
|
||||||
|
const items = data.items || [];
|
||||||
|
|
||||||
if (workshopID && !mods.workshop.includes(workshopID)) mods.workshop.push(workshopID);
|
let added = 0;
|
||||||
if (modID && !mods.modIDs.includes(modID)) mods.modIDs.push(modID);
|
items.forEach((item) => {
|
||||||
|
mods.details[item.id] = item;
|
||||||
|
if (!mods.workshop.includes(item.id)) {
|
||||||
|
mods.workshop.push(item.id);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$("#mod-workshop-id").value = "";
|
$("#mod-input").value = "";
|
||||||
$("#mod-mod-id").value = "";
|
|
||||||
renderMods();
|
renderMods();
|
||||||
lookupWorkshopTitles().catch(() => {});
|
ok(added
|
||||||
|
? `Из коллекции добавлено модов: ${added} (всего в ней ${items.length})`
|
||||||
|
: `Все ${items.length} модов коллекции уже были в списке`);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#btn-mods-save").addEventListener("click", async () => {
|
$("#btn-mods-save").addEventListener("click", async () => {
|
||||||
const maps = $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean);
|
const maps = $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||||
try {
|
try {
|
||||||
await put("/api/mods", {
|
await put("/api/mods", {
|
||||||
mods: mods.modIDs,
|
mods: mods.enabled,
|
||||||
workshop_items: mods.workshop,
|
workshop_items: mods.workshop,
|
||||||
map: maps,
|
map: maps,
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-10
@@ -173,7 +173,8 @@
|
|||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Моды</h2>
|
<h2>Моды</h2>
|
||||||
<p class="muted small">Сервер сам скачивает моды из мастерской при запуске.</p>
|
<p class="muted small">Галочка включает мод, перетаскивание задаёт порядок загрузки.
|
||||||
|
Сервер сам скачивает моды из мастерской при запуске.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="btn-mods-save">Сохранить</button>
|
<button class="btn btn-primary" id="btn-mods-save">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,18 +183,22 @@
|
|||||||
|
|
||||||
<div id="mods-body">
|
<div id="mods-body">
|
||||||
<form class="mod-add" id="mod-add-form">
|
<form class="mod-add" id="mod-add-form">
|
||||||
<input type="text" id="mod-workshop-id" placeholder="Workshop ID, например 2392709985" inputmode="numeric">
|
<input type="text" id="mod-input" autocomplete="off"
|
||||||
<input type="text" id="mod-mod-id" placeholder="Mod ID (из описания мода)">
|
placeholder="Ссылка на мод или коллекцию, либо просто ID">
|
||||||
<button class="btn" type="submit">Добавить</button>
|
<button class="btn" type="submit">Добавить мод</button>
|
||||||
|
<button class="btn" type="button" id="btn-add-collection">Добавить коллекцию</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="muted small">Workshop ID — число из ссылки на мод. Mod ID автор указывает в описании; у одного мода
|
<p class="muted small">Коллекция разворачивается в список входящих в неё модов.
|
||||||
мастерской их может быть несколько.</p>
|
Сами файлы сервер скачивает сам при запуске — после этого моды можно включать галочками.</p>
|
||||||
|
|
||||||
<h3>Моды из мастерской</h3>
|
<div class="mod-toolbar">
|
||||||
<ul class="mod-list" id="workshop-list"></ul>
|
<span class="muted small" id="mod-summary"></span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<input type="search" id="mod-filter" class="input-sm" placeholder="Поиск по названию…">
|
||||||
|
<label class="checkbox"><input type="checkbox" id="mod-only-enabled"> Только включённые</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3>Порядок загрузки (Mods)</h3>
|
<ul class="mod-list" id="mod-list"></ul>
|
||||||
<ul class="mod-list" id="modid-list"></ul>
|
|
||||||
|
|
||||||
<h3>Карты (Map)</h3>
|
<h3>Карты (Map)</h3>
|
||||||
<textarea id="map-list" rows="3" spellcheck="false"
|
<textarea id="map-list" rows="3" spellcheck="false"
|
||||||
|
|||||||
@@ -295,6 +295,34 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
|||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mod-toolbar { display: flex; align-items: center; gap: 10px; margin: 14px 0 10px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Включённый мод виден с одного взгляда: он попадёт на сервер. */
|
||||||
|
.mod-row.enabled { border-left: 3px solid var(--accent); }
|
||||||
|
.mod-row .mod-toggle { width: 16px; height: 16px; flex: none; cursor: pointer; }
|
||||||
|
.mod-row .mod-toggle:disabled { cursor: not-allowed; opacity: .4; }
|
||||||
|
.mod-row .mod-title small { display: block; }
|
||||||
|
.mod-row .mod-title a { color: var(--muted); }
|
||||||
|
|
||||||
|
.mod-thumb {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mod-row:not(.enabled) .drag-handle { opacity: .3; cursor: default; }
|
||||||
|
.mod-row.dragging { opacity: .5; }
|
||||||
|
.mod-row.drop-target { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||||
.mod-list .mod-title { flex: 1; min-width: 0; }
|
.mod-list .mod-title { flex: 1; min-width: 0; }
|
||||||
.mod-list .mod-title b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.mod-list .mod-title b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.mod-list .mod-title small { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
.mod-list .mod-title small { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user