diff --git a/apps/web/src/pages/tackle/items/[id].astro b/apps/web/src/pages/tackle/items/[id].astro index 495b775..73c64ad 100644 --- a/apps/web/src/pages/tackle/items/[id].astro +++ b/apps/web/src/pages/tackle/items/[id].astro @@ -1,21 +1,25 @@ --- import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro"; import DataPassport from "../../../components/DataPassport.astro"; +import EntityMedia from "../../../components/EntityMedia.astro"; import Layout from "../../../layouts/Layout.astro"; import PageHero from "../../../components/PageHero.astro"; import StatePanel from "../../../components/StatePanel.astro"; import TackleGlyph from "../../../components/TackleGlyph.astro"; -import { ApiError, api, type TackleItem } from "../../../lib/api"; +import { ApiError, api, type MediaAsset, type TackleItem } from "../../../lib/api"; +import { findMediaByLabel, mediaRoleLabel } from "../../../lib/media"; import { safeHttpUrl } from "../../../lib/urls"; const { id } = Astro.params; -let item: TackleItem | undefined; +let item: TackleItem | undefined, media: MediaAsset[] = []; let unavailable = false; -try { - item = await api(`/api/v1/tackle/items/${encodeURIComponent(id ?? "")}`); -} catch (error) { - unavailable = !(error instanceof ApiError) || error.status >= 500; -} +const [itemResult, mediaResult] = await Promise.allSettled([ + api(`/api/v1/tackle/items/${encodeURIComponent(id ?? "")}`), + api("/api/v1/media/catalog?entity_type=tackle"), +]); +if (itemResult.status === "fulfilled") item = itemResult.value; +else { const error = itemResult.reason; unavailable = !(error instanceof ApiError) || error.status >= 500; } +if (mediaResult.status === "fulfilled") media = mediaResult.value; if (!item && !unavailable) Astro.response.status = 404; if (unavailable) { Astro.response.status = 503; @@ -27,6 +31,7 @@ const categoryLabels: Record = { bait: "Наживка", lure: " const missingLabels: Record = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" }; const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? []; const sourceHref = safeHttpUrl(item?.source_url); +const mediaCandidates = item ? (["tackle_card", "tackle_detail"] as const).map(role => findMediaByLabel(media, item!.name, [role])).filter((asset): asset is MediaAsset => Boolean(asset)) : []; --- @@ -37,5 +42,6 @@ const sourceHref = safeHttpUrl(item?.source_url);
Подкатегория
{item.subcategory ?? "Не указана"}
Уровень открытия
{item.unlock_level ?? "Не указан"}
Источник
{item.source_system ?? "Не указан"}
{missing.length > 0 &&

Не хватает: {missing.join(", ")}.

}{sourceHref && Открыть первоисточник}
+ {mediaCandidates.length > 0 &&
{mediaCandidates.map(asset =>
{mediaRoleLabel(asset.media_role ?? "")}
)}
} }
diff --git a/apps/web/src/pages/tackle/rigs/[id].astro b/apps/web/src/pages/tackle/rigs/[id].astro index 3178a56..cdeeaed 100644 --- a/apps/web/src/pages/tackle/rigs/[id].astro +++ b/apps/web/src/pages/tackle/rigs/[id].astro @@ -1,21 +1,25 @@ --- import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro"; import DataPassport from "../../../components/DataPassport.astro"; +import EntityMedia from "../../../components/EntityMedia.astro"; import Layout from "../../../layouts/Layout.astro"; import PageHero from "../../../components/PageHero.astro"; import StatePanel from "../../../components/StatePanel.astro"; import TackleGlyph from "../../../components/TackleGlyph.astro"; -import { ApiError, api, type Rig } from "../../../lib/api"; +import { ApiError, api, type MediaAsset, type Rig } from "../../../lib/api"; +import { findMediaByLabel, mediaRoleLabel } from "../../../lib/media"; import { safeHttpUrl } from "../../../lib/urls"; const { id } = Astro.params; -let rig: Rig | undefined; +let rig: Rig | undefined, media: MediaAsset[] = []; let unavailable = false; -try { - rig = await api(`/api/v1/tackle/rigs/${encodeURIComponent(id ?? "")}`); -} catch (error) { - unavailable = !(error instanceof ApiError) || error.status >= 500; -} +const [rigResult, mediaResult] = await Promise.allSettled([ + api(`/api/v1/tackle/rigs/${encodeURIComponent(id ?? "")}`), + api("/api/v1/media/catalog?entity_type=tackle"), +]); +if (rigResult.status === "fulfilled") rig = rigResult.value; +else { const error = rigResult.reason; unavailable = !(error instanceof ApiError) || error.status >= 500; } +if (mediaResult.status === "fulfilled") media = mediaResult.value; if (!rig && !unavailable) Astro.response.status = 404; if (unavailable) { Astro.response.status = 503; @@ -27,6 +31,7 @@ const roleLabels: Record = { lure: "Приманка", bait: "Н const missingLabels: Record = { source_url: "ссылка на источник", source_checked_at: "дата проверки" }; const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? []; const sourceHref = safeHttpUrl(rig?.source_url); +const mediaCandidates = rig ? [findMediaByLabel(media, rig.name, ["rig_diagram"])].filter((asset): asset is MediaAsset => Boolean(asset)) : []; --- @@ -37,6 +42,7 @@ const sourceHref = safeHttpUrl(rig?.source_url);
Компоненты
    {rig.components.length ? rig.components.map(component =>
  1. {roleLabels[component.role] ?? component.role}{component.tackle_item_id ? {component.raw_value ?? "Карточка снасти"} : {component.raw_value ?? "Значение не указано"}}
  2. ) :
  3. Состав не указан.
  4. }
{missing.length > 0 &&

Не хватает: {missing.join(", ")}.

}{sourceHref && Открыть первоисточник}
+ {mediaCandidates.length > 0 &&
{mediaCandidates.map(asset =>
{mediaRoleLabel(asset.media_role ?? "")}
)}
} }
diff --git a/apps/web/src/styles/media-catalog.css b/apps/web/src/styles/media-catalog.css index ae0bec1..9fd7dae 100644 --- a/apps/web/src/styles/media-catalog.css +++ b/apps/web/src/styles/media-catalog.css @@ -1,6 +1,7 @@ .entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}} .entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}} .waterbody-media-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;padding-block:28px}.waterbody-media-grid article{min-width:0;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:var(--surface-soft)}.waterbody-media-grid article>.overline{display:block;margin-bottom:10px}.waterbody-media-grid .entity-media__frame{min-height:220px}@media(max-width:720px){.waterbody-media-grid{grid-template-columns:1fr;padding-block:20px}.waterbody-media-grid .entity-media__frame{min-height:190px}} +.catalog-media-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:18px;padding-block:28px 70px}.catalog-media-grid article{min-width:0;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:var(--surface-soft)}.catalog-media-grid article>.overline{display:block;margin-bottom:10px}.catalog-media-grid .entity-media__frame{min-height:220px} .media-role-filter{display:flex;align-items:end;gap:10px;margin:0 0 22px;padding:14px 16px;border:1px solid var(--border-soft);border-radius:12px;background:var(--surface-soft)}.media-role-filter label{display:grid;gap:6px;color:var(--text-muted);font-size:11px;font-weight:750;text-transform:uppercase;letter-spacing:.08em}.media-role-filter select{min-width:220px;height:40px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text)}.media-role-filter a{padding:11px 4px;color:var(--text-muted);font-size:12px}@media(max-width:720px){.media-role-filter{align-items:stretch;flex-wrap:wrap}.media-role-filter label{flex:1 1 100%}.media-role-filter select{width:100%}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2c0bb5b..77108b0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -73,7 +73,7 @@ - [ ] **G05 · Связи с уловами и источниками.** Добавлены `catch_tackle_component` и offline `gear_components`: можно сохранять несколько unresolved/canonical компонентов с ролью, порядком, исходным значением, source identity и `raw_payload`; legacy `bait_id` не меняется. Parser сохраняет порядок оборудования из detail и разделяет bait/rig в catch-полях. Запись компонентов подключена к community import, официальному импорту и пользовательской форме; повторная обработка идемпотентна. Canonical-привязка и безопасный backfill остаются только после подтверждённого crosswalk. - [ ] **G06 · API и публичный каталог.** Добавлены пагинированные `/api/v1/tackle/items` и `/api/v1/tackle/rigs` с фильтрами предметов и сборок по названию, а также предметов по категории, бренду, семейству и уровню; detail endpoints для предмета и сборки, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Публичный каталог теперь показывает обнаруживаемые карточки снастей и сборок, имеет detail-routes `/tackle/items/:id` и `/tackle/rigs/:id` с 404/unavailable-различением, паспортом данных и явным списком недостающих полей; CatchList показывает ordered-компоненты и ведёт в canonical detail снасти или сборки только при наличии соответствующего ID, а unresolved raw value оставляет текстом. - [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода, роли и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Публичный `/tackle/analytics` показывает и сохраняет фильтры, имеет clean reset-link, выборку, независимых игроков и раздельные `recommendation`/`insufficient_data` states без рейтинга эффективности. Добавлен объяснимый `freshness_score` с half-life `12.5` часа и контрактом `0..100`: он не меняет raw-счётчики и пороги, но стабильно ставит более свежую равную выборку выше старой и виден на карточке. При однозначной canonical-привязке аналитика ведёт в карточку снасти или сборки, а при конфликте идентификаторов сохраняет raw-значение без ложной ссылки. Остаётся дальнейшая визуальная приёмка. -- [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Публичный и admin UI используют единый словарь человекочитаемых role labels с безопасным fallback для будущих ролей; публичная медиатека фильтрует разрешённые роли через URL/API, admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. +- [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Публичный и admin UI используют единый словарь человекочитаемых role labels с безопасным fallback для будущих ролей; публичная медиатека фильтрует разрешённые роли через URL/API, detail-страницы снастей и сборок показывают только совпавшие reviewed media с прямой атрибуцией, admin-карточки дополнительно получают `data-media-role` для проверяемой инспекции, unit-регрессия словаря проходит. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации. - [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое импорта проходят. TEMP-only query-plan gate для фильтра каталога и группировки сочетаний использует индексы и укладывается в 250 мс. Chromium подтвердил empty-state публичного каталога и отсутствие overflow на 320 px; обычный E2E пропускает bootstrap без явных переменных, отдельный bootstrap с токеном проходит. Остаются HTTP/browser acceptance для неоднозначных и многокомпонентных комплектов, ручной visual review и проверяемый счётчик по категориям либо явный `unknown`. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. ### Тёмная тема