Files
rf4-spotter/apps/web/src/pages/report.astro
T

63 lines
7.8 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import Layout from "../layouts/Layout.astro";
import { api, type DictionaryItem } from "../lib/api";
let fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
const state = Astro.url.searchParams.get("state");
const reportId = Astro.url.searchParams.get("report_id");
---
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки.">
<span id="report-state" data-state={state ?? ""} hidden></span>
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Около минуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
{state === "create_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Заявка не создана.</strong> Проверьте поля; введённые значения сохранены.</div>}
{state === "rate_limited" && <div class="notice error" id="form-error" tabindex="-1"><strong>Слишком много отправок.</strong> Подождите 10 минут перед повторной попыткой.</div>}
{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 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 data-action="primary" type="submit">Отправить на проверку</button>
</form>}
<script>
const form = document.querySelector<HTMLFormElement>(".report-form");
const state = document.querySelector<HTMLElement>("#report-state")?.dataset.state ?? "";
const key = "rf4-report-draft";
// A05: Safe sessionStorage access with error handling
const safeStorage = {
getItem: (k: string) => { try { return sessionStorage.getItem(k); } catch { return null; } },
setItem: (k: string, v: string) => { try { sessionStorage.setItem(k, v); } catch {} },
removeItem: (k: string) => { try { sessionStorage.removeItem(k); } catch {} },
};
// A05: Restore draft on all error states that don't destroy the submission
const recoverableStates = ["create_error", "rate_limited", "server_error", "timeout"];
if (form && recoverableStates.includes(state)) {
try {
const draft = JSON.parse(safeStorage.getItem(key) || "{}");
for (const [name, value] of Object.entries(draft)) {
const field = form.elements.namedItem(name);
if (field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement) {
field.value = String(value);
}
}
} catch {}
document.querySelector<HTMLElement>("#form-error")?.focus();
}
if (state === "sent" || state === "screenshot_sent") safeStorage.removeItem(key);
form?.addEventListener("submit", () => {
const btn = form.querySelector<HTMLButtonElement>("button[type=submit]");
if (btn) { btn.disabled = true; btn.textContent = "Отправка..."; }
// A05: Save draft before submit, handle sessionStorage unavailable
const draft: Record<string, string> = {};
try {
for (const [name, value] of new FormData(form)) {
if (typeof value === "string" && name !== "website") draft[name] = value;
}
safeStorage.setItem(key, JSON.stringify(draft));
} catch {}
});
</script>
</Layout>