refactor: unify action hierarchy
This commit is contained in:
@@ -5,13 +5,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
<Layout title="Внешние источники — RF4 Spotter">
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Staging</span><h1>Внешние<br/><em>наблюдения</em></h1></div><p>Сопоставьте названия с каноническим каталогом. Опубликовать можно только запись с координатами и весом.</p></section>
|
||||
<section class="moderation-app" data-api-url={apiUrl}>
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button type="submit">Открыть очередь</button></form>
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||||
<p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
|
||||
<nav data-pages aria-label="Страницы очереди" hidden>
|
||||
<button type="button" data-previous>Предыдущая</button>
|
||||
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
|
||||
<span data-page-number aria-live="polite"></span>
|
||||
<button type="button" data-next>Следующая</button>
|
||||
<button data-action="secondary" type="button" data-next>Следующая</button>
|
||||
</nav>
|
||||
</section>
|
||||
<script>
|
||||
@@ -44,7 +44,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
if (next) next.disabled = rows.length <= 50;
|
||||
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
|
||||
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button type="button" data-suggest>Подсказать соответствия</button><button type="button" data-map>Сопоставить</button><button type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button class="reject" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
||||
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
||||
}
|
||||
async function changePage(delta: number) {
|
||||
const oldOffset = offset;
|
||||
|
||||
@@ -5,7 +5,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
<Layout title="Модерация уловов — RF4 Spotter">
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Очередь проверки</span><h1>Модерация<br/><em>уловов</em></h1></div><p>Проверьте данные и скриншот до того, как запись повлияет на публичную статистику.</p></section>
|
||||
<section class="moderation-app" data-api-url={apiUrl}>
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button type="submit">Открыть очередь</button></form>
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||||
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||
</section>
|
||||
@@ -27,7 +27,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const reports: Record<string, unknown>[] = await response.json();
|
||||
setLoading(false);
|
||||
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
|
||||
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button type="button" data-decision="approved">Одобрить</button><button class="reject" type="button" data-decision="rejected">Отклонить</button><button class="delete" type="button" data-delete>Удалить</button></div></div></article>`).join("");
|
||||
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`).join("");
|
||||
}
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
list?.addEventListener("click", async event => {
|
||||
|
||||
@@ -66,14 +66,14 @@ const datasetJsonLd = {
|
||||
<label class="filter-advanced-field">Период<select name="hours"><option value="6" selected={hours === "6"}>6 часов</option><option value="12" selected={hours === "12"}>12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72" selected={hours === "72"}>72 часа</option></select></label>
|
||||
<label class="filter-advanced-field">Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
|
||||
</div></details>
|
||||
<button>⌕ Найти клёв</button>
|
||||
<button data-action="primary">⌕ Найти клёв</button>
|
||||
</form></section>
|
||||
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
||||
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><FishingIcon name="lure" size={25}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||
</section>
|
||||
{signals.length > 0 && <SignalFeed signals={signals}/>}
|
||||
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
|
||||
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
|
||||
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
|
||||
<script>
|
||||
const advancedFields = document.querySelectorAll<HTMLElement>(".filter-advanced-field");
|
||||
|
||||
@@ -22,11 +22,11 @@ const last = runs[0];
|
||||
---
|
||||
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
|
||||
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
|
||||
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
|
||||
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
|
||||
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} из ${totalRecords} записей`} /></div>
|
||||
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
|
||||
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
|
||||
{!unavailable && items.length && offset + items.length < totalRecords && <a class="load-more" href={`/records?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё <span>{offset + items.length} из {totalRecords}</span> ↓</a>}
|
||||
{!unavailable && items.length && offset + items.length < totalRecords && <a class="load-more" data-action="secondary" href={`/records?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё <span>{offset + items.length} из {totalRecords}</span> ↓</a>}
|
||||
{!unavailable && !items.length && <StatePanel title="Рекорды ещё не импортированы" description="Для выбранных условий записей пока нет." actionHref="/records" actionLabel="Сбросить фильтры" />}
|
||||
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||
</Layout>
|
||||
|
||||
@@ -15,10 +15,10 @@ const reportId = Astro.url.searchParams.get("report_id");
|
||||
{state === "timeout" && <div class="notice error" id="form-error" tabindex="-1"><strong>Превышено время ожидания.</strong> Проверьте соединение и попробуйте снова.</div>}
|
||||
{state === "server_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Сервер временно недоступен.</strong> Попробуйте позже.</div>}
|
||||
{state === "screenshot_error" && <div class="notice warning"><strong>Заявка сохранена без скриншота.</strong> Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.</div>}
|
||||
{state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button type="submit">Загрузить скриншот</button></form>}
|
||||
{state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button data-action="inverse" type="submit">Загрузить скриншот</button></form>}
|
||||
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
|
||||
<fieldset><legend>Главное <span>обязательно</span></legend><div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" placeholder="Например, 72" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" placeholder="Например, 84" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" placeholder="1250" required /></label><label>Приманка<input name="bait_name" maxlength="200" placeholder="Название в игре" /></label></div><p class="field-help">Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.</p></fieldset>
|
||||
<details class="optional-fields"><summary>Дополнительные сведения <span>необязательно</span></summary><div class="form-grid"><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div><label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label></details><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><label class="consent"><input name="consent" type="checkbox" required /> <span>Я отправляю собственное наблюдение и принимаю <a href="/rules" target="_blank">правила</a> и <a href="/privacy" target="_blank">политику конфиденциальности</a>.</span></label><button type="submit">Отправить на проверку</button>
|
||||
<details class="optional-fields"><summary>Дополнительные сведения <span>необязательно</span></summary><div class="form-grid"><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div><label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label></details><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><label class="consent"><input name="consent" type="checkbox" required /> <span>Я отправляю собственное наблюдение и принимаю <a href="/rules" target="_blank">правила</a> и <a href="/privacy" target="_blank">политику конфиденциальности</a>.</span></label><button data-action="primary" type="submit">Отправить на проверку</button>
|
||||
</form>}
|
||||
<script is:inline define:vars={{ state }}>
|
||||
const form = document.querySelector(".report-form"); const key = "rf4-report-draft";
|
||||
|
||||
@@ -32,7 +32,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
||||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
||||
<a class="back" href="/">← Все активные точки</a>
|
||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||
<ActivityTimeline buckets={timeline}/>
|
||||
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
|
||||
|
||||
Reference in New Issue
Block a user