feat: separate waterbody media roles
This commit is contained in:
@@ -8,7 +8,7 @@ import StatePanel from "../../components/StatePanel.astro";
|
||||
import DataPassport from "../../components/DataPassport.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
import { findMediaByLabel, mediaRoleLabel } from "../../lib/media";
|
||||
import { safeHttpUrl } from "../../lib/urls";
|
||||
const { slug } = Astro.params;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
@@ -25,14 +25,16 @@ if (unavailable) {
|
||||
}
|
||||
if (!water && !unavailable) Astro.response.status = 404;
|
||||
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
|
||||
const image = water ? findMediaByLabel(media, water.name_ru, ["waterbody_cover", "waterbody_map", "waterbody_depth_map"]) : undefined;
|
||||
const coverImage = water ? findMediaByLabel(media, water.name_ru, ["waterbody_cover"]) : undefined;
|
||||
const supportingMedia = water ? (["waterbody_map", "waterbody_depth_map"] as const).map(role => findMediaByLabel(media, water!.name_ru, [role])).filter((asset): asset is MediaAsset => Boolean(asset)) : [];
|
||||
const sourceHref = safeHttpUrl(water?.source_url);
|
||||
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
||||
---
|
||||
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
|
||||
{coverImage && <section class="entity-feature content-grid" aria-label={`Заставка: ${water!.name_ru}`}><EntityMedia asset={coverImage} sourceLink /><div><span class="overline">{mediaRoleLabel("waterbody_cover")}</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
|
||||
{supportingMedia.length > 0 && <section class="waterbody-media-grid content-grid" aria-label={`Карты: ${water!.name_ru}`}>{supportingMedia.map(asset => <article><span class="overline">{mediaRoleLabel(asset.media_role ?? "")}</span><EntityMedia asset={asset} sourceLink /></article>)}</section>}
|
||||
{water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) && <section class="data-passport content-grid" aria-label="Паспорт водоёма"><div><span class="overline">Канонические сведения</span><h2>{water.name_ru}</h2>{water.description && <p>{water.description}</p>}{water.unlock_level !== undefined && water.unlock_level !== null && <p>Открывается с уровня: <strong>{water.unlock_level}</strong></p>}{water.source_fish_species?.length && <p>В карточке источника указано видов рыб: <strong>{water.source_fish_species.length}</strong>.</p>}{water.source_aliases?.length && <p>Алиасы источника: {water.source_aliases.join(", ")}.</p>}</div><div><span class="overline">Источник</span>{sourceHref ? <p><a href={sourceHref} rel="noreferrer">{water.source_system ?? "Внешний источник"} · исходная карточка</a></p> : <p>Источник для описания ещё не подтверждён.</p>}{water.source_point_urls?.length && <p>Сохранено ссылок на точки: <strong>{water.source_point_urls.length}</strong>.</p>}{water.source_image_urls?.length && <p>Изображений-кандидатов: <strong>{water.source_image_urls.length}</strong>; публикация требует отдельной проверки.</p>}{water.source_checked_at && <p>Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}</p>}</div></section>}
|
||||
{water && <DataPassport sources={water.source_system ? [water.source_system] : []} sourceUrl={water.source_url} observedAt={water.source_checked_at} status={water.source_checked_at ? "verified" : "unverified"}/>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel="72 часа"/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
|
||||
@@ -1,5 +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}}
|
||||
.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}}
|
||||
|
||||
.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%}}
|
||||
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@
|
||||
- [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника.
|
||||
- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. RF4MAP/RF4-STAT directory parsers теперь преобразуются в crosswalk identities без переноса метрик или автоматической привязки. Осталось подать полный набор реальных RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта.
|
||||
- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Исправлена spot detail: точность (`точные`/`приблизительные`/`район`/`не указаны`) теперь видна рядом с координатами и покрыта smoke regression, а `coordinate_sources` раскрываются внутри evidence-паспорта без потери provenance. Все public-компоненты используют единый `coordinatePrecisionLabel` с безопасным fallback и unit-регрессией четырёх состояний. In-app Chromium и focused smoke подтвердили `/spots/vyunok-321x654` на desktop/mobile без overflow; browser regression для отсутствующей точки проверяет недоступное состояние без ложных координат, а API regression параметризован по всем четырём precision-состояниям. Visual matrix теперь включает valid spot и четыре not-found/error-маршрута во всех 4 ширинах и 3 темах; остаётся отдельная production/browser QA для данных, которые не представлены локальным seed.
|
||||
- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Public media API теперь передаёт reviewed `media_role` и дополнительно отбрасывает unknown/cross-entity роли на границе публикации, waterbody detail принимает только `waterbody_cover`/`waterbody_map`/`waterbody_depth_map`, а UI подписывает карту/заставку/скриншот текстом. Осталось завершить browser QA для реальных role-вариантов и не показывать abstract fingerprint как внешнее изображение.
|
||||
- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Public media API теперь передаёт reviewed `media_role` и дополнительно отбрасывает unknown/cross-entity роли на границе публикации, waterbody detail принимает только `waterbody_cover`/`waterbody_map`/`waterbody_depth_map`, а UI раздельно показывает заставку, карту и карту глубин с текстовыми ролями и прямой атрибуцией. Осталось завершить browser QA для реальных role-вариантов и не показывать abstract fingerprint как внешнее изображение.
|
||||
- [ ] **W08 · Приёмка и эксплуатация.** Fixture-based parser tests, offline catalog/media audit и повторный idempotent import подтверждены: `created=0 updated=19`, в PostgreSQL ровно `19` RF4DB waterbodies плюс `2` legacy-записи, media audit сообщает `issues=[]` и `orphaned_files=[]`. In-app Chromium и focused smoke покрывают desktop/mobile основные страницы; 20.09 targeted waterbody E2E подтвердил detail с пустой активностью, not-found waterbody и not-found waterbody/fish pair, а spot regression — unavailable state без ложной координаты; публичные каталоги, media, records, spot/report/status, analytics и tackle detail имеют opt-in regression (`EXPECT_UNAVAILABLE=1`) для `503` + `Retry-After` + `no-store`, `noindex` и отсутствия утечки внутренних ошибок. После пересборки текущего Compose web-артефакта полный Playwright regression подтвердил `63 passed, 13 skipped` из `76` тестов, а Python suite — `229 passed, 1 skipped`; полный production/browser прогон остаётся обязательным финальным gate. Внешние detail-данные закрываются отдельно. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff.
|
||||
|
||||
### Каталог снастей, наживок и прочей оснастки
|
||||
|
||||
Reference in New Issue
Block a user