feat: split tackle catalog pagination

This commit is contained in:
ik
2026-09-22 19:53:31 +07:00
parent a8288b2dd3
commit 089b9cc11d
5 changed files with 47 additions and 20 deletions
+4 -3
View File
@@ -7,21 +7,22 @@ interface Props {
total: number; total: number;
limit: number; limit: number;
offset: number; offset: number;
offsetParam?: string;
anchor?: string; anchor?: string;
itemLabel?: string; itemLabel?: string;
} }
const { path, params, total, limit, offset, anchor = "", itemLabel = "записей" } = Astro.props; const { path, params, total, limit, offset, offsetParam = "offset", anchor = "", itemLabel = "записей" } = Astro.props;
const state = pageWindow(total, limit, offset); const state = pageWindow(total, limit, offset);
--- ---
{total > limit && <nav class="pagination" aria-label="Пагинация"> {total > limit && <nav class="pagination" aria-label="Пагинация">
<span>{state.start}{state.end} из {total} {itemLabel} · страница {state.page} из {state.pages}</span> <span>{state.start}{state.end} из {total} {itemLabel} · страница {state.page} из {state.pages}</span>
<div> <div>
{state.previousOffset !== null {state.previousOffset !== null
? <a data-action="secondary" rel="prev" href={pageHref(path, params, state.previousOffset, anchor)}>← Предыдущая</a> ? <a data-action="secondary" rel="prev" href={pageHref(path, params, state.previousOffset, anchor, offsetParam)}>← Предыдущая</a>
: <span class="pagination__disabled" aria-disabled="true">← Предыдущая</span>} : <span class="pagination__disabled" aria-disabled="true">← Предыдущая</span>}
{state.nextOffset !== null {state.nextOffset !== null
? <a data-action="secondary" rel="next" href={pageHref(path, params, state.nextOffset, anchor)}>Следующая →</a> ? <a data-action="secondary" rel="next" href={pageHref(path, params, state.nextOffset, anchor, offsetParam)}>Следующая →</a>
: <span class="pagination__disabled" aria-disabled="true">Следующая →</span>} : <span class="pagination__disabled" aria-disabled="true">Следующая →</span>}
</div> </div>
</nav>} </nav>}
+3 -3
View File
@@ -22,10 +22,10 @@ export const pageWindow = (total: number, limit: number, offset: number): PageWi
}; };
}; };
export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "") => { export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "", offsetParam = "offset") => {
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
if (offset > 0) params.set("offset", String(offset)); if (offset > 0) params.set(offsetParam, String(offset));
else params.delete("offset"); else params.delete(offsetParam);
const query = params.toString(); const query = params.toString();
return `${path}${query ? `?${query}` : ""}${anchor}`; return `${path}${query ? `?${query}` : ""}${anchor}`;
}; };
+29 -13
View File
@@ -5,7 +5,7 @@ import StatePanel from "../../components/StatePanel.astro";
import Pagination from "../../components/Pagination.astro"; import Pagination from "../../components/Pagination.astro";
import TackleGlyph from "../../components/TackleGlyph.astro"; import TackleGlyph from "../../components/TackleGlyph.astro";
import DataPassport from "../../components/DataPassport.astro"; import DataPassport from "../../components/DataPassport.astro";
import { api, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/api"; import { api, ApiError, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/api";
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
const category = params.get("category") ?? ""; const category = params.get("category") ?? "";
@@ -14,23 +14,34 @@ const brand = params.get("brand") ?? "";
const family = params.get("family") ?? ""; const family = params.get("family") ?? "";
const unlockLevelParam = params.get("unlock_level") ?? ""; const unlockLevelParam = params.get("unlock_level") ?? "";
const unlockLevel = /^\d+$/.test(unlockLevelParam) ? unlockLevelParam : ""; const unlockLevel = /^\d+$/.test(unlockLevelParam) ? unlockLevelParam : "";
const requestedOffset = Number(params.get("offset") ?? 0); const requestedItemsOffset = Number(params.get("items_offset") ?? 0);
const requestedRigsOffset = Number(params.get("rigs_offset") ?? 0);
const limit = 48; const limit = 48;
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0; const itemsOffset = Number.isInteger(requestedItemsOffset) && requestedItemsOffset >= 0 ? requestedItemsOffset : 0;
const query = new URLSearchParams({ limit: String(limit), offset: String(offset) }); const rigsOffset = Number.isInteger(requestedRigsOffset) && requestedRigsOffset >= 0 ? requestedRigsOffset : 0;
const query = new URLSearchParams({ limit: String(limit), offset: String(itemsOffset) });
if (category) query.set("category", category); if (category) query.set("category", category);
if (search) query.set("q", search); if (search) query.set("q", search);
if (brand) query.set("brand", brand); if (brand) query.set("brand", brand);
if (family) query.set("family", family); if (family) query.set("family", family);
if (unlockLevel) query.set("unlock_level", unlockLevel); if (unlockLevel) query.set("unlock_level", unlockLevel);
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset }, unavailable = false, rigsUnavailable = false; let result: PaginatedTackleItems = { items: [], total: 0, limit, offset: itemsOffset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset: rigsOffset };
const [itemsResult, rigsResult] = await Promise.allSettled([ const [itemsResult, rigsResult] = await Promise.allSettled([
api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`), api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`),
api<PaginatedRigs>(`/api/v1/tackle/rigs?limit=${limit}&offset=${offset}${search ? `&q=${encodeURIComponent(search)}` : ""}`), api<PaginatedRigs>(`/api/v1/tackle/rigs?limit=${limit}&offset=${rigsOffset}${search ? `&q=${encodeURIComponent(search)}` : ""}`),
]); ]);
if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true; const itemsError = itemsResult.status === "rejected" ? itemsResult.reason : null;
if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true; const rigsError = rigsResult.status === "rejected" ? rigsResult.reason : null;
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); } const itemsInvalid = itemsError instanceof ApiError && itemsError.status === 422;
const rigsInvalid = rigsError instanceof ApiError && rigsError.status === 422;
const itemsUnavailable = Boolean(itemsError) && !itemsInvalid;
const rigsUnavailable = Boolean(rigsError) && !rigsInvalid;
const invalidFilters = itemsInvalid || rigsInvalid;
const unavailable = itemsUnavailable || rigsUnavailable;
if (itemsResult.status === "fulfilled") result = itemsResult.value;
if (rigsResult.status === "fulfilled") rigs = rigsResult.value;
if (invalidFilters) Astro.response.status = 422;
else if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" }; const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
const filterParams = new URLSearchParams(); const filterParams = new URLSearchParams();
if (category) filterParams.set("category", category); if (category) filterParams.set("category", category);
@@ -38,8 +49,10 @@ if (search) filterParams.set("q", search);
if (brand) filterParams.set("brand", brand); if (brand) filterParams.set("brand", brand);
if (family) filterParams.set("family", family); if (family) filterParams.set("family", family);
if (unlockLevel) filterParams.set("unlock_level", unlockLevel); if (unlockLevel) filterParams.set("unlock_level", unlockLevel);
const itemsParams = new URLSearchParams(filterParams);
const rigsParams = new URLSearchParams(search ? { q: search } : {});
--- ---
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и сборок Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable} errorPage={unavailable}> <Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и сборок Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable || invalidFilters} errorPage={false}>
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} /> <PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей"> <form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label> <label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
@@ -51,7 +64,10 @@ if (unlockLevel) filterParams.set("unlock_level", unlockLevel);
{(category || search || brand || family || unlockLevel) && <a data-action="quiet" href="/tackle">Сбросить</a>} {(category || search || brand || family || unlockLevel) && <a data-action="quiet" href="/tackle">Сбросить</a>}
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a> <a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
</form> </form>
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small>{item.unlock_level !== null && <small>Открывается с уровня {item.unlock_level}</small>}<div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />} {invalidFilters ? <StatePanel tone="error" title="Проверьте фильтры каталога" description="Один из параметров поиска имеет недопустимое значение. Измените фильтры и попробуйте снова." /> : <>
{!unavailable && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Сборки</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Сборки временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <a href={`/tackle/rigs/${rig.id}`}><TackleGlyph name={rig.name} size={32} /><span>Сборка · {rig.component_count} компонентов</span><strong>{rig.name}</strong><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title="Подтверждённых сборок пока нет" description="Сборки появятся после разрешённой загрузки и проверки источников." />}</section>} {itemsUnavailable ? <StatePanel tone="unavailable" title="Каталог снастей временно недоступен" description="Сборки продолжают отображаться независимо. Попробуйте обновить карточки снастей позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <article class="catalog-card"><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><a class="catalog-card__title" href={`/tackle/items/${item.id}`}><strong>{item.name}</strong></a><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small>{item.unlock_level !== null && <small>Открывается с уровня {item.unlock_level}</small>}<div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></article>)}</section> : <StatePanel title={search ? "По запросу ничего не найдено" : "Подтверждённых карточек пока нет"} description={search ? "Измените запрос или сбросьте фильтры." : "Каталог заполнится после разрешённой загрузки и ручной проверки источников."} />}
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />} {!itemsUnavailable && <Pagination path="/tackle" params={itemsParams} total={result.total} limit={limit} offset={itemsOffset} offsetParam="items_offset" itemLabel="карточек" />}
<section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Сборки</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Сборки временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <article class="catalog-card"><TackleGlyph name={rig.name} size={32} /><span>Сборка · {rig.component_count} компонентов</span><a class="catalog-card__title" href={`/tackle/rigs/${rig.id}`}><strong>{rig.name}</strong></a><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></article>)}</div> : <StatePanel contained={false} title={search ? "По запросу сборки не найдены" : "Подтверждённых сборок пока нет"} description={search ? "Измените запрос или сбросьте фильтры." : "Сборки появятся после разрешённой загрузки и проверки источников."} />}</section>
{!rigsUnavailable && <Pagination path="/tackle" params={rigsParams} total={rigs.total} limit={limit} offset={rigsOffset} offsetParam="rigs_offset" itemLabel="сборок" />}
</>}
</Layout> </Layout>
+10
View File
@@ -3,3 +3,13 @@
/* Editorial atlas: catalog pages read as a field guide, not a utility list. */ /* Editorial atlas: catalog pages read as a field guide, not a utility list. */
.catalog-hero--illustrated{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 210px;align-items:center;gap:48px;min-height:310px;overflow:hidden}.catalog-hero--illustrated:before{content:"";position:absolute;right:102px;width:330px;height:190px;border:1px solid color-mix(in srgb,var(--teal) 11%,transparent);border-radius:50%;box-shadow:0 0 0 32px color-mix(in srgb,var(--teal) 5%,transparent),0 0 0 64px color-mix(in srgb,var(--teal) 3%,transparent);transform:rotate(-9deg);pointer-events:none}.catalog-hero__copy{position:relative;z-index:1}.catalog-hero__copy p{max-width:660px;line-height:1.6}.catalog-hero__seal{position:relative;z-index:1;width:176px;height:176px;justify-self:end;display:grid;place-items:center;align-content:center;border:1px solid #345155;border-radius:50%;background:var(--deep);color:var(--lime);box-shadow:0 18px 48px #16383c1b}.catalog-hero__seal>span{height:60px;display:grid;place-items:center}.catalog-hero__seal .fish-silhouette{color:var(--lime)}.catalog-hero__seal strong{margin-top:5px;font:400 32px/1 Georgia,serif;color:var(--white)}.catalog-hero__seal small{margin-top:4px;color:#a9b8b5;font-size:9px;text-transform:uppercase;letter-spacing:.13em}.catalog-grid{grid-template-columns:repeat(auto-fit,minmax(min(100%,330px),1fr));gap:14px}.catalog-grid>a{min-height:146px;align-content:start;padding:24px 25px;border-color:var(--border-soft);background:linear-gradient(145deg,var(--surface),var(--surface-soft))}.catalog-grid>a:before{content:"";position:absolute;inset:0;border-top:3px solid transparent;transition:border-color var(--motion-base) var(--ease-out)}.catalog-grid>a:hover:before{border-color:var(--lime)}.catalog-grid>a>span{color:var(--text-muted)}.catalog-grid>a>strong{position:relative;z-index:1;margin-top:10px;font-size:28px}.catalog-grid>a>i{position:relative;z-index:1;align-self:end;display:flex;align-items:center;gap:8px;margin-top:20px;color:var(--text-muted);font:700 10px Inter,sans-serif;text-transform:uppercase;letter-spacing:.09em}.catalog-grid>a>i b{color:#6d8d2a;font-size:16px}.catalog-grid>a>.fish-silhouette{right:20px;bottom:12px;opacity:.095}.waterbody-mark{position:absolute;right:14px;bottom:8px;fill:none;stroke:#315f63;stroke-width:1.2;opacity:.12;pointer-events:none}.waterbody-mark__shore{stroke-width:2}.waterbody-mark circle{fill:var(--lime);stroke:none} .catalog-hero--illustrated{position:relative;display:grid;grid-template-columns:minmax(0,1fr) 210px;align-items:center;gap:48px;min-height:310px;overflow:hidden}.catalog-hero--illustrated:before{content:"";position:absolute;right:102px;width:330px;height:190px;border:1px solid color-mix(in srgb,var(--teal) 11%,transparent);border-radius:50%;box-shadow:0 0 0 32px color-mix(in srgb,var(--teal) 5%,transparent),0 0 0 64px color-mix(in srgb,var(--teal) 3%,transparent);transform:rotate(-9deg);pointer-events:none}.catalog-hero__copy{position:relative;z-index:1}.catalog-hero__copy p{max-width:660px;line-height:1.6}.catalog-hero__seal{position:relative;z-index:1;width:176px;height:176px;justify-self:end;display:grid;place-items:center;align-content:center;border:1px solid #345155;border-radius:50%;background:var(--deep);color:var(--lime);box-shadow:0 18px 48px #16383c1b}.catalog-hero__seal>span{height:60px;display:grid;place-items:center}.catalog-hero__seal .fish-silhouette{color:var(--lime)}.catalog-hero__seal strong{margin-top:5px;font:400 32px/1 Georgia,serif;color:var(--white)}.catalog-hero__seal small{margin-top:4px;color:#a9b8b5;font-size:9px;text-transform:uppercase;letter-spacing:.13em}.catalog-grid{grid-template-columns:repeat(auto-fit,minmax(min(100%,330px),1fr));gap:14px}.catalog-grid>a{min-height:146px;align-content:start;padding:24px 25px;border-color:var(--border-soft);background:linear-gradient(145deg,var(--surface),var(--surface-soft))}.catalog-grid>a:before{content:"";position:absolute;inset:0;border-top:3px solid transparent;transition:border-color var(--motion-base) var(--ease-out)}.catalog-grid>a:hover:before{border-color:var(--lime)}.catalog-grid>a>span{color:var(--text-muted)}.catalog-grid>a>strong{position:relative;z-index:1;margin-top:10px;font-size:28px}.catalog-grid>a>i{position:relative;z-index:1;align-self:end;display:flex;align-items:center;gap:8px;margin-top:20px;color:var(--text-muted);font:700 10px Inter,sans-serif;text-transform:uppercase;letter-spacing:.09em}.catalog-grid>a>i b{color:#6d8d2a;font-size:16px}.catalog-grid>a>.fish-silhouette{right:20px;bottom:12px;opacity:.095}.waterbody-mark{position:absolute;right:14px;bottom:8px;fill:none;stroke:#315f63;stroke-width:1.2;opacity:.12;pointer-events:none}.waterbody-mark__shore{stroke-width:2}.waterbody-mark circle{fill:var(--lime);stroke:none}
@media(max-width:720px){.catalog-hero--illustrated{grid-template-columns:1fr 106px;gap:18px;min-height:230px}.catalog-hero--illustrated:before{right:-35px;width:210px;height:120px}.catalog-hero__seal{width:96px;height:96px}.catalog-hero__seal>span{height:38px}.catalog-hero__seal .fish-icon{width:36px;height:36px}.catalog-hero__seal .fish-silhouette{width:62px}.catalog-hero__seal strong{font-size:20px}.catalog-hero__seal small{display:none}.catalog-grid>a{min-height:132px}.catalog-grid>a>strong{font-size:25px}} @media(max-width:720px){.catalog-hero--illustrated{grid-template-columns:1fr 106px;gap:18px;min-height:230px}.catalog-hero--illustrated:before{right:-35px;width:210px;height:120px}.catalog-hero__seal{width:96px;height:96px}.catalog-hero__seal>span{height:38px}.catalog-hero__seal .fish-icon{width:36px;height:36px}.catalog-hero__seal .fish-silhouette{width:62px}.catalog-hero__seal strong{font-size:20px}.catalog-hero__seal small{display:none}.catalog-grid>a{min-height:132px}.catalog-grid>a>strong{font-size:25px}}
.catalog-grid>.catalog-card{display:grid;grid-template-columns:1fr auto;gap:7px;min-height:146px;align-content:start;padding:24px 25px;position:relative;overflow:hidden;border:1px solid var(--border-soft);border-radius:14px;background:linear-gradient(145deg,var(--surface),var(--surface-soft));transition:transform var(--motion-base) var(--ease-out),border-color var(--motion-base) var(--ease-out),box-shadow var(--motion-base) var(--ease-out)}
.catalog-grid>.catalog-card:hover{transform:translateY(-2px);border-color:var(--border-strong);box-shadow:0 14px 30px var(--shadow-color)}
.catalog-card>span{grid-column:1/-1;color:var(--text-muted)}
.catalog-card__title{grid-column:1/-1;margin-top:10px;color:inherit;text-decoration:none}
.catalog-card__title strong{font:400 28px Georgia,serif}
.catalog-card__title:hover{text-decoration:underline;text-underline-offset:4px}
.catalog-card>small{color:var(--text-muted)}
.catalog-card .tackle-card-passport{grid-column:1/-1;min-width:0}
@media(max-width:720px){.catalog-grid>.catalog-card{min-height:132px}.catalog-card__title strong{font-size:25px}}
+1 -1
View File
@@ -27,7 +27,7 @@ R-пункты уточняют критерии существующих B/G/U/
- [x] **R02 · P1 · Публикация выбранных media и защита от гонок (A04/B23).** Admin API получил preview и явный набор SHA-256 asset IDs; publish больше не выбирает скрытые `upgrade_stored`, проверяет целостность выбранных файлов/вариантов, сверяет monotonic manifest version и возвращает `409` для устаревшей вкладки. Publish/rollback и CLI-совместимый путь сериализованы общим lock-файлом; решения пишутся атомарно и увеличивают версию. Regression покрывает выбор одного кандидата из двух и stale-version conflict; web admin передаёт ID и версию из заголовка manifest. Browser/production acceptance A04/A06/A07 остаётся отдельным gate. - [x] **R02 · P1 · Публикация выбранных media и защита от гонок (A04/B23).** Admin API получил preview и явный набор SHA-256 asset IDs; publish больше не выбирает скрытые `upgrade_stored`, проверяет целостность выбранных файлов/вариантов, сверяет monotonic manifest version и возвращает `409` для устаревшей вкладки. Publish/rollback и CLI-совместимый путь сериализованы общим lock-файлом; решения пишутся атомарно и увеличивают версию. Regression покрывает выбор одного кандидата из двух и stale-version conflict; web admin передаёт ID и версию из заголовка manifest. Browser/production acceptance A04/A06/A07 остаётся отдельным gate.
- [x] **R03 · P1 · Идемпотентная отправка улова.** Единый replay-контракт теперь сверяет payload hash и TTL также после `IntegrityError`; старый уникальный ключ получает `409`, а завершённый screenshot больше не ломает повтор исходной отправки. Spot/Bait создаются через savepoint и повторный select после unique-конфликта, поэтому ожидаемые гонки не дают необработанный `500`. Regression покрывает одинаковый повтор, изменённый payload, завершённый upload и просроченный ключ. - [x] **R03 · P1 · Идемпотентная отправка улова.** Единый replay-контракт теперь сверяет payload hash и TTL также после `IntegrityError`; старый уникальный ключ получает `409`, а завершённый screenshot больше не ломает повтор исходной отправки. Spot/Bait создаются через savepoint и повторный select после unique-конфликта, поэтому ожидаемые гонки не дают необработанный `500`. Regression покрывает одинаковый повтор, изменённый payload, завершённый upload и просроченный ключ.
- [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется. - [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется.
- [ ] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Отдельная пагинация сборок/предметов, независимые ошибки, валидация URL-фильтров, empty search отдельно от незаполненного каталога. Критерий: 49+ сборок достижимы при 0 предметов, 422 не маскируется под 503. Убрать вложенные ссылки: карточка оборачивает DataPassport с source-link; проверить итоговый DOM и keyboard наполненных карточек. - [x] **R05 · P1 · Полноценный каталог снастей (G06/G09).** Предметы и сборки получили независимые offsets и пагинаторы (`items_offset`/`rigs_offset`), один сбой endpoint больше не скрывает второй, 422 фильтров отделён от 503, пустой поиск отделён от незаполненного каталога. Карточки стали article-блоками с отдельной ссылкой названия и валидным source-link внутри паспорта без вложенных ссылок. `astro check` и production build проходят; browser/keyboard acceptance наполненного каталога остаётся частью G06/G09.
- [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены. - [ ] **R06 · P1 · Надёжный перенос плана (U05).** Preview, объединение/замена с восстановлением, однократный импорт, storage errors, вычисляемая свежесть и точное описание передачи share-данных. Критерий: ссылка не стирает план без выбора, reload не возвращает удалённое, старые данные отмечены.
- [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия. - [ ] **R07 · P1 · Схема координат по водоёмам (U04).** Разделить системы координат, показать точность и водоём, обработать совпадения/обрезку. Критерий: все точки достижимы на 320 px и с клавиатуры, включая одинаковые координаты и длинные названия.
- [ ] **R08 · P1 · Достоверная аналитика снастей (G07).** Зафиксировать публичный минимум 3 наблюдения/2 игрока, разделить время улова и импорта, conservative canonical grouping, ограничить ответ. Критерий: 1/1 не рекомендация, unresolved не получает ложную привязку, старый улов не выглядит свежим. - [ ] **R08 · P1 · Достоверная аналитика снастей (G07).** Зафиксировать публичный минимум 3 наблюдения/2 игрока, разделить время улова и импорта, conservative canonical grouping, ограничить ответ. Критерий: 1/1 не рекомендация, unresolved не получает ложную привязку, старый улов не выглядит свежим.