From 49aa0831f7b5ba8c2c44283ede06a038555cf1e6 Mon Sep 17 00:00:00 2001 From: Mechanica Date: Wed, 12 Aug 2026 23:47:48 +0500 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=BE=D1=80=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 1533 +---------------------- src/adminApi.ts | 47 + src/components/CompatibilityDetails.tsx | 36 + src/components/Equipment.tsx | 600 +++++++++ src/components/Home.tsx | 33 + src/components/ItemDrawer.tsx | 55 + src/components/ItemLinkList.tsx | 16 + src/components/NotFound.tsx | 5 + src/components/Sprite.tsx | 12 + src/components/Stats.tsx | 293 +++++ src/components/ToolPlaceholder.tsx | 18 + src/constants.ts | 57 + src/format.ts | 309 +++++ src/types.ts | 68 + src/useCatalog.ts | 31 + 15 files changed, 1585 insertions(+), 1528 deletions(-) create mode 100644 src/adminApi.ts create mode 100644 src/components/CompatibilityDetails.tsx create mode 100644 src/components/Equipment.tsx create mode 100644 src/components/Home.tsx create mode 100644 src/components/ItemDrawer.tsx create mode 100644 src/components/ItemLinkList.tsx create mode 100644 src/components/NotFound.tsx create mode 100644 src/components/Sprite.tsx create mode 100644 src/components/Stats.tsx create mode 100644 src/components/ToolPlaceholder.tsx create mode 100644 src/constants.ts create mode 100644 src/format.ts create mode 100644 src/types.ts create mode 100644 src/useCatalog.ts diff --git a/src/App.tsx b/src/App.tsx index dd89593..08d89ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,132 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Link, NavLink, Route, Routes, useParams, useSearchParams } from "react-router-dom"; - -const DATA_ROOT = - "https://raw.githubusercontent.com/DeferW/ssmc-wiki-data/main/data/"; -const DATA_URL = `${DATA_ROOT}equipment-catalog.json`; - -const CATEGORY_ORDER = [ - "Оружие", - "Боеприпасы и взрывчатка", - "Обвесы", - "Ближний бой", - "Броня", - "Экипировка", - "Медицина", - "Снаряжение", - "Другое", -] as const; - -const ADMIN_DRAFT_KEY = "ssmc-equipment-admin-overrides-v1"; -const ADMIN_API_URL = - "https://ssmc-wiki-admin-api.24dfffer.workers.dev/api/overrides"; - -type JsonMap = Record; - -type AttachmentSlot = { - id?: string; - slotId?: string; - name?: string; - slotName?: string; - compatibleItemIds?: string[]; - installedItemIds?: string[]; - weaponIds?: string[]; -}; - -type CatalogItem = { - id: string; - name: string; - baseName?: string; - description?: unknown; - category?: string; - image?: string; - types?: string[]; - tags?: string[]; - componentTypes?: string[]; - equipmentSlots?: string[]; - properties?: Record; - classification?: { - category?: string; - categoryId?: string; - confidence?: string; - reason?: string; - }; - attachmentSlots?: AttachmentSlot[]; - attachableTo?: AttachmentSlot[]; - magazineSlots?: AttachmentSlot[]; - compatibleWeaponIds?: string[]; - containsItemIds?: string[]; - weaponStats?: JsonMap; - armorStats?: JsonMap; - attachmentStats?: JsonMap; - storageStats?: JsonMap; -}; - -type PublicCatalog = { - itemIds: string[]; - categories: Record; - aliases?: Record; -}; - -type Catalog = { - schemaVersion: number; - gameCommit: string; - items: Record; - publicCatalog: PublicCatalog; - counts?: { publicItems?: number }; -}; - -type AdminOverride = { - category?: string; - hidden?: boolean; -}; - -type AdminDraft = Record; - -type AdminApiDocument = { - schemaVersion: 1; - items: AdminDraft; -}; - -type AdminSyncState = "loading" | "ready" | "saving" | "saved" | "error"; - -const modules = [ - { - slug: "equipment", - title: "Снаряжение", - text: "Оружие, боеприпасы, обвесы и остальное оснащение морпехов.", - }, - { - slug: "weapon-builder", - title: "Конструктор оружия", - text: "Выбор оружия, совместимых обвесов и итоговых характеристик сборки.", - status: "Запланировано", - }, - { - slug: "damage", - title: "Калькулятор урона", - text: "Урон на выбранной дистанции с falloff, бронёй и бронепробитием.", - status: "Запланировано", - }, - { - slug: "compare", - title: "Сравнение", - text: "Несколько предметов рядом без прыжков между вкладками вики.", - status: "Запланировано", - }, - { - slug: "loadout", - title: "Комплект бойца", - text: "Оружие, броня, пояс, подсумки и расходники в одной сборке.", - status: "Запланировано", - }, - { - slug: "chemistry", - title: "Химический планировщик", - text: "Расчёт реагентов и пошаговый маршрут приготовления.", - status: "Запланировано", - }, -]; +import { Link, NavLink, Route, Routes } from "react-router-dom"; +import { AdminEquipment, Equipment } from "./components/Equipment"; +import { Home } from "./components/Home"; +import { NotFound } from "./components/NotFound"; +import { ToolPlaceholder } from "./components/ToolPlaceholder"; function App() { return ( @@ -149,1403 +25,4 @@ function App() { ); } -function Home() { - return ( -
- -
- ); -} - -function useCatalog() { - const [catalog, setCatalog] = useState(null); - const [loadState, setLoadState] = useState<"loading" | "live" | "error">("loading"); - - useEffect(() => { - const controller = new AbortController(); - fetch(DATA_URL, { signal: controller.signal, cache: "no-cache" }) - .then((response) => { - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; - }) - .then((data) => { - if (data.schemaVersion < 3 || !data.publicCatalog?.itemIds) { - throw new Error("Unsupported equipment catalog schema"); - } - setCatalog(data); - setLoadState("live"); - }) - .catch((error: unknown) => { - if (error instanceof DOMException && error.name === "AbortError") return; - setLoadState("error"); - }); - return () => controller.abort(); - }, []); - - return { catalog, loadState }; -} - -function Equipment({ adminMode = false }: { adminMode?: boolean }) { - const { catalog, loadState } = useCatalog(); - const [searchParams, setSearchParams] = useSearchParams(); - const [filtersOpen, setFiltersOpen] = useState(false); - const [draft, setDraft] = useState(() => readAdminDraft()); - const [selectedAdminIds, setSelectedAdminIds] = useState>(() => new Set()); - const [bulkCategory, setBulkCategory] = useState(CATEGORY_ORDER[0]); - const [adminPassword, setAdminPassword] = useState(""); - const [remoteSha, setRemoteSha] = useState(null); - const [adminSyncState, setAdminSyncState] = useState(adminMode ? "loading" : "ready"); - const [adminSyncMessage, setAdminSyncMessage] = useState(adminMode ? "Загружаю сохранённые изменения…" : ""); - const [showBackToTop, setShowBackToTop] = useState(false); - const resultsTop = useRef(null); - - const query = searchParams.get("q") || ""; - const selectedCategories = searchParams.getAll("category"); - const selectedSlots = searchParams.getAll("slot"); - const compatibleWeapon = searchParams.get("weapon") || ""; - const sort = searchParams.get("sort") || "name"; - const showHidden = searchParams.get("hidden") === "1"; - const selectedId = searchParams.get("item"); - const changes = Object.entries(draft).filter(([, value]) => value.category || value.hidden !== undefined); - - useEffect(() => { - if (!adminMode) return; - const controller = new AbortController(); - const localDraft = readAdminDraft(); - const hasLocalDraft = Object.keys(localDraft).length > 0; - - fetch(ADMIN_API_URL, { signal: controller.signal, cache: "no-store" }) - .then(async (response) => { - const result: unknown = await response.json(); - if (!response.ok || !isMap(result) || result.ok !== true) { - throw new Error(readApiError(result, `HTTP ${response.status}`)); - } - const remoteDraft = normalizeApiDocument(result.overrides); - setRemoteSha(typeof result.sha === "string" ? result.sha : null); - if (hasLocalDraft) { - setDraft(localDraft); - setAdminSyncMessage("Восстановлен локальный черновик. Сохранённая версия с GitHub загружена для проверки конфликта."); - } else { - setDraft(remoteDraft); - setAdminSyncMessage(result.exists === true ? "Сохранённые изменения загружены из GitHub." : "Файл изменений ещё не создан. Первое сохранение создаст его."); - } - setAdminSyncState("ready"); - }) - .catch((error: unknown) => { - if (error instanceof DOMException && error.name === "AbortError") return; - setAdminSyncState("error"); - setAdminSyncMessage(error instanceof Error ? `Не удалось загрузить изменения: ${error.message}` : "Не удалось загрузить изменения."); - }); - - return () => controller.abort(); - }, [adminMode]); - - useEffect(() => { - if (!adminMode || typeof window === "undefined") return; - window.localStorage.setItem(ADMIN_DRAFT_KEY, JSON.stringify(draft)); - }, [adminMode, draft]); - - useEffect(() => { - const onScroll = () => setShowBackToTop(window.scrollY > 620); - onScroll(); - window.addEventListener("scroll", onScroll, { passive: true }); - return () => window.removeEventListener("scroll", onScroll); - }, []); - - function changeOverride(id: string, patch: AdminOverride) { - setDraft((current) => ({ ...current, [id]: { ...current[id], ...patch } })); - } - - function resetOverride(id: string) { - setDraft((current) => { - const next = { ...current }; - delete next[id]; - return next; - }); - } - - function toggleAdminSelection(id: string) { - setSelectedAdminIds((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - } - - function applyBulkOverride(patch: AdminOverride) { - if (!selectedAdminIds.size) return; - const affectedCount = selectedAdminIds.size; - setDraft((current) => { - const next = { ...current }; - for (const id of selectedAdminIds) next[id] = { ...next[id], ...patch }; - return next; - }); - setSelectedAdminIds(new Set()); - setAdminSyncState("ready"); - setAdminSyncMessage(`Действие добавлено в черновик для ${affectedCount} предметов. Можно выбрать следующую группу.`); - } - - function resetSelectedOverrides() { - if (!selectedAdminIds.size) return; - const affectedCount = selectedAdminIds.size; - setDraft((current) => { - const next = { ...current }; - for (const id of selectedAdminIds) delete next[id]; - return next; - }); - setSelectedAdminIds(new Set()); - setAdminSyncState("ready"); - setAdminSyncMessage(`Изменения сброшены для ${affectedCount} предметов. Выбор очищен.`); - } - - function exportOverrides() { - const items = Object.fromEntries(changes); - const blob = new Blob( - [`${JSON.stringify({ schemaVersion: 1, items }, null, 2)}\n`], - { type: "application/json;charset=utf-8" }, - ); - const href = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = href; - link.download = "equipment-overrides.json"; - link.click(); - URL.revokeObjectURL(href); - } - - async function saveOverrides() { - if (!adminPassword) { - setAdminSyncState("error"); - setAdminSyncMessage("Введите пароль администратора."); - return; - } - - setAdminSyncState("saving"); - setAdminSyncMessage("Сохраняю изменения в GitHub…"); - - try { - const response = await fetch(ADMIN_API_URL, { - method: "PUT", - headers: { - "Content-Type": "application/json", - "X-Admin-Password": adminPassword, - }, - body: JSON.stringify({ - sha: remoteSha, - overrides: makeAdminDocument(Object.fromEntries(changes)), - }), - }); - const result: unknown = await response.json(); - - if (!response.ok || !isMap(result) || result.ok !== true) { - if (response.status === 401) throw new Error("Неверный пароль администратора."); - if (response.status === 409 || (isMap(result) && result.code === "SHA_CONFLICT")) { - throw new Error("Файл уже изменился после загрузки страницы. Обновите страницу, проверьте изменения и повторите сохранение."); - } - throw new Error(readApiError(result, `HTTP ${response.status}`)); - } - - setRemoteSha(typeof result.sha === "string" ? result.sha : null); - window.localStorage.removeItem(ADMIN_DRAFT_KEY); - setAdminSyncState("saved"); - setAdminSyncMessage(result.created === true ? "Файл изменений создан в GitHub." : "Изменения сохранены в GitHub."); - } catch (error: unknown) { - setAdminSyncState("error"); - setAdminSyncMessage(error instanceof Error ? error.message : "Не удалось сохранить изменения."); - } - } - - const publicIds = useMemo(() => catalog?.publicCatalog.itemIds || [], [catalog]); - - const categories = useMemo(() => { - if (!catalog) return []; - const fromCatalog = Object.keys(catalog.publicCatalog.categories || {}); - return CATEGORY_ORDER.filter((name) => fromCatalog.includes(name)); - }, [catalog]); - - const attachmentSlots = useMemo(() => { - if (!catalog) return []; - return [...new Set(publicIds.flatMap((id) => - (catalog.items[id]?.attachableTo || []).map((slot) => slot.slotName || slot.name || ""), - ).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ru")); - }, [catalog, publicIds]); - - const compatibleWeapons = useMemo(() => { - if (!catalog) return []; - const ids = new Set(); - for (const id of publicIds) { - const item = catalog.items[id]; - for (const weaponId of item?.compatibleWeaponIds || []) ids.add(weaponId); - for (const slot of item?.attachableTo || []) { - for (const weaponId of slot.weaponIds || []) ids.add(weaponId); - } - } - return [...ids] - .filter((id) => catalog.items[id]) - .sort((a, b) => catalog.items[a].name.localeCompare(catalog.items[b].name, "ru")); - }, [catalog, publicIds]); - - function patchParams(update: (next: URLSearchParams) => void) { - const next = new URLSearchParams(searchParams); - update(next); - setSearchParams(next, { replace: true }); - } - - function setParam(name: string, value: string | null) { - patchParams((next) => { - if (value) next.set(name, value); - else next.delete(name); - if (name !== "item") next.delete("item"); - }); - } - - function toggleParam(name: string, value: string) { - patchParams((next) => { - const values = new Set(next.getAll(name)); - if (values.has(value)) values.delete(value); - else values.add(value); - next.delete(name); - for (const entry of values) next.append(name, entry); - next.delete("item"); - }); - } - - function openCategory(category: string) { - patchParams((next) => { - next.delete("category"); - next.append("category", category); - next.delete("item"); - }); - setFiltersOpen(false); - window.requestAnimationFrame(() => resultsTop.current?.scrollIntoView({ behavior: "smooth", block: "start" })); - } - - function clearFilters() { - setSearchParams(sort === "name" ? {} : { sort }, { replace: true }); - } - - function effectiveCategory(id: string, item: CatalogItem) { - return adminMode && draft[id]?.category ? draft[id].category : item.category || "Другое"; - } - - const matchesItem = (id: string, ignoredFacet?: "category" | "slot") => { - const item = catalog?.items[id]; - if (!item) return false; - const normalized = normalize(query); - const matchesQuery = !normalized || [item.name, descriptionText(item.description), id] - .filter(Boolean) - .some((value) => normalize(String(value)).includes(normalized)); - const slots = new Set((item.attachableTo || []).map((slot) => slot.slotName || slot.name || "")); - const matchesCategory = ignoredFacet === "category" || !selectedCategories.length - || selectedCategories.includes(effectiveCategory(id, item)); - const matchesSlot = ignoredFacet === "slot" || !selectedSlots.length - || selectedSlots.some((slot) => slots.has(slot)); - const matchesWeapon = !compatibleWeapon || itemCompatibleWith(item, compatibleWeapon); - const override = draft[id]; - const matchesVisibility = !adminMode || showHidden || override?.hidden !== true; - return matchesQuery && matchesCategory && matchesSlot && matchesWeapon && matchesVisibility; - }; - - const visibleIds = catalog - ? publicIds.filter((id) => matchesItem(id)).sort((a, b) => { - const first = catalog.items[a]; - const second = catalog.items[b]; - if (sort === "category") { - const order = categoryIndex(effectiveCategory(a, first)) - categoryIndex(effectiveCategory(b, second)); - if (order) return order; - } - return first.name.localeCompare(second.name, "ru") || a.localeCompare(b); - }) - : []; - - const selectedVisibleCount = visibleIds.filter((id) => selectedAdminIds.has(id)).length; - const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length; - - function toggleAllVisible() { - setSelectedAdminIds((current) => { - const next = new Set(current); - if (allVisibleSelected) { - for (const id of visibleIds) next.delete(id); - } else { - for (const id of visibleIds) next.add(id); - } - return next; - }); - } - - const categoryCounts = (() => { - const counts = new Map(); - for (const id of publicIds) { - if (!matchesItem(id, "category")) continue; - const item = catalog?.items[id]; - const category = item ? effectiveCategory(id, item) : null; - if (category) counts.set(category, (counts.get(category) || 0) + 1); - } - return counts; - })(); - - const slotCounts = (() => { - const counts = new Map(); - for (const id of publicIds) { - if (!matchesItem(id, "slot")) continue; - for (const slot of catalog?.items[id]?.attachableTo || []) { - const name = slot.slotName || slot.name; - if (name) counts.set(name, (counts.get(name) || 0) + 1); - } - } - return counts; - })(); - - const activeFilters = [ - ...selectedCategories.map((value) => ({ key: `category:${value}`, label: value, clear: () => toggleParam("category", value) })), - ...selectedSlots.map((value) => ({ key: `slot:${value}`, label: `Обвес: ${value}`, clear: () => toggleParam("slot", value) })), - ...(compatibleWeapon && catalog?.items[compatibleWeapon] - ? [{ key: "weapon", label: `Для: ${catalog.items[compatibleWeapon].name}`, clear: () => setParam("weapon", null) }] - : []), - ...(adminMode && showHidden - ? [{ key: "hidden", label: "Показаны скрытые", clear: () => setParam("hidden", null) }] - : []), - ]; - - const selectedItem = selectedId && catalog ? catalog.items[selectedId] : null; - - useEffect(() => { - if (!filtersOpen && !selectedId) return; - const previousOverflow = document.body.style.overflow; - if (filtersOpen) document.body.style.overflow = "hidden"; - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - if (filtersOpen) setFiltersOpen(false); - else if (selectedId) { - const next = new URLSearchParams(searchParams); - next.delete("item"); - setSearchParams(next, { replace: true }); - } - }; - window.addEventListener("keydown", closeOnEscape); - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener("keydown", closeOnEscape); - }; - }, [selectedId, filtersOpen, searchParams, setSearchParams]); - - return ( -
-
-
-

{adminMode ? "Режим администратора" : "Арсенал морпехов"}

-

{adminMode ? "Управление снаряжением" : "Каталог снаряжения"}

-

{adminMode - ? "Тот же каталог, но с быстрым изменением категории и видимости прямо на карточках." - : "Один поиск, понятные разделы и характеристики без похода в исходный YAML."}

-
- - {adminMode ? "Выйти из админ-режима" : "Админ-режим"} - -
- - {adminMode && ( -
-
- {changes.length} изменений · {selectedAdminIds.size} выбрано -
- - - - - - - - - -

{adminSyncMessage}

- Пароль хранится только в памяти этой вкладки. Локальный черновик остаётся страховкой до успешного сохранения. -
- )} - - - - {loadState === "loading" &&

Загружаю актуальный каталог…

} - {loadState === "error" &&

Не удалось загрузить каталог. Попробуйте обновить страницу чуть позже.

} - - {catalog && ( -
- {filtersOpen && -
- -
- {categories.map((name) => { - const checked = selectedCategories.includes(name); - const count = categoryCounts.get(name) || 0; - return ( -
- - -
- ); - })} -
- - {adminMode && ( - - )} - - {attachmentSlots.length > 0 && ( -
- Место установки обвеса -
- {attachmentSlots.map((slot) => ( - - ))} -
-
- )} - - {compatibleWeapons.length > 0 && ( - - )} - - - - -
-
-
- - Показано {visibleIds.length} из {publicIds.length} -
- -
- - {activeFilters.length > 0 && ( -
- {activeFilters.map((filter) => ( - - ))} - -
- )} - -
- {visibleIds.map((id) => { - const item = catalog.items[id]; - const isSelected = selectedItem?.id === id; - const override = draft[id]; - const adminSelected = selectedAdminIds.has(id); - const hidden = override?.hidden === true; - return ( -
- {adminMode && ( - - )} - - {adminMode && ( -
- - - -
- )} -
- ); - })} -
- {!visibleIds.length &&

Ничего не найдено. Даже подозрительного ящика.

} -
- - )} - - {selectedItem && catalog && ( - setParam("item", null)} - onSelect={(id) => setParam("item", id)} - /> - )} - {showBackToTop && ( - - )} -
- ); -} - -function ItemDrawer({ - item, - catalog, - onClose, - onSelect, -}: { - item: CatalogItem; - catalog: Catalog; - onClose: () => void; - onSelect: (id: string) => void; -}) { - const contained = ["Броня", "Экипировка"].includes(item.category || "") - ? [] - : (item.containsItemIds || []).filter((id) => catalog.items[id]); - - return ( -
{ - if (event.target === event.currentTarget) onClose(); - }}> - -
- ); -} - -function StatsDetails({ item }: { item: CatalogItem }) { - return ( - <> - {item.weaponStats && } - {item.armorStats && } - {item.attachmentStats && } - - - ); -} - -function WeaponStats({ stats }: { stats: JsonMap }) { - const recoil = isMap(stats.recoil) ? stats.recoil : {}; - const scatter = isMap(stats.scatter) ? stats.scatter : {}; - const accuracy = isMap(stats.accuracy) ? stats.accuracy : {}; - const wieldDelay = isMap(stats.wieldDelay) ? stats.wieldDelay : {}; - const fireModes = Array.isArray(stats.fireModes) ? stats.fireModes.map((mode) => fireModeLabel(String(mode))) : []; - const rows: Array<[string, unknown]> = [ - ["Темп стрельбы", stats.roundsPerMinute != null ? `${formatNumber(stats.roundsPerMinute)} выстр./мин` : null], - ["Режимы огня", fireModes.length ? fireModes.join(", ") : null], - ["Основной режим", fireModes.length > 1 && stats.defaultFireMode ? fireModeLabel(String(stats.defaultFireMode)) : null], - ["Очередь", stats.burstSize != null ? `${formatNumber(stats.burstSize)} выстр.` : null], - ["Бронепробитие", stats.weaponArmorPiercing], - ["Множитель урона", stats.damageMultiplier != null ? `×${formatNumber(stats.damageMultiplier)}` : null], - ["Отдача · в упоре", recoil.wielded], - ["Отдача · с рук", recoil.unwielded], - ["Разброс · в упоре", scatter.wielded], - ["Разброс · с рук", scatter.unwielded], - ["Точность · в упоре", asMultiplier(accuracy.wieldedMultiplier)], - ["Точность · с рук", asMultiplier(accuracy.unwieldedMultiplier)], - ["Время вскидывания", wieldDelay.baseDelay != null ? `${formatNumber(wieldDelay.baseDelay)} с` : null], - ["IFF", stats.iffEnabled], - ]; - const provider = isMap(stats.ammoProvider) ? stats.ammoProvider : null; - if (provider) { - rows.push(["Ёмкость", provider.capacity]); - rows.push(["Штатный боеприпас", provider.startingAmmoId]); - } - const movement = isMap(stats.wieldedMovement) ? stats.wieldedMovement : null; - if (movement) { - for (const [key, label] of [["base", "без брони"], ["light", "лёгкая броня"], ["medium", "средняя броня"], ["heavy", "тяжёлая броня"]] as const) { - const value = movement[key]; - if (typeof value === "number" && value !== 1) rows.push([`Снижение скорости · ${label}`, `−${Math.round((1 - value) * 100)}%`]); - } - } - const ammunition = Array.isArray(stats.ammunition) ? stats.ammunition.filter(isMap) : []; - return ( -
-

Характеристики оружия

- - {ammunition.length > 0 && ( -
-

Магазины, боеприпасы и урон

- {ammunition.map((entry, index) => { - const projectiles = Array.isArray(entry.projectiles) ? entry.projectiles.filter(isMap) : []; - return ( -
-
- {String(entry.magazineName || entry.ammoName || entry.magazineId || entry.ammoId || "Боеприпас")} - {entry.capacity != null && {formatNumber(entry.capacity)} шт.} -
- {projectiles.map((projectile, projectileIndex) => ( -
-
- {formatDamage(projectile.effectiveDamage || projectile.damage)} - {projectile.armorPiercing != null && Бронепробитие {formatNumber(projectile.armorPiercing)}} -
- -
- ))} -
- ); - })} -
- )} - -
- ); -} - -function ArmorStats({ stats }: { stats: JsonMap }) { - const protection = isMap(stats.protection) ? stats.protection : {}; - const movement = isMap(stats.movement) ? stats.movement : {}; - const protectionRows = Object.entries(protection) - .filter(([, value]) => typeof value === "number" && value !== 0) - .map(([key, value]) => [armorLabel(key), value] as [string, unknown]); - return ( -
-

Защита брони

- -

Сопротивления

- -
- ); -} - -function AttachmentStats({ stats }: { stats: JsonMap }) { - const modifiers = isMap(stats.modifiers) ? stats.modifiers : {}; - const rows = attachmentModifierRows(modifiers); - return ( -
-

Характеристики обвеса

- -
- ); -} - -function CommonItemStats({ item }: { item: CatalogItem }) { - const properties = item.properties || {}; - const solutionManager = isMap(properties.SolutionContainerManager) ? properties.SolutionContainerManager : null; - const solutions = solutionManager && isMap(solutionManager.solutions) ? solutionManager.solutions : {}; - const storage = isMap(item.storageStats) ? item.storageStats : null; - const melee = isMap(properties.MeleeWeapon) ? properties.MeleeWeapon : null; - const armor = isMap(properties.CMArmor) ? properties.CMArmor : null; - const armorPiercing = isMap(properties.CMArmorPiercing) ? properties.CMArmorPiercing : null; - const ammoProvider = ["BallisticAmmoProvider", "RevolverAmmoProvider", "CartridgeAmmo", "RMCFlamerTank"] - .map((key) => [key, properties[key]] as [string, unknown]) - .find(([, value]) => isMap(value)); - - const overviewRows: Array<[string, unknown]> = [ - ["Бронепробитие в ближнем бою", armorPiercing?.amount], - ]; - const hasOverview = overviewRows.some(([, value]) => value !== null && value !== undefined && value !== ""); - - return ( - <> - {hasOverview &&

Основные свойства

} - {Object.keys(solutions).length > 0 && ( -
-

Состав и объём

-
- {Object.entries(solutions).map(([name, raw]) => { - const solution = isMap(raw) ? raw : {}; - const reagents = Array.isArray(solution.reagents) ? solution.reagents.filter(isMap) : []; - return ( -
-
{solutionLabel(name)}{solution.maxVol != null && {formatNumber(solution.maxVol)} ед.}
- {reagents.length ?
    {reagents.map((reagent, index) =>
  • {readableId(String(reagent.ReagentId || "Реагент"))}{formatNumber(reagent.Quantity)} ед.
  • )}
: Пусто} -
- ); - })} -
-
- )} - {storage && ( -
-

Хранилище

- -
- )} - {melee && !item.weaponStats && ( -
-

Ближний бой

- -
- )} - {armor && !item.armorStats && ( -
-

Дополнительная защита

- [armorLabel(key), value] as [string, unknown])} /> -
- )} - {ammoProvider && !item.weaponStats && ( -
-

Боеприпасы

- -
- )} - - ); -} - -function StorageSummary({ storage }: { storage: JsonMap }) { - const capacities = Array.isArray(storage.capacities) ? storage.capacities.filter(isMap) : []; - const limits = Array.isArray(storage.limits) ? storage.limits.filter(isMap) : []; - const exactPlaces = typeof storage.exactPlaces === "number" ? storage.exactPlaces : null; - const capacityText = capacities - .map((entry) => `${formatNumber(entry.count)} ${itemSizeGenitive(String(entry.size || "Small"))}`) - .join(" · "); - const limitText = limits - .map((entry) => typeof entry.count === "number" ? `до ${formatNumber(entry.count)} специальных` : null) - .filter(Boolean) - .join(" · "); - return ; -} - -function ProjectileRange({ projectile }: { projectile: JsonMap }) { - const accuracy = isMap(projectile.accuracy) ? projectile.accuracy : {}; - const falloff = isMap(projectile.damageFalloff) ? projectile.damageFalloff : {}; - const thresholds: Array<{ kind: string; range?: unknown; falloff?: unknown }> = [ - ...(Array.isArray(accuracy.thresholds) ? accuracy.thresholds.filter(isMap).map((entry) => ({ kind: "Точность", range: entry.range, falloff: entry.falloff })) : []), - ...(Array.isArray(falloff.thresholds) ? falloff.thresholds.filter(isMap).map((entry) => ({ kind: "Урон", range: entry.range, falloff: entry.falloff })) : []), - ]; - if (accuracy.accuracy == null && !thresholds.length) return null; - return ( -
- {accuracy.accuracy != null && Точность {formatNumber(accuracy.accuracy)}} - {thresholds.slice(0, 3).map((entry, index) => {entry.kind}: после {formatNumber(entry.range)} тайл. −{formatNumber(entry.falloff)})} -
- ); -} - -function ProtectionBars({ rows }: { rows: Array<[string, unknown]> }) { - const visible = rows.filter(([, value]) => typeof value === "number" && value !== 0); - if (!visible.length) return

Сопротивления не указаны.

; - return ( -
- {visible.map(([label, value]) => { - const amount = Number(value); - return
{label}
{formatNumber(amount)}%
; - })} -
- ); -} - -function ReadableStatGroups({ groups }: { groups: Array<[string, unknown]> }) { - const visible = groups.filter(([, value]) => value !== null && value !== undefined - && (!Array.isArray(value) || value.length > 0) - && (!isMap(value) || Object.keys(value).length > 0)); - if (!visible.length) return null; - return ( -
- {visible.map(([title, value]) => ( -
- {title} -
-
- ))} -
- ); -} - -function ReadableValue({ value }: { value: unknown }) { - const { rows, complex } = flattenReadable(value); - return ( - <> - {rows.length > 0 && } - {complex.length > 0 &&
{prettyJson(complex.length === 1 ? complex[0] : complex)}
} - - ); -} - -function StatGrid({ rows }: { rows: Array<[string, unknown]> }) { - const visible = rows.filter(([, value]) => value !== null && value !== undefined && value !== ""); - if (!visible.length) return

Числовые характеристики не указаны.

; - return ( -
- {visible.map(([label, value]) => ( -
{label}
{formatValue(value)}
- ))} -
- ); -} - -function CompatibilityDetails({ item, catalog, onSelect }: { item: CatalogItem; catalog: Catalog; onSelect: (id: string) => void }) { - const groups = [ - ...(item.attachmentSlots || []).map((slot) => ({ - title: `Обвесы · ${slot.name || slot.slotName || "Слот"}`, - ids: slot.compatibleItemIds || [], - })), - ...(item.magazineSlots || []).map((slot) => ({ - title: slot.name || slot.slotName || "Совместимые магазины", - ids: slot.compatibleItemIds || [], - })), - ...(item.attachableTo || []).map((slot) => ({ - title: `Устанавливается · ${slot.slotName || slot.name || "Слот"}`, - ids: slot.weaponIds || [], - })), - ...(item.compatibleWeaponIds?.length ? [{ title: "Совместимое оружие", ids: item.compatibleWeaponIds }] : []), - ].map((group) => ({ ...group, ids: [...new Set(group.ids)].filter((id) => catalog.items[id]) })) - .filter((group) => group.ids.length); - - if (!groups.length) return null; - return ( -
-

Совместимость

-
- {groups.map((group) => ( -
-

{group.title}

- -
- ))} -
-
- ); -} - -function ItemLinkList({ ids, catalog, onSelect }: { ids: string[]; catalog: Catalog; onSelect: (id: string) => void }) { - return ( -
- {ids.map((id) => ( - - ))} -
- ); -} - -function Sprite({ item, compact = false, eager = false }: { item: CatalogItem; compact?: boolean; eager?: boolean }) { - if (!item.image) return ; - return ( - - {/* Pixel-art sprites are already generated at their final catalog size. */} - - - ); -} - -function AdminEquipment() { - return ; -} - -function ToolPlaceholder() { - const { slug } = useParams(); - const tool = modules.find((item) => item.slug === slug); - if (!tool) return ; - return ( -
-

Макет раздела

-

{tool.title}

-

{tool.text}

-
Этот инструмент ещё не собран.
- ← Вернуться на главную -
- ); -} - -function NotFound() { - return

Такого раздела пока нет

Вернуться на главную
; -} - -function readAdminDraft(): AdminDraft { - if (typeof window === "undefined") return {}; - try { - const value: unknown = JSON.parse(window.localStorage.getItem(ADMIN_DRAFT_KEY) || "{}"); - if (!isMap(value)) return {}; - const draft: AdminDraft = {}; - for (const [id, entry] of Object.entries(value)) { - if (!isMap(entry)) continue; - draft[id] = { - ...(typeof entry.category === "string" ? { category: entry.category } : {}), - ...(typeof entry.hidden === "boolean" ? { hidden: entry.hidden } : {}), - }; - } - return draft; - } catch { - return {}; - } -} - -function makeAdminDocument(items: AdminDraft): AdminApiDocument { - return { schemaVersion: 1, items }; -} - -function normalizeApiDocument(value: unknown): AdminDraft { - if (!isMap(value)) return {}; - const items = value.schemaVersion === 1 && isMap(value.items) ? value.items : value; - const draft: AdminDraft = {}; - for (const [id, entry] of Object.entries(items)) { - if (!isMap(entry)) continue; - const normalized: AdminOverride = { - ...(typeof entry.category === "string" && CATEGORY_ORDER.includes(entry.category as typeof CATEGORY_ORDER[number]) - ? { category: entry.category } - : {}), - ...(typeof entry.hidden === "boolean" ? { hidden: entry.hidden } : {}), - }; - if (normalized.category || normalized.hidden !== undefined) draft[id] = normalized; - } - return draft; -} - -function readApiError(value: unknown, fallback: string) { - return isMap(value) && typeof value.error === "string" ? value.error : fallback; -} - -function normalize(value: string) { - return value.trim().toLocaleLowerCase("ru").replace(/ё/g, "е"); -} - -function capitalizeName(value: string) { - for (let index = 0; index < value.length; index += 1) { - const character = value[index]; - if (/\d/u.test(character)) return value; - if (/\p{L}/u.test(character)) return value.slice(0, index) + character.toLocaleUpperCase("ru") + value.slice(index + 1); - } - return value; -} - -function categoryIndex(category?: string) { - const index = CATEGORY_ORDER.indexOf(category as typeof CATEGORY_ORDER[number]); - return index === -1 ? CATEGORY_ORDER.length : index; -} - -function itemCompatibleWith(item: CatalogItem, weaponId: string) { - if (item.compatibleWeaponIds?.includes(weaponId)) return true; - return (item.attachableTo || []).some((slot) => (slot.weaponIds || []).includes(weaponId)); -} - -function isMap(value: unknown): value is JsonMap { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function formatNumber(value: unknown) { - return typeof value === "number" ? new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(value) : String(value ?? ""); -} - -function formatValue(value: unknown): string { - if (typeof value === "number") return formatNumber(value); - if (typeof value === "boolean") return value ? "Да" : "Нет"; - if (Array.isArray(value)) return value.map((entry) => formatValue(entry)).join(", "); - if (typeof value === "string") { - const translated = value.split(",").map((entry) => enumLabel(entry.trim())).join(", "); - return translated || "—"; - } - return String(value ?? "—"); -} - -function descriptionText(value: unknown): string { - if (typeof value === "string") return value.trim() || "Нет описания"; - if (isMap(value)) { - const text = Object.values(value).find((entry) => typeof entry === "string" && entry.trim()); - return typeof text === "string" ? text.trim() : "Нет описания"; - } - return "Нет описания"; -} - -function signedNumber(value: unknown, suffix = "") { - if (typeof value !== "number") return null; - const sign = value > 0 ? "+" : value < 0 ? "−" : ""; - return `${sign}${formatNumber(Math.abs(value))}${suffix}`; -} - -function signedPercent(value: unknown) { - return typeof value === "number" ? signedNumber(Math.round(value * 100), "%") : null; -} - -function attachmentCondition(value: unknown) { - if (!isMap(value)) return "Всегда"; - return [ - value.activeOnly === true ? "включён" : null, - value.inactiveOnly === true ? "выключен" : null, - value.wieldedOnly === true ? "в упоре" : null, - value.unwieldedOnly === true ? "с рук" : null, - ].filter(Boolean).join(", ") || "Всегда"; -} - -function attachmentModifierRows(modifiers: JsonMap): Array<[string, unknown]> { - const rows: Array<[string, unknown]> = []; - const append = (label: string, value: unknown, condition: string) => { - if (value !== null && value !== undefined && value !== "") { - rows.push([condition === "Всегда" ? label : `${label} · ${condition}`, value]); - } - }; - for (const raw of Object.values(modifiers)) { - if (!isMap(raw)) continue; - const lists = [raw.modifiers, raw.fireModeMods].filter(Array.isArray) as unknown[][]; - if (!lists.length) lists.push([raw]); - for (const list of lists) { - for (const entry of list.filter(isMap)) { - const condition = attachmentCondition(entry.conditions); - append("Точность", signedPercent(entry.accuracyAddMult), condition); - append("Урон", signedPercent(entry.damageAddMult), condition); - append("Падение урона", signedPercent(entry.damageFalloffAddMult), condition); - append("Разброс", signedNumber(entry.scatterFlat), condition); - append("Разброс очереди", signedNumber(entry.burstScatterAddMult), condition); - append("Отдача", signedNumber(entry.recoilFlat), condition); - append("Задержка выстрела", signedNumber(entry.fireDelayFlat, " с"), condition); - append("Патронов в очереди", signedNumber(entry.shotsPerBurstFlat), condition); - append("Скорость ходьбы", signedPercent(entry.walk), condition); - append("Скорость бега", signedPercent(entry.sprint), condition); - append("Время вскидывания", signedNumber(entry.delay, " с"), condition); - append("Размер оружия", signedNumber(entry.size), condition); - if (isMap(entry.bonusDamage)) { - const damage = isMap(entry.bonusDamage.types) ? entry.bonusDamage.types : entry.bonusDamage; - append("Урон в ближнем бою", formatDamage(damage), condition); - } - if (entry.extraFireModes != null) append("Добавляет режим", formatValue(entry.extraFireModes), condition); - } - } - } - return rows; -} - -function ammoProviderRows(value: unknown): Array<[string, unknown]> { - const provider = isMap(value) ? value : {}; - return [ - ["Вместимость", provider.capacity != null ? `${formatNumber(provider.capacity)} шт.` : null], - ["Боеприпас", provider.proto ? readableId(String(provider.proto)) : null], - ["Расход за выстрел", provider.fireCost], - ]; -} - -function flattenReadable(value: unknown, path: string[] = []): { rows: Array<[string, unknown]>; complex: unknown[] } { - const rows: Array<[string, unknown]> = []; - const complex: unknown[] = []; - if (!isMap(value)) { - if (Array.isArray(value) && value.every((entry) => ["string", "number", "boolean"].includes(typeof entry))) { - rows.push([path.map(statLabel).join(" · ") || "Значение", value]); - } else if (["string", "number", "boolean"].includes(typeof value)) { - rows.push([path.map(statLabel).join(" · ") || "Значение", value]); - } else if (value != null) complex.push(value); - return { rows, complex }; - } - for (const [key, child] of Object.entries(value)) { - const nextPath = [...path, key]; - if ((key === "damage" || key === "bonusDamage") && isMap(child)) { - const damage = isMap(child.types) ? child.types : child; - rows.push([nextPath.map(statLabel).join(" · "), formatDamage(damage)]); - continue; - } - if (["string", "number", "boolean"].includes(typeof child)) { - rows.push([nextPath.map(statLabel).join(" · "), formatFieldValue(key, child)]); - continue; - } - if (Array.isArray(child) && child.every((entry) => ["string", "number", "boolean"].includes(typeof entry))) { - rows.push([nextPath.map(statLabel).join(" · "), child]); - continue; - } - if (isMap(child)) { - const nested = flattenReadable(child, nextPath); - rows.push(...nested.rows); - complex.push(...nested.complex); - continue; - } - if (child != null) complex.push({ [key]: child }); - } - return { rows, complex }; -} - -function formatFieldValue(key: string, value: unknown) { - if (typeof value !== "number") return value; - if (/delay|cooldown|doAfter/i.test(key)) return `${formatNumber(value)} с`; - if (/multiplier|modifier/i.test(key)) return `×${formatNumber(value)}`; - if (/walk|sprint/i.test(key)) return asPercent(value); - if (/angle|rotation/i.test(key)) return `${formatNumber(value)}°`; - if (/range|radius/i.test(key)) return `${formatNumber(value)} тайл.`; - return value; -} - -function statLabel(value: string) { - const labels: Record = { - wielded: "В упоре", unwielded: "С рук", wieldedOnly: "Только в упоре", unwieldedOnly: "Только с рук", - activeOnly: "Только во включённом состоянии", inactiveOnly: "Только в выключенном состоянии", - accuracy: "Точность", wieldedMultiplier: "В упоре", unwieldedMultiplier: "С рук", - recoilFlat: "Отдача", scatterFlat: "Разброс", accuracyAddMult: "Точность", damageAddMult: "Урон", - fireDelay: "Задержка между выстрелами", maxScatterModifier: "Максимальный разброс", - shotsToMaxScatter: "Выстрелов до максимального разброса", unwieldedScatterMultiplier: "Разброс с рук", - useBurstScatterMult: "Учитывать разброс очереди", falloffMultiplier: "Множитель падения урона", - attacksPerSecond: "Атак в секунду", angle: "Угол атаки", damage: "Урон", bonusDamage: "Дополнительный урон", - baseDelay: "Базовая задержка", preventFiring: "Нельзя стрелять во время вскидывания", - base: "Базовый", light: "Лёгкая броня", medium: "Средняя броня", heavy: "Тяжёлая броня", - skills: "Навыки", weaponGroup: "Группа оружия", conditions: "Условия", modifiers: "Модификаторы", - capacity: "Ёмкость", proto: "Боеприпас", cycleable: "Перезаряжается вручную", mayTransfer: "Можно извлечь патроны", - maxRange: "Дальность", maxDuration: "Длительность", maxIntensity: "Интенсивность", - FullAuto: "Автоматический", SemiAuto: "Одиночный", Burst: "Очередь", - }; - return labels[value] || readableId(value); -} - -function enumLabel(value: string) { - return fireModeLabel(value) !== value ? fireModeLabel(value) : ({ - light: "Лёгкий", medium: "Средний", heavy: "Тяжёлый", Handgun: "Пистолет", Rifle: "Винтовка", - Small: "Маленький", Normal: "Обычный", Large: "Большой", Huge: "Огромный", - } as Record)[value] || slotLabel(value); -} - -function fireModeLabel(value: string) { - return ({ SemiAuto: "Одиночный", Burst: "Очередь", FullAuto: "Автоматический" } as Record)[value] || value; -} - -function slotLabel(value: string) { - const labels: Record = { - Back: "Спина", back: "Спина", suitStorage: "Броня", suitstorage: "Броня", - outerClothing: "Верхняя одежда", head: "Голова", eyes: "Глаза", ears: "Уши", mask: "Маска", - belt: "Пояс", pocket: "Карман", gloves: "Перчатки", neck: "Шея", shoes: "Обувь", jumpsuit: "Униформа", - }; - return labels[value] || value; -} - -function itemSizeLabel(value: unknown) { - if (value == null) return null; - return ({ Tiny: "Крошечный", Small: "Маленький", Normal: "Обычный", Large: "Большой", Huge: "Огромный", Ginormous: "Гигантский" } as Record)[String(value)] || String(value); -} - -function solutionLabel(value: string) { - return ({ pen: "Инъектор", drink: "Раствор", pack: "Пакет", food: "Содержимое", tank: "Резервуар" } as Record)[value] || readableId(value); -} - -function readableId(value: string) { - const known: Record = { - CMBicaridine: "Бикаридин", CMKelotane: "Келотан", CMTricordrazine: "Трикордразин", CMDexalin: "Дексалин", - CMDylovene: "Диловен", CMInaprovaline: "Инапровалин", CMEpinephrine: "Эпинефрин", Blood: "Кровь", - Fiber: "Волокно", RMCSkillFirearms: "Огнестрельное оружие", RMCSkillEngineer: "Инженерия", - RMCSkillSmartGun: "Умное оружие", RMCSkillPolice: "Военная полиция", - }; - if (known[value]) return known[value]; - return value.replace(/^RMC|^CM/, "").replace(/([a-zа-я])([A-ZА-Я])/g, "$1 $2").replace(/_/g, " "); -} - -function asMultiplier(value: unknown) { - return typeof value === "number" ? `×${formatNumber(value)}` : null; -} - -function asPercent(value: unknown) { - return typeof value === "number" ? `${formatNumber(value * 100)}%` : null; -} - -function formatMovementPenalty(movement: JsonMap) { - const walk = typeof movement.walkModifier === "number" ? movement.walkModifier : null; - const sprint = typeof movement.sprintModifier === "number" ? movement.sprintModifier : null; - if (walk == null && sprint == null) return null; - const penalty = (value: number) => `${Math.round((1 - value) * 100)}%`; - if (walk != null && sprint != null && walk === sprint) return `−${penalty(walk)}`; - return [ - walk != null ? `ходьба −${penalty(walk)}` : null, - sprint != null ? `бег −${penalty(sprint)}` : null, - ].filter(Boolean).join(" · "); -} - -function itemSizeGenitive(value: string) { - return ({ - tiny: "крошечного", - small: "маленького", - normal: "обычного", - large: "большого", - huge: "огромного", - ginormous: "гигантского", - } as Record)[value.toLocaleLowerCase()] || itemSizeLabel(value) || value; -} - -function pluralize(value: number, one: string, few: string, many: string) { - const lastTwo = value % 100; - const last = value % 10; - if (lastTwo >= 11 && lastTwo <= 14) return many; - if (last === 1) return one; - if (last >= 2 && last <= 4) return few; - return many; -} - -function formatDamage(value: unknown) { - if (!isMap(value)) return "Урон не указан"; - const entries = Object.entries(value).filter(([, amount]) => typeof amount === "number" && amount !== 0); - return entries.length ? entries.map(([type, amount]) => `${damageLabel(type)} ${formatNumber(amount)}`).join(" · ") : "Урон не указан"; -} - -function damageLabel(value: string) { - const labels: Record = { Blunt: "Дробящий", Slash: "Режущий", Piercing: "Колющий", Heat: "Термический", Caustic: "Кислотный" }; - return labels[value] || value; -} - -function armorLabel(value: string) { - const labels: Record = { - xenoArmor: "Ксено-урон", - frontalArmor: "Спереди", - sideArmor: "Сбоку", - melee: "Ближний бой", - bullet: "Пули", - laser: "Лазеры", - bio: "Биозащита", - explosionArmor: "Взрывы", - acid: "Кислота", - }; - return labels[value] || value; -} - -function componentLabel(value: string) { - const labels: Record = { - AttachableWeaponRangedMods: "Стрельба", - AttachableSpeedMods: "Скорость движения", - AttachableWieldDelayMods: "Время вскидывания", - AttachableMeleeMods: "Ближний бой", - }; - return labels[value] || value; -} - -function prettyJson(value: unknown) { - return JSON.stringify(value, null, 2) - .replaceAll('"conditions"', "условия") - .replaceAll('"modifiers"', "модификаторы") - .replaceAll('"', ""); -} - export default App; diff --git a/src/adminApi.ts b/src/adminApi.ts new file mode 100644 index 0000000..5c12db9 --- /dev/null +++ b/src/adminApi.ts @@ -0,0 +1,47 @@ +import { ADMIN_DRAFT_KEY, CATEGORY_ORDER } from "./constants"; +import { isMap } from "./format"; +import type { AdminApiDocument, AdminDraft, AdminOverride } from "./types"; + +export function readAdminDraft(): AdminDraft { + if (typeof window === "undefined") return {}; + try { + const value: unknown = JSON.parse(window.localStorage.getItem(ADMIN_DRAFT_KEY) || "{}"); + if (!isMap(value)) return {}; + const draft: AdminDraft = {}; + for (const [id, entry] of Object.entries(value)) { + if (!isMap(entry)) continue; + draft[id] = { + ...(typeof entry.category === "string" ? { category: entry.category } : {}), + ...(typeof entry.hidden === "boolean" ? { hidden: entry.hidden } : {}), + }; + } + return draft; + } catch { + return {}; + } +} + +export function makeAdminDocument(items: AdminDraft): AdminApiDocument { + return { schemaVersion: 1, items }; +} + +export function normalizeApiDocument(value: unknown): AdminDraft { + if (!isMap(value)) return {}; + const items = value.schemaVersion === 1 && isMap(value.items) ? value.items : value; + const draft: AdminDraft = {}; + for (const [id, entry] of Object.entries(items)) { + if (!isMap(entry)) continue; + const normalized: AdminOverride = { + ...(typeof entry.category === "string" && CATEGORY_ORDER.includes(entry.category as typeof CATEGORY_ORDER[number]) + ? { category: entry.category } + : {}), + ...(typeof entry.hidden === "boolean" ? { hidden: entry.hidden } : {}), + }; + if (normalized.category || normalized.hidden !== undefined) draft[id] = normalized; + } + return draft; +} + +export function readApiError(value: unknown, fallback: string) { + return isMap(value) && typeof value.error === "string" ? value.error : fallback; +} diff --git a/src/components/CompatibilityDetails.tsx b/src/components/CompatibilityDetails.tsx new file mode 100644 index 0000000..c418462 --- /dev/null +++ b/src/components/CompatibilityDetails.tsx @@ -0,0 +1,36 @@ +import type { Catalog, CatalogItem } from "../types"; +import { ItemLinkList } from "./ItemLinkList"; + +export function CompatibilityDetails({ item, catalog, onSelect }: { item: CatalogItem; catalog: Catalog; onSelect: (id: string) => void }) { + const groups = [ + ...(item.attachmentSlots || []).map((slot) => ({ + title: `Обвесы · ${slot.name || slot.slotName || "Слот"}`, + ids: slot.compatibleItemIds || [], + })), + ...(item.magazineSlots || []).map((slot) => ({ + title: slot.name || slot.slotName || "Совместимые магазины", + ids: slot.compatibleItemIds || [], + })), + ...(item.attachableTo || []).map((slot) => ({ + title: `Устанавливается · ${slot.slotName || slot.name || "Слот"}`, + ids: slot.weaponIds || [], + })), + ...(item.compatibleWeaponIds?.length ? [{ title: "Совместимое оружие", ids: item.compatibleWeaponIds }] : []), + ].map((group) => ({ ...group, ids: [...new Set(group.ids)].filter((id) => catalog.items[id]) })) + .filter((group) => group.ids.length); + + if (!groups.length) return null; + return ( +
+

Совместимость

+
+ {groups.map((group) => ( +
+

{group.title}

+ +
+ ))} +
+
+ ); +} diff --git a/src/components/Equipment.tsx b/src/components/Equipment.tsx new file mode 100644 index 0000000..8b3a562 --- /dev/null +++ b/src/components/Equipment.tsx @@ -0,0 +1,600 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { makeAdminDocument, normalizeApiDocument, readAdminDraft, readApiError } from "../adminApi"; +import { ADMIN_API_URL, ADMIN_DRAFT_KEY, CATEGORY_ORDER } from "../constants"; +import { capitalizeName, categoryIndex, descriptionText, isMap, itemCompatibleWith, normalize } from "../format"; +import type { AdminDraft, AdminOverride, AdminSyncState, CatalogItem } from "../types"; +import { useCatalog } from "../useCatalog"; +import { ItemDrawer } from "./ItemDrawer"; +import { Sprite } from "./Sprite"; + +export function Equipment({ adminMode = false }: { adminMode?: boolean }) { + const { catalog, loadState } = useCatalog(); + const [searchParams, setSearchParams] = useSearchParams(); + const [filtersOpen, setFiltersOpen] = useState(false); + const [draft, setDraft] = useState(() => readAdminDraft()); + const [selectedAdminIds, setSelectedAdminIds] = useState>(() => new Set()); + const [bulkCategory, setBulkCategory] = useState(CATEGORY_ORDER[0]); + const [adminPassword, setAdminPassword] = useState(""); + const [remoteSha, setRemoteSha] = useState(null); + const [adminSyncState, setAdminSyncState] = useState(adminMode ? "loading" : "ready"); + const [adminSyncMessage, setAdminSyncMessage] = useState(adminMode ? "Загружаю сохранённые изменения…" : ""); + const [showBackToTop, setShowBackToTop] = useState(false); + const resultsTop = useRef(null); + + const query = searchParams.get("q") || ""; + const selectedCategories = searchParams.getAll("category"); + const selectedSlots = searchParams.getAll("slot"); + const compatibleWeapon = searchParams.get("weapon") || ""; + const sort = searchParams.get("sort") || "name"; + const showHidden = searchParams.get("hidden") === "1"; + const selectedId = searchParams.get("item"); + const changes = Object.entries(draft).filter(([, value]) => value.category || value.hidden !== undefined); + + useEffect(() => { + if (!adminMode) return; + const controller = new AbortController(); + const localDraft = readAdminDraft(); + const hasLocalDraft = Object.keys(localDraft).length > 0; + + fetch(ADMIN_API_URL, { signal: controller.signal, cache: "no-store" }) + .then(async (response) => { + const result: unknown = await response.json(); + if (!response.ok || !isMap(result) || result.ok !== true) { + throw new Error(readApiError(result, `HTTP ${response.status}`)); + } + const remoteDraft = normalizeApiDocument(result.overrides); + setRemoteSha(typeof result.sha === "string" ? result.sha : null); + if (hasLocalDraft) { + setDraft(localDraft); + setAdminSyncMessage("Восстановлен локальный черновик. Сохранённая версия с GitHub загружена для проверки конфликта."); + } else { + setDraft(remoteDraft); + setAdminSyncMessage(result.exists === true ? "Сохранённые изменения загружены из GitHub." : "Файл изменений ещё не создан. Первое сохранение создаст его."); + } + setAdminSyncState("ready"); + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return; + setAdminSyncState("error"); + setAdminSyncMessage(error instanceof Error ? `Не удалось загрузить изменения: ${error.message}` : "Не удалось загрузить изменения."); + }); + + return () => controller.abort(); + }, [adminMode]); + + useEffect(() => { + if (!adminMode || typeof window === "undefined") return; + window.localStorage.setItem(ADMIN_DRAFT_KEY, JSON.stringify(draft)); + }, [adminMode, draft]); + + useEffect(() => { + const onScroll = () => setShowBackToTop(window.scrollY > 620); + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + function changeOverride(id: string, patch: AdminOverride) { + setDraft((current) => ({ ...current, [id]: { ...current[id], ...patch } })); + } + + function resetOverride(id: string) { + setDraft((current) => { + const next = { ...current }; + delete next[id]; + return next; + }); + } + + function toggleAdminSelection(id: string) { + setSelectedAdminIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function applyBulkOverride(patch: AdminOverride) { + if (!selectedAdminIds.size) return; + const affectedCount = selectedAdminIds.size; + setDraft((current) => { + const next = { ...current }; + for (const id of selectedAdminIds) next[id] = { ...next[id], ...patch }; + return next; + }); + setSelectedAdminIds(new Set()); + setAdminSyncState("ready"); + setAdminSyncMessage(`Действие добавлено в черновик для ${affectedCount} предметов. Можно выбрать следующую группу.`); + } + + function resetSelectedOverrides() { + if (!selectedAdminIds.size) return; + const affectedCount = selectedAdminIds.size; + setDraft((current) => { + const next = { ...current }; + for (const id of selectedAdminIds) delete next[id]; + return next; + }); + setSelectedAdminIds(new Set()); + setAdminSyncState("ready"); + setAdminSyncMessage(`Изменения сброшены для ${affectedCount} предметов. Выбор очищен.`); + } + + function exportOverrides() { + const items = Object.fromEntries(changes); + const blob = new Blob( + [`${JSON.stringify({ schemaVersion: 1, items }, null, 2)}\n`], + { type: "application/json;charset=utf-8" }, + ); + const href = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = href; + link.download = "equipment-overrides.json"; + link.click(); + URL.revokeObjectURL(href); + } + + async function saveOverrides() { + if (!adminPassword) { + setAdminSyncState("error"); + setAdminSyncMessage("Введите пароль администратора."); + return; + } + + setAdminSyncState("saving"); + setAdminSyncMessage("Сохраняю изменения в GitHub…"); + + try { + const response = await fetch(ADMIN_API_URL, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-Admin-Password": adminPassword, + }, + body: JSON.stringify({ + sha: remoteSha, + overrides: makeAdminDocument(Object.fromEntries(changes)), + }), + }); + const result: unknown = await response.json(); + + if (!response.ok || !isMap(result) || result.ok !== true) { + if (response.status === 401) throw new Error("Неверный пароль администратора."); + if (response.status === 409 || (isMap(result) && result.code === "SHA_CONFLICT")) { + throw new Error("Файл уже изменился после загрузки страницы. Обновите страницу, проверьте изменения и повторите сохранение."); + } + throw new Error(readApiError(result, `HTTP ${response.status}`)); + } + + setRemoteSha(typeof result.sha === "string" ? result.sha : null); + window.localStorage.removeItem(ADMIN_DRAFT_KEY); + setAdminSyncState("saved"); + setAdminSyncMessage(result.created === true ? "Файл изменений создан в GitHub." : "Изменения сохранены в GitHub."); + } catch (error: unknown) { + setAdminSyncState("error"); + setAdminSyncMessage(error instanceof Error ? error.message : "Не удалось сохранить изменения."); + } + } + + const publicIds = useMemo(() => catalog?.publicCatalog.itemIds || [], [catalog]); + + const categories = useMemo(() => { + if (!catalog) return []; + const fromCatalog = Object.keys(catalog.publicCatalog.categories || {}); + return CATEGORY_ORDER.filter((name) => fromCatalog.includes(name)); + }, [catalog]); + + const attachmentSlots = useMemo(() => { + if (!catalog) return []; + return [...new Set(publicIds.flatMap((id) => + (catalog.items[id]?.attachableTo || []).map((slot) => slot.slotName || slot.name || ""), + ).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ru")); + }, [catalog, publicIds]); + + const compatibleWeapons = useMemo(() => { + if (!catalog) return []; + const ids = new Set(); + for (const id of publicIds) { + const item = catalog.items[id]; + for (const weaponId of item?.compatibleWeaponIds || []) ids.add(weaponId); + for (const slot of item?.attachableTo || []) { + for (const weaponId of slot.weaponIds || []) ids.add(weaponId); + } + } + return [...ids] + .filter((id) => catalog.items[id]) + .sort((a, b) => catalog.items[a].name.localeCompare(catalog.items[b].name, "ru")); + }, [catalog, publicIds]); + + function patchParams(update: (next: URLSearchParams) => void) { + const next = new URLSearchParams(searchParams); + update(next); + setSearchParams(next, { replace: true }); + } + + function setParam(name: string, value: string | null) { + patchParams((next) => { + if (value) next.set(name, value); + else next.delete(name); + if (name !== "item") next.delete("item"); + }); + } + + function toggleParam(name: string, value: string) { + patchParams((next) => { + const values = new Set(next.getAll(name)); + if (values.has(value)) values.delete(value); + else values.add(value); + next.delete(name); + for (const entry of values) next.append(name, entry); + next.delete("item"); + }); + } + + function openCategory(category: string) { + patchParams((next) => { + next.delete("category"); + next.append("category", category); + next.delete("item"); + }); + setFiltersOpen(false); + window.requestAnimationFrame(() => resultsTop.current?.scrollIntoView({ behavior: "smooth", block: "start" })); + } + + function clearFilters() { + setSearchParams(sort === "name" ? {} : { sort }, { replace: true }); + } + + function effectiveCategory(id: string, item: CatalogItem) { + return adminMode && draft[id]?.category ? draft[id].category : item.category || "Другое"; + } + + const matchesItem = (id: string, ignoredFacet?: "category" | "slot") => { + const item = catalog?.items[id]; + if (!item) return false; + const normalized = normalize(query); + const matchesQuery = !normalized || [item.name, descriptionText(item.description), id] + .filter(Boolean) + .some((value) => normalize(String(value)).includes(normalized)); + const slots = new Set((item.attachableTo || []).map((slot) => slot.slotName || slot.name || "")); + const matchesCategory = ignoredFacet === "category" || !selectedCategories.length + || selectedCategories.includes(effectiveCategory(id, item)); + const matchesSlot = ignoredFacet === "slot" || !selectedSlots.length + || selectedSlots.some((slot) => slots.has(slot)); + const matchesWeapon = !compatibleWeapon || itemCompatibleWith(item, compatibleWeapon); + const override = draft[id]; + const matchesVisibility = !adminMode || showHidden || override?.hidden !== true; + return matchesQuery && matchesCategory && matchesSlot && matchesWeapon && matchesVisibility; + }; + + const visibleIds = catalog + ? publicIds.filter((id) => matchesItem(id)).sort((a, b) => { + const first = catalog.items[a]; + const second = catalog.items[b]; + if (sort === "category") { + const order = categoryIndex(effectiveCategory(a, first)) - categoryIndex(effectiveCategory(b, second)); + if (order) return order; + } + return first.name.localeCompare(second.name, "ru") || a.localeCompare(b); + }) + : []; + + const selectedVisibleCount = visibleIds.filter((id) => selectedAdminIds.has(id)).length; + const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length; + + function toggleAllVisible() { + setSelectedAdminIds((current) => { + const next = new Set(current); + if (allVisibleSelected) { + for (const id of visibleIds) next.delete(id); + } else { + for (const id of visibleIds) next.add(id); + } + return next; + }); + } + + const categoryCounts = (() => { + const counts = new Map(); + for (const id of publicIds) { + if (!matchesItem(id, "category")) continue; + const item = catalog?.items[id]; + const category = item ? effectiveCategory(id, item) : null; + if (category) counts.set(category, (counts.get(category) || 0) + 1); + } + return counts; + })(); + + const slotCounts = (() => { + const counts = new Map(); + for (const id of publicIds) { + if (!matchesItem(id, "slot")) continue; + for (const slot of catalog?.items[id]?.attachableTo || []) { + const name = slot.slotName || slot.name; + if (name) counts.set(name, (counts.get(name) || 0) + 1); + } + } + return counts; + })(); + + const activeFilters = [ + ...selectedCategories.map((value) => ({ key: `category:${value}`, label: value, clear: () => toggleParam("category", value) })), + ...selectedSlots.map((value) => ({ key: `slot:${value}`, label: `Обвес: ${value}`, clear: () => toggleParam("slot", value) })), + ...(compatibleWeapon && catalog?.items[compatibleWeapon] + ? [{ key: "weapon", label: `Для: ${catalog.items[compatibleWeapon].name}`, clear: () => setParam("weapon", null) }] + : []), + ...(adminMode && showHidden + ? [{ key: "hidden", label: "Показаны скрытые", clear: () => setParam("hidden", null) }] + : []), + ]; + + const selectedItem = selectedId && catalog ? catalog.items[selectedId] : null; + + useEffect(() => { + if (!filtersOpen && !selectedId) return; + const previousOverflow = document.body.style.overflow; + if (filtersOpen) document.body.style.overflow = "hidden"; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if (filtersOpen) setFiltersOpen(false); + else if (selectedId) { + const next = new URLSearchParams(searchParams); + next.delete("item"); + setSearchParams(next, { replace: true }); + } + }; + window.addEventListener("keydown", closeOnEscape); + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", closeOnEscape); + }; + }, [selectedId, filtersOpen, searchParams, setSearchParams]); + + return ( +
+
+
+

{adminMode ? "Режим администратора" : "Арсенал морпехов"}

+

{adminMode ? "Управление снаряжением" : "Каталог снаряжения"}

+

{adminMode + ? "Тот же каталог, но с быстрым изменением категории и видимости прямо на карточках." + : "Один поиск, понятные разделы и характеристики без похода в исходный YAML."}

+
+ + {adminMode ? "Выйти из админ-режима" : "Админ-режим"} + +
+ + {adminMode && ( +
+
+ {changes.length} изменений · {selectedAdminIds.size} выбрано +
+ + + + + + + + + +

{adminSyncMessage}

+ Пароль хранится только в памяти этой вкладки. Локальный черновик остаётся страховкой до успешного сохранения. +
+ )} + + + + {loadState === "loading" &&

Загружаю актуальный каталог…

} + {loadState === "error" &&

Не удалось загрузить каталог. Попробуйте обновить страницу чуть позже.

} + + {catalog && ( +
+ {filtersOpen && +
+ +
+ {categories.map((name) => { + const checked = selectedCategories.includes(name); + const count = categoryCounts.get(name) || 0; + return ( +
+ + +
+ ); + })} +
+ + {adminMode && ( + + )} + + {attachmentSlots.length > 0 && ( +
+ Место установки обвеса +
+ {attachmentSlots.map((slot) => ( + + ))} +
+
+ )} + + {compatibleWeapons.length > 0 && ( + + )} + + + + +
+
+
+ + Показано {visibleIds.length} из {publicIds.length} +
+ +
+ + {activeFilters.length > 0 && ( +
+ {activeFilters.map((filter) => ( + + ))} + +
+ )} + +
+ {visibleIds.map((id) => { + const item = catalog.items[id]; + const isSelected = selectedItem?.id === id; + const override = draft[id]; + const adminSelected = selectedAdminIds.has(id); + const hidden = override?.hidden === true; + return ( +
+ {adminMode && ( + + )} + + {adminMode && ( +
+ + + +
+ )} +
+ ); + })} +
+ {!visibleIds.length &&

Ничего не найдено. Даже подозрительного ящика.

} +
+ + )} + + {selectedItem && catalog && ( + setParam("item", null)} + onSelect={(id) => setParam("item", id)} + /> + )} + {showBackToTop && ( + + )} +
+ ); +} + +export function AdminEquipment() { + return ; +} diff --git a/src/components/Home.tsx b/src/components/Home.tsx new file mode 100644 index 0000000..7d2e5e2 --- /dev/null +++ b/src/components/Home.tsx @@ -0,0 +1,33 @@ +import { Link } from "react-router-dom"; +import { modules } from "../constants"; + +export function Home() { + return ( +
+ +
+ ); +} diff --git a/src/components/ItemDrawer.tsx b/src/components/ItemDrawer.tsx new file mode 100644 index 0000000..fc4b07c --- /dev/null +++ b/src/components/ItemDrawer.tsx @@ -0,0 +1,55 @@ +import { capitalizeName, descriptionText } from "../format"; +import type { Catalog, CatalogItem } from "../types"; +import { CompatibilityDetails } from "./CompatibilityDetails"; +import { ItemLinkList } from "./ItemLinkList"; +import { Sprite } from "./Sprite"; +import { StatsDetails } from "./Stats"; + +export function ItemDrawer({ + item, + catalog, + onClose, + onSelect, +}: { + item: CatalogItem; + catalog: Catalog; + onClose: () => void; + onSelect: (id: string) => void; +}) { + const contained = ["Броня", "Экипировка"].includes(item.category || "") + ? [] + : (item.containsItemIds || []).filter((id) => catalog.items[id]); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }}> + +
+ ); +} diff --git a/src/components/ItemLinkList.tsx b/src/components/ItemLinkList.tsx new file mode 100644 index 0000000..84a43aa --- /dev/null +++ b/src/components/ItemLinkList.tsx @@ -0,0 +1,16 @@ +import { capitalizeName } from "../format"; +import type { Catalog } from "../types"; +import { Sprite } from "./Sprite"; + +export function ItemLinkList({ ids, catalog, onSelect }: { ids: string[]; catalog: Catalog; onSelect: (id: string) => void }) { + return ( +
+ {ids.map((id) => ( + + ))} +
+ ); +} diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx new file mode 100644 index 0000000..6d80e61 --- /dev/null +++ b/src/components/NotFound.tsx @@ -0,0 +1,5 @@ +import { Link } from "react-router-dom"; + +export function NotFound() { + return

Такого раздела пока нет

Вернуться на главную
; +} diff --git a/src/components/Sprite.tsx b/src/components/Sprite.tsx new file mode 100644 index 0000000..2e32863 --- /dev/null +++ b/src/components/Sprite.tsx @@ -0,0 +1,12 @@ +import { DATA_ROOT } from "../constants"; +import type { CatalogItem } from "../types"; + +export function Sprite({ item, compact = false, eager = false }: { item: CatalogItem; compact?: boolean; eager?: boolean }) { + if (!item.image) return ; + return ( + + {/* Pixel-art sprites are already generated at their final catalog size. */} + + + ); +} diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx new file mode 100644 index 0000000..9bc5619 --- /dev/null +++ b/src/components/Stats.tsx @@ -0,0 +1,293 @@ +import { + ammoProviderRows, + armorLabel, + asMultiplier, + attachmentModifierRows, + fireModeLabel, + flattenReadable, + formatDamage, + formatMovementPenalty, + formatNumber, + formatValue, + isMap, + itemSizeGenitive, + itemSizeLabel, + pluralize, + prettyJson, + readableId, + solutionLabel, +} from "../format"; +import type { CatalogItem, JsonMap } from "../types"; + +export function StatsDetails({ item }: { item: CatalogItem }) { + return ( + <> + {item.weaponStats && } + {item.armorStats && } + {item.attachmentStats && } + + + ); +} + +function WeaponStats({ stats }: { stats: JsonMap }) { + const recoil = isMap(stats.recoil) ? stats.recoil : {}; + const scatter = isMap(stats.scatter) ? stats.scatter : {}; + const accuracy = isMap(stats.accuracy) ? stats.accuracy : {}; + const wieldDelay = isMap(stats.wieldDelay) ? stats.wieldDelay : {}; + const fireModes = Array.isArray(stats.fireModes) ? stats.fireModes.map((mode) => fireModeLabel(String(mode))) : []; + const rows: Array<[string, unknown]> = [ + ["Темп стрельбы", stats.roundsPerMinute != null ? `${formatNumber(stats.roundsPerMinute)} выстр./мин` : null], + ["Режимы огня", fireModes.length ? fireModes.join(", ") : null], + ["Основной режим", fireModes.length > 1 && stats.defaultFireMode ? fireModeLabel(String(stats.defaultFireMode)) : null], + ["Очередь", stats.burstSize != null ? `${formatNumber(stats.burstSize)} выстр.` : null], + ["Бронепробитие", stats.weaponArmorPiercing], + ["Множитель урона", stats.damageMultiplier != null ? `×${formatNumber(stats.damageMultiplier)}` : null], + ["Отдача · в упоре", recoil.wielded], + ["Отдача · с рук", recoil.unwielded], + ["Разброс · в упоре", scatter.wielded], + ["Разброс · с рук", scatter.unwielded], + ["Точность · в упоре", asMultiplier(accuracy.wieldedMultiplier)], + ["Точность · с рук", asMultiplier(accuracy.unwieldedMultiplier)], + ["Время вскидывания", wieldDelay.baseDelay != null ? `${formatNumber(wieldDelay.baseDelay)} с` : null], + ["IFF", stats.iffEnabled], + ]; + const provider = isMap(stats.ammoProvider) ? stats.ammoProvider : null; + if (provider) { + rows.push(["Ёмкость", provider.capacity]); + rows.push(["Штатный боеприпас", provider.startingAmmoId]); + } + const movement = isMap(stats.wieldedMovement) ? stats.wieldedMovement : null; + if (movement) { + for (const [key, label] of [["base", "без брони"], ["light", "лёгкая броня"], ["medium", "средняя броня"], ["heavy", "тяжёлая броня"]] as const) { + const value = movement[key]; + if (typeof value === "number" && value !== 1) rows.push([`Снижение скорости · ${label}`, `−${Math.round((1 - value) * 100)}%`]); + } + } + const ammunition = Array.isArray(stats.ammunition) ? stats.ammunition.filter(isMap) : []; + return ( +
+

Характеристики оружия

+ + {ammunition.length > 0 && ( +
+

Магазины, боеприпасы и урон

+ {ammunition.map((entry, index) => { + const projectiles = Array.isArray(entry.projectiles) ? entry.projectiles.filter(isMap) : []; + return ( +
+
+ {String(entry.magazineName || entry.ammoName || entry.magazineId || entry.ammoId || "Боеприпас")} + {entry.capacity != null && {formatNumber(entry.capacity)} шт.} +
+ {projectiles.map((projectile, projectileIndex) => ( +
+
+ {formatDamage(projectile.effectiveDamage || projectile.damage)} + {projectile.armorPiercing != null && Бронепробитие {formatNumber(projectile.armorPiercing)}} +
+ +
+ ))} +
+ ); + })} +
+ )} + +
+ ); +} + +function ArmorStats({ stats }: { stats: JsonMap }) { + const protection = isMap(stats.protection) ? stats.protection : {}; + const movement = isMap(stats.movement) ? stats.movement : {}; + const protectionRows = Object.entries(protection) + .filter(([, value]) => typeof value === "number" && value !== 0) + .map(([key, value]) => [armorLabel(key), value] as [string, unknown]); + return ( +
+

Защита брони

+ +

Сопротивления

+ +
+ ); +} + +function AttachmentStats({ stats }: { stats: JsonMap }) { + const modifiers = isMap(stats.modifiers) ? stats.modifiers : {}; + const rows = attachmentModifierRows(modifiers); + return ( +
+

Характеристики обвеса

+ +
+ ); +} + +function CommonItemStats({ item }: { item: CatalogItem }) { + const properties = item.properties || {}; + const solutionManager = isMap(properties.SolutionContainerManager) ? properties.SolutionContainerManager : null; + const solutions = solutionManager && isMap(solutionManager.solutions) ? solutionManager.solutions : {}; + const storage = isMap(item.storageStats) ? item.storageStats : null; + const melee = isMap(properties.MeleeWeapon) ? properties.MeleeWeapon : null; + const armor = isMap(properties.CMArmor) ? properties.CMArmor : null; + const armorPiercing = isMap(properties.CMArmorPiercing) ? properties.CMArmorPiercing : null; + const ammoProvider = ["BallisticAmmoProvider", "RevolverAmmoProvider", "CartridgeAmmo", "RMCFlamerTank"] + .map((key) => [key, properties[key]] as [string, unknown]) + .find(([, value]) => isMap(value)); + + const overviewRows: Array<[string, unknown]> = [ + ["Бронепробитие в ближнем бою", armorPiercing?.amount], + ]; + const hasOverview = overviewRows.some(([, value]) => value !== null && value !== undefined && value !== ""); + + return ( + <> + {hasOverview &&

Основные свойства

} + {Object.keys(solutions).length > 0 && ( +
+

Состав и объём

+
+ {Object.entries(solutions).map(([name, raw]) => { + const solution = isMap(raw) ? raw : {}; + const reagents = Array.isArray(solution.reagents) ? solution.reagents.filter(isMap) : []; + return ( +
+
{solutionLabel(name)}{solution.maxVol != null && {formatNumber(solution.maxVol)} ед.}
+ {reagents.length ?
    {reagents.map((reagent, index) =>
  • {readableId(String(reagent.ReagentId || "Реагент"))}{formatNumber(reagent.Quantity)} ед.
  • )}
: Пусто} +
+ ); + })} +
+
+ )} + {storage && ( +
+

Хранилище

+ +
+ )} + {melee && !item.weaponStats && ( +
+

Ближний бой

+ +
+ )} + {armor && !item.armorStats && ( +
+

Дополнительная защита

+ [armorLabel(key), value] as [string, unknown])} /> +
+ )} + {ammoProvider && !item.weaponStats && ( +
+

Боеприпасы

+ +
+ )} + + ); +} + +function StorageSummary({ storage }: { storage: JsonMap }) { + const capacities = Array.isArray(storage.capacities) ? storage.capacities.filter(isMap) : []; + const limits = Array.isArray(storage.limits) ? storage.limits.filter(isMap) : []; + const exactPlaces = typeof storage.exactPlaces === "number" ? storage.exactPlaces : null; + const capacityText = capacities + .map((entry) => `${formatNumber(entry.count)} ${itemSizeGenitive(String(entry.size || "Small"))}`) + .join(" · "); + const limitText = limits + .map((entry) => typeof entry.count === "number" ? `до ${formatNumber(entry.count)} специальных` : null) + .filter(Boolean) + .join(" · "); + return ; +} + +function ProjectileRange({ projectile }: { projectile: JsonMap }) { + const accuracy = isMap(projectile.accuracy) ? projectile.accuracy : {}; + const falloff = isMap(projectile.damageFalloff) ? projectile.damageFalloff : {}; + const thresholds: Array<{ kind: string; range?: unknown; falloff?: unknown }> = [ + ...(Array.isArray(accuracy.thresholds) ? accuracy.thresholds.filter(isMap).map((entry) => ({ kind: "Точность", range: entry.range, falloff: entry.falloff })) : []), + ...(Array.isArray(falloff.thresholds) ? falloff.thresholds.filter(isMap).map((entry) => ({ kind: "Урон", range: entry.range, falloff: entry.falloff })) : []), + ]; + if (accuracy.accuracy == null && !thresholds.length) return null; + return ( +
+ {accuracy.accuracy != null && Точность {formatNumber(accuracy.accuracy)}} + {thresholds.slice(0, 3).map((entry, index) => {entry.kind}: после {formatNumber(entry.range)} тайл. −{formatNumber(entry.falloff)})} +
+ ); +} + +function ProtectionBars({ rows }: { rows: Array<[string, unknown]> }) { + const visible = rows.filter(([, value]) => typeof value === "number" && value !== 0); + if (!visible.length) return

Сопротивления не указаны.

; + return ( +
+ {visible.map(([label, value]) => { + const amount = Number(value); + return
{label}
{formatNumber(amount)}%
; + })} +
+ ); +} + +function ReadableStatGroups({ groups }: { groups: Array<[string, unknown]> }) { + const visible = groups.filter(([, value]) => value !== null && value !== undefined + && (!Array.isArray(value) || value.length > 0) + && (!isMap(value) || Object.keys(value).length > 0)); + if (!visible.length) return null; + return ( +
+ {visible.map(([title, value]) => ( +
+ {title} +
+
+ ))} +
+ ); +} + +function ReadableValue({ value }: { value: unknown }) { + const { rows, complex } = flattenReadable(value); + return ( + <> + {rows.length > 0 && } + {complex.length > 0 &&
{prettyJson(complex.length === 1 ? complex[0] : complex)}
} + + ); +} + +function StatGrid({ rows }: { rows: Array<[string, unknown]> }) { + const visible = rows.filter(([, value]) => value !== null && value !== undefined && value !== ""); + if (!visible.length) return

Числовые характеристики не указаны.

; + return ( +
+ {visible.map(([label, value]) => ( +
{label}
{formatValue(value)}
+ ))} +
+ ); +} diff --git a/src/components/ToolPlaceholder.tsx b/src/components/ToolPlaceholder.tsx new file mode 100644 index 0000000..63cea1e --- /dev/null +++ b/src/components/ToolPlaceholder.tsx @@ -0,0 +1,18 @@ +import { Link, useParams } from "react-router-dom"; +import { modules } from "../constants"; +import { NotFound } from "./NotFound"; + +export function ToolPlaceholder() { + const { slug } = useParams(); + const tool = modules.find((item) => item.slug === slug); + if (!tool) return ; + return ( +
+

Макет раздела

+

{tool.title}

+

{tool.text}

+
Этот инструмент ещё не собран.
+ ← Вернуться на главную +
+ ); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..eeae4d7 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,57 @@ +export const DATA_ROOT = + "https://raw.githubusercontent.com/DeferW/ssmc-wiki-data/main/data/"; +export const DATA_URL = `${DATA_ROOT}equipment-catalog.json`; + +export const CATEGORY_ORDER = [ + "Оружие", + "Боеприпасы и взрывчатка", + "Обвесы", + "Ближний бой", + "Броня", + "Экипировка", + "Медицина", + "Снаряжение", + "Другое", +] as const; + +export const ADMIN_DRAFT_KEY = "ssmc-equipment-admin-overrides-v1"; +export const ADMIN_API_URL = + "https://ssmc-wiki-admin-api.24dfffer.workers.dev/api/overrides"; + +export const modules = [ + { + slug: "equipment", + title: "Снаряжение", + text: "Оружие, боеприпасы, обвесы и остальное оснащение морпехов.", + }, + { + slug: "weapon-builder", + title: "Конструктор оружия", + text: "Выбор оружия, совместимых обвесов и итоговых характеристик сборки.", + status: "Запланировано", + }, + { + slug: "damage", + title: "Калькулятор урона", + text: "Урон на выбранной дистанции с falloff, бронёй и бронепробитием.", + status: "Запланировано", + }, + { + slug: "compare", + title: "Сравнение", + text: "Несколько предметов рядом без прыжков между вкладками вики.", + status: "Запланировано", + }, + { + slug: "loadout", + title: "Комплект бойца", + text: "Оружие, броня, пояс, подсумки и расходники в одной сборке.", + status: "Запланировано", + }, + { + slug: "chemistry", + title: "Химический планировщик", + text: "Расчёт реагентов и пошаговый маршрут приготовления.", + status: "Запланировано", + }, +]; diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..aad156e --- /dev/null +++ b/src/format.ts @@ -0,0 +1,309 @@ +import { CATEGORY_ORDER } from "./constants"; +import type { CatalogItem, JsonMap } from "./types"; + +export function isMap(value: unknown): value is JsonMap { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function normalize(value: string) { + return value.trim().toLocaleLowerCase("ru").replace(/ё/g, "е"); +} + +export function capitalizeName(value: string) { + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (/\d/u.test(character)) return value; + if (/\p{L}/u.test(character)) return value.slice(0, index) + character.toLocaleUpperCase("ru") + value.slice(index + 1); + } + return value; +} + +export function categoryIndex(category?: string) { + const index = CATEGORY_ORDER.indexOf(category as typeof CATEGORY_ORDER[number]); + return index === -1 ? CATEGORY_ORDER.length : index; +} + +export function itemCompatibleWith(item: CatalogItem, weaponId: string) { + if (item.compatibleWeaponIds?.includes(weaponId)) return true; + return (item.attachableTo || []).some((slot) => (slot.weaponIds || []).includes(weaponId)); +} + +export function formatNumber(value: unknown) { + return typeof value === "number" ? new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(value) : String(value ?? ""); +} + +export function formatValue(value: unknown): string { + if (typeof value === "number") return formatNumber(value); + if (typeof value === "boolean") return value ? "Да" : "Нет"; + if (Array.isArray(value)) return value.map((entry) => formatValue(entry)).join(", "); + if (typeof value === "string") { + const translated = value.split(",").map((entry) => enumLabel(entry.trim())).join(", "); + return translated || "—"; + } + return String(value ?? "—"); +} + +export function descriptionText(value: unknown): string { + if (typeof value === "string") return value.trim() || "Нет описания"; + if (isMap(value)) { + const text = Object.values(value).find((entry) => typeof entry === "string" && entry.trim()); + return typeof text === "string" ? text.trim() : "Нет описания"; + } + return "Нет описания"; +} + +export function signedNumber(value: unknown, suffix = "") { + if (typeof value !== "number") return null; + const sign = value > 0 ? "+" : value < 0 ? "−" : ""; + return `${sign}${formatNumber(Math.abs(value))}${suffix}`; +} + +export function signedPercent(value: unknown) { + return typeof value === "number" ? signedNumber(Math.round(value * 100), "%") : null; +} + +export function attachmentCondition(value: unknown) { + if (!isMap(value)) return "Всегда"; + return [ + value.activeOnly === true ? "включён" : null, + value.inactiveOnly === true ? "выключен" : null, + value.wieldedOnly === true ? "в упоре" : null, + value.unwieldedOnly === true ? "с рук" : null, + ].filter(Boolean).join(", ") || "Всегда"; +} + +export function attachmentModifierRows(modifiers: JsonMap): Array<[string, unknown]> { + const rows: Array<[string, unknown]> = []; + const append = (label: string, value: unknown, condition: string) => { + if (value !== null && value !== undefined && value !== "") { + rows.push([condition === "Всегда" ? label : `${label} · ${condition}`, value]); + } + }; + for (const raw of Object.values(modifiers)) { + if (!isMap(raw)) continue; + const lists = [raw.modifiers, raw.fireModeMods].filter(Array.isArray) as unknown[][]; + if (!lists.length) lists.push([raw]); + for (const list of lists) { + for (const entry of list.filter(isMap)) { + const condition = attachmentCondition(entry.conditions); + append("Точность", signedPercent(entry.accuracyAddMult), condition); + append("Урон", signedPercent(entry.damageAddMult), condition); + append("Падение урона", signedPercent(entry.damageFalloffAddMult), condition); + append("Разброс", signedNumber(entry.scatterFlat), condition); + append("Разброс очереди", signedNumber(entry.burstScatterAddMult), condition); + append("Отдача", signedNumber(entry.recoilFlat), condition); + append("Задержка выстрела", signedNumber(entry.fireDelayFlat, " с"), condition); + append("Патронов в очереди", signedNumber(entry.shotsPerBurstFlat), condition); + append("Скорость ходьбы", signedPercent(entry.walk), condition); + append("Скорость бега", signedPercent(entry.sprint), condition); + append("Время вскидывания", signedNumber(entry.delay, " с"), condition); + append("Размер оружия", signedNumber(entry.size), condition); + if (isMap(entry.bonusDamage)) { + const damage = isMap(entry.bonusDamage.types) ? entry.bonusDamage.types : entry.bonusDamage; + append("Урон в ближнем бою", formatDamage(damage), condition); + } + if (entry.extraFireModes != null) append("Добавляет режим", formatValue(entry.extraFireModes), condition); + } + } + } + return rows; +} + +export function ammoProviderRows(value: unknown): Array<[string, unknown]> { + const provider = isMap(value) ? value : {}; + return [ + ["Вместимость", provider.capacity != null ? `${formatNumber(provider.capacity)} шт.` : null], + ["Боеприпас", provider.proto ? readableId(String(provider.proto)) : null], + ["Расход за выстрел", provider.fireCost], + ]; +} + +export function flattenReadable(value: unknown, path: string[] = []): { rows: Array<[string, unknown]>; complex: unknown[] } { + const rows: Array<[string, unknown]> = []; + const complex: unknown[] = []; + if (!isMap(value)) { + if (Array.isArray(value) && value.every((entry) => ["string", "number", "boolean"].includes(typeof entry))) { + rows.push([path.map(statLabel).join(" · ") || "Значение", value]); + } else if (["string", "number", "boolean"].includes(typeof value)) { + rows.push([path.map(statLabel).join(" · ") || "Значение", value]); + } else if (value != null) complex.push(value); + return { rows, complex }; + } + for (const [key, child] of Object.entries(value)) { + const nextPath = [...path, key]; + if ((key === "damage" || key === "bonusDamage") && isMap(child)) { + const damage = isMap(child.types) ? child.types : child; + rows.push([nextPath.map(statLabel).join(" · "), formatDamage(damage)]); + continue; + } + if (["string", "number", "boolean"].includes(typeof child)) { + rows.push([nextPath.map(statLabel).join(" · "), formatFieldValue(key, child)]); + continue; + } + if (Array.isArray(child) && child.every((entry) => ["string", "number", "boolean"].includes(typeof entry))) { + rows.push([nextPath.map(statLabel).join(" · "), child]); + continue; + } + if (isMap(child)) { + const nested = flattenReadable(child, nextPath); + rows.push(...nested.rows); + complex.push(...nested.complex); + continue; + } + if (child != null) complex.push({ [key]: child }); + } + return { rows, complex }; +} + +export function formatFieldValue(key: string, value: unknown) { + if (typeof value !== "number") return value; + if (/delay|cooldown|doAfter/i.test(key)) return `${formatNumber(value)} с`; + if (/multiplier|modifier/i.test(key)) return `×${formatNumber(value)}`; + if (/walk|sprint/i.test(key)) return asPercent(value); + if (/angle|rotation/i.test(key)) return `${formatNumber(value)}°`; + if (/range|radius/i.test(key)) return `${formatNumber(value)} тайл.`; + return value; +} + +export function statLabel(value: string) { + const labels: Record = { + wielded: "В упоре", unwielded: "С рук", wieldedOnly: "Только в упоре", unwieldedOnly: "Только с рук", + activeOnly: "Только во включённом состоянии", inactiveOnly: "Только в выключенном состоянии", + accuracy: "Точность", wieldedMultiplier: "В упоре", unwieldedMultiplier: "С рук", + recoilFlat: "Отдача", scatterFlat: "Разброс", accuracyAddMult: "Точность", damageAddMult: "Урон", + fireDelay: "Задержка между выстрелами", maxScatterModifier: "Максимальный разброс", + shotsToMaxScatter: "Выстрелов до максимального разброса", unwieldedScatterMultiplier: "Разброс с рук", + useBurstScatterMult: "Учитывать разброс очереди", falloffMultiplier: "Множитель падения урона", + attacksPerSecond: "Атак в секунду", angle: "Угол атаки", damage: "Урон", bonusDamage: "Дополнительный урон", + baseDelay: "Базовая задержка", preventFiring: "Нельзя стрелять во время вскидывания", + base: "Базовый", light: "Лёгкая броня", medium: "Средняя броня", heavy: "Тяжёлая броня", + skills: "Навыки", weaponGroup: "Группа оружия", conditions: "Условия", modifiers: "Модификаторы", + capacity: "Ёмкость", proto: "Боеприпас", cycleable: "Перезаряжается вручную", mayTransfer: "Можно извлечь патроны", + maxRange: "Дальность", maxDuration: "Длительность", maxIntensity: "Интенсивность", + FullAuto: "Автоматический", SemiAuto: "Одиночный", Burst: "Очередь", + }; + return labels[value] || readableId(value); +} + +export function enumLabel(value: string) { + return fireModeLabel(value) !== value ? fireModeLabel(value) : ({ + light: "Лёгкий", medium: "Средний", heavy: "Тяжёлый", Handgun: "Пистолет", Rifle: "Винтовка", + Small: "Маленький", Normal: "Обычный", Large: "Большой", Huge: "Огромный", + } as Record)[value] || slotLabel(value); +} + +export function fireModeLabel(value: string) { + return ({ SemiAuto: "Одиночный", Burst: "Очередь", FullAuto: "Автоматический" } as Record)[value] || value; +} + +export function slotLabel(value: string) { + const labels: Record = { + Back: "Спина", back: "Спина", suitStorage: "Броня", suitstorage: "Броня", + outerClothing: "Верхняя одежда", head: "Голова", eyes: "Глаза", ears: "Уши", mask: "Маска", + belt: "Пояс", pocket: "Карман", gloves: "Перчатки", neck: "Шея", shoes: "Обувь", jumpsuit: "Униформа", + }; + return labels[value] || value; +} + +export function itemSizeLabel(value: unknown) { + if (value == null) return null; + return ({ Tiny: "Крошечный", Small: "Маленький", Normal: "Обычный", Large: "Большой", Huge: "Огромный", Ginormous: "Гигантский" } as Record)[String(value)] || String(value); +} + +export function solutionLabel(value: string) { + return ({ pen: "Инъектор", drink: "Раствор", pack: "Пакет", food: "Содержимое", tank: "Резервуар" } as Record)[value] || readableId(value); +} + +export function readableId(value: string) { + const known: Record = { + CMBicaridine: "Бикаридин", CMKelotane: "Келотан", CMTricordrazine: "Трикордразин", CMDexalin: "Дексалин", + CMDylovene: "Диловен", CMInaprovaline: "Инапровалин", CMEpinephrine: "Эпинефрин", Blood: "Кровь", + Fiber: "Волокно", RMCSkillFirearms: "Огнестрельное оружие", RMCSkillEngineer: "Инженерия", + RMCSkillSmartGun: "Умное оружие", RMCSkillPolice: "Военная полиция", + }; + if (known[value]) return known[value]; + return value.replace(/^RMC|^CM/, "").replace(/([a-zа-я])([A-ZА-Я])/g, "$1 $2").replace(/_/g, " "); +} + +export function asMultiplier(value: unknown) { + return typeof value === "number" ? `×${formatNumber(value)}` : null; +} + +export function asPercent(value: unknown) { + return typeof value === "number" ? `${formatNumber(value * 100)}%` : null; +} + +export function formatMovementPenalty(movement: JsonMap) { + const walk = typeof movement.walkModifier === "number" ? movement.walkModifier : null; + const sprint = typeof movement.sprintModifier === "number" ? movement.sprintModifier : null; + if (walk == null && sprint == null) return null; + const penalty = (value: number) => `${Math.round((1 - value) * 100)}%`; + if (walk != null && sprint != null && walk === sprint) return `−${penalty(walk)}`; + return [ + walk != null ? `ходьба −${penalty(walk)}` : null, + sprint != null ? `бег −${penalty(sprint)}` : null, + ].filter(Boolean).join(" · "); +} + +export function itemSizeGenitive(value: string) { + return ({ + tiny: "крошечного", + small: "маленького", + normal: "обычного", + large: "большого", + huge: "огромного", + ginormous: "гигантского", + } as Record)[value.toLocaleLowerCase()] || itemSizeLabel(value) || value; +} + +export function pluralize(value: number, one: string, few: string, many: string) { + const lastTwo = value % 100; + const last = value % 10; + if (lastTwo >= 11 && lastTwo <= 14) return many; + if (last === 1) return one; + if (last >= 2 && last <= 4) return few; + return many; +} + +export function formatDamage(value: unknown) { + if (!isMap(value)) return "Урон не указан"; + const entries = Object.entries(value).filter(([, amount]) => typeof amount === "number" && amount !== 0); + return entries.length ? entries.map(([type, amount]) => `${damageLabel(type)} ${formatNumber(amount)}`).join(" · ") : "Урон не указан"; +} + +export function damageLabel(value: string) { + const labels: Record = { Blunt: "Дробящий", Slash: "Режущий", Piercing: "Колющий", Heat: "Термический", Caustic: "Кислотный" }; + return labels[value] || value; +} + +export function armorLabel(value: string) { + const labels: Record = { + xenoArmor: "Ксено-урон", + frontalArmor: "Спереди", + sideArmor: "Сбоку", + melee: "Ближний бой", + bullet: "Пули", + laser: "Лазеры", + bio: "Биозащита", + explosionArmor: "Взрывы", + acid: "Кислота", + }; + return labels[value] || value; +} + +export function componentLabel(value: string) { + const labels: Record = { + AttachableWeaponRangedMods: "Стрельба", + AttachableSpeedMods: "Скорость движения", + AttachableWieldDelayMods: "Время вскидывания", + AttachableMeleeMods: "Ближний бой", + }; + return labels[value] || value; +} + +export function prettyJson(value: unknown) { + return JSON.stringify(value, null, 2) + .replaceAll('"conditions"', "условия") + .replaceAll('"modifiers"', "модификаторы") + .replaceAll('"', ""); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..bd130f6 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,68 @@ +export type JsonMap = Record; + +export type AttachmentSlot = { + id?: string; + slotId?: string; + name?: string; + slotName?: string; + compatibleItemIds?: string[]; + installedItemIds?: string[]; + weaponIds?: string[]; +}; + +export type CatalogItem = { + id: string; + name: string; + baseName?: string; + description?: unknown; + category?: string; + image?: string; + types?: string[]; + tags?: string[]; + componentTypes?: string[]; + equipmentSlots?: string[]; + properties?: Record; + classification?: { + category?: string; + categoryId?: string; + confidence?: string; + reason?: string; + }; + attachmentSlots?: AttachmentSlot[]; + attachableTo?: AttachmentSlot[]; + magazineSlots?: AttachmentSlot[]; + compatibleWeaponIds?: string[]; + containsItemIds?: string[]; + weaponStats?: JsonMap; + armorStats?: JsonMap; + attachmentStats?: JsonMap; + storageStats?: JsonMap; +}; + +export type PublicCatalog = { + itemIds: string[]; + categories: Record; + aliases?: Record; +}; + +export type Catalog = { + schemaVersion: number; + gameCommit: string; + items: Record; + publicCatalog: PublicCatalog; + counts?: { publicItems?: number }; +}; + +export type AdminOverride = { + category?: string; + hidden?: boolean; +}; + +export type AdminDraft = Record; + +export type AdminApiDocument = { + schemaVersion: 1; + items: AdminDraft; +}; + +export type AdminSyncState = "loading" | "ready" | "saving" | "saved" | "error"; diff --git a/src/useCatalog.ts b/src/useCatalog.ts new file mode 100644 index 0000000..49fa9aa --- /dev/null +++ b/src/useCatalog.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { DATA_URL } from "./constants"; +import type { Catalog } from "./types"; + +export function useCatalog() { + const [catalog, setCatalog] = useState(null); + const [loadState, setLoadState] = useState<"loading" | "live" | "error">("loading"); + + useEffect(() => { + const controller = new AbortController(); + fetch(DATA_URL, { signal: controller.signal, cache: "no-cache" }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + }) + .then((data) => { + if (data.schemaVersion < 3 || !data.publicCatalog?.itemIds) { + throw new Error("Unsupported equipment catalog schema"); + } + setCatalog(data); + setLoadState("live"); + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") return; + setLoadState("error"); + }); + return () => controller.abort(); + }, []); + + return { catalog, loadState }; +}