diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py index 853f85e..7b6a603 100644 --- a/apps/api/app/media_catalog.py +++ b/apps/api/app/media_catalog.py @@ -23,7 +23,7 @@ def _public_role_allowed(entity_type: str | None, role: object) -> bool: return False -def published_assets(entity_type: str | None = None) -> list[dict]: +def published_assets(entity_type: str | None = None, media_role: str | None = None) -> list[dict]: manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) result = [] for item in manifest.get("assets", []): @@ -31,6 +31,8 @@ def published_assets(entity_type: str | None = None) -> list[dict]: continue if entity_type and item.get("entity_type") != entity_type: continue + if media_role and item.get("media_role") != media_role: + continue if not _public_role_allowed(item.get("entity_type"), item.get("media_role")): continue source_page = str(item.get("source_page") or "") diff --git a/apps/api/app/routers/media.py b/apps/api/app/routers/media.py index dae6a36..14ed838 100644 --- a/apps/api/app/routers/media.py +++ b/apps/api/app/routers/media.py @@ -1,15 +1,22 @@ from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse -from ..media_catalog import published_assets, published_file +from ..media_catalog import KNOWN_MEDIA_ROLES, published_assets, published_file router = APIRouter() @router.get("/api/v1/media/catalog") -def media_catalog(entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$")) -> list[dict]: - return published_assets(entity_type) +def media_catalog( + entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"), + media_role: str | None = Query(None, pattern="^(waterbody_cover|waterbody_map|waterbody_depth_map|waterbody_screenshot|tackle_card|tackle_detail|rig_diagram|tackle_screenshot)$"), +) -> list[dict]: + if media_role and entity_type not in {"waterbody", "tackle"}: + raise HTTPException(status_code=422, detail="media_role requires waterbody or tackle entity_type") + if media_role and media_role not in KNOWN_MEDIA_ROLES: + raise HTTPException(status_code=422, detail="unknown media role") + return published_assets(entity_type, media_role) @router.get("/api/v1/media/assets/{digest}", response_class=FileResponse) diff --git a/apps/web/src/pages/media.astro b/apps/web/src/pages/media.astro index f8651a8..cfae657 100644 --- a/apps/web/src/pages/media.astro +++ b/apps/web/src/pages/media.astro @@ -7,8 +7,11 @@ import { api, type MediaAsset } from "../lib/api"; const allowed = new Set(["fish", "waterbody", "tackle", "reference"]); const requested = Astro.url.searchParams.get("type") ?? "fish"; const type = allowed.has(requested) ? requested : "fish"; +const requestedRole = Astro.url.searchParams.get("role") ?? ""; +const roleOptions: Record> = { waterbody: [["waterbody_cover", "Заставки"], ["waterbody_map", "Карты"], ["waterbody_depth_map", "Карты глубин"], ["waterbody_screenshot", "Скриншоты"]], tackle: [["tackle_card", "Карточки снастей"], ["tackle_detail", "Детали снастей"], ["rig_diagram", "Схемы монтажей"], ["tackle_screenshot", "Скриншоты снастей"]] }; +const selectedRole = (roleOptions[type] ?? []).some(([value]) => value === requestedRole) ? requestedRole : ""; let assets: MediaAsset[] = [], unavailable = false; -try { assets = await api(`/api/v1/media/catalog?entity_type=${type}`); } catch { unavailable = true; } +try { const query = new URLSearchParams({ entity_type: type }); if (selectedRole) query.set("media_role", selectedRole); assets = await api(`/api/v1/media/catalog?${query}`); } catch { unavailable = true; } if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); @@ -20,6 +23,7 @@ const labels: Record = {fish:"Рыбы",waterbody:"Водоёмы
+ {roleOptions[type]?.length > 0 &&
{selectedRole && Сбросить}
} {unavailable ? : assets.length ?
{assets.map(asset =>

{asset.label ?? "Без подписи"}

{labels[asset.entity_type]}
)}
: }
diff --git a/apps/web/src/styles/media-catalog.css b/apps/web/src/styles/media-catalog.css index 6b60ea1..5a96986 100644 --- a/apps/web/src/styles/media-catalog.css +++ b/apps/web/src/styles/media-catalog.css @@ -1,4 +1,6 @@ .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}} +.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%}} + @media (min-width:721px) and (max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,minmax(0,1fr))}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d055c77..40bef1f 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 для будущих ролей; 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, 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. ### Тёмная тема