feat: add local fishing plan page
This commit is contained in:
@@ -15,6 +15,7 @@ import "../styles/pagination.css";
|
||||
import "../styles/dashboard-polish.css";
|
||||
import "../styles/loading-states.css";
|
||||
import "../styles/query-context.css";
|
||||
import "../styles/plan.css";
|
||||
import "../styles/theme.css";
|
||||
import "../styles/media-catalog.css";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
@@ -122,7 +123,7 @@ Astro.response.headers.set("Content-Security-Policy", [
|
||||
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/media") || path.startsWith("/admin/media")}} aria-current={path.startsWith("/media") || path.startsWith("/admin/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path === "/plan"}} aria-current={path === "/plan" ? "page" : undefined} href="/plan"><FishingIcon name="pin"/> <span>Мой план</span></a><a class:list={{active:path.startsWith("/media") || path.startsWith("/admin/media")}} aria-current={path.startsWith("/media") || path.startsWith("/admin/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<div class="header-tools">
|
||||
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
||||
<div class="theme-switcher" role="group" aria-label="Цветовая тема">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
---
|
||||
<Layout title="Мой план рыбалки — RF4 Spotter" description="Сохранённые точки рыбалки RF4 без аккаунта: координаты, наживки, свежесть и доверие." noindex>
|
||||
<section class="plan-hero content-grid"><div><span class="eyebrow">Локально на этом устройстве</span><h1>Мой план<br/><em>рыбалки.</em></h1><p>Соберите до пяти точек, сравните их перед выездом и распечатайте список. Данные не отправляются на сервер.</p></div><div class="plan-hero__mark" aria-hidden="true">♢</div></section>
|
||||
<section class="plan-toolbar content-grid" aria-label="Действия с планом"><span data-plan-count>0 из 5 точек</span><div><button data-action="secondary" type="button" data-plan-print>Печать / PDF</button><button data-action="quiet" type="button" data-plan-clear>Очистить план</button></div></section>
|
||||
<section class="plan-content content-grid" data-plan-page aria-live="polite"><div class="plan-grid" data-plan-list></div><div class="state" data-plan-empty><h2>План пока пуст</h2><p>Сохраните точку на её detail-странице — она появится здесь.</p><a data-action="secondary" href="/">Посмотреть горячие точки</a></div></section>
|
||||
</Layout>
|
||||
<script>
|
||||
const planStorageKey = "rf4spotter:fishing-plan";
|
||||
const list = document.querySelector<HTMLElement>("[data-plan-list]");
|
||||
const empty = document.querySelector<HTMLElement>("[data-plan-empty]");
|
||||
const count = document.querySelector<HTMLElement>("[data-plan-count]");
|
||||
const readPlan = (): Array<Record<string, string>> => {
|
||||
try { const value = JSON.parse(localStorage.getItem(planStorageKey) || "[]"); return Array.isArray(value) ? value : []; }
|
||||
catch { return []; }
|
||||
};
|
||||
const render = () => {
|
||||
if (!list || !empty) return;
|
||||
const plan = readPlan();
|
||||
list.replaceChildren();
|
||||
empty.hidden = plan.length > 0;
|
||||
if (count) count.textContent = `${plan.length} из 5 точек`;
|
||||
plan.forEach(item => {
|
||||
const card = document.createElement("article"); card.className = "plan-card";
|
||||
const heading = document.createElement("h2"); heading.textContent = item.waterbody || "Водоём не указан";
|
||||
const coordinate = document.createElement("strong"); coordinate.textContent = item.coordinates || "Координаты не указаны";
|
||||
const meta = document.createElement("p"); meta.textContent = item.baits ? `Наживка: ${item.baits.split("|").join(", ")}` : "Наживка не указана";
|
||||
const facts = document.createElement("small"); facts.textContent = [item.freshness ? "Свежесть сохранена" : "Свежесть не указана", item.confidence ? `Доверие: ${item.confidence}%` : "Доверие не рассчитано"].join(" · ");
|
||||
const link = document.createElement("a"); link.href = item.key || "/"; link.textContent = "Открыть точку";
|
||||
const remove = document.createElement("button"); remove.type = "button"; remove.dataset.action = "quiet"; remove.textContent = "Убрать"; remove.addEventListener("click", () => { localStorage.setItem(planStorageKey, JSON.stringify(readPlan().filter(entry => entry.key !== item.key))); render(); });
|
||||
const actions = document.createElement("div"); actions.className = "plan-card__actions"; actions.append(link, remove);
|
||||
card.append(heading, coordinate, meta, facts, actions); list.append(card);
|
||||
});
|
||||
};
|
||||
document.querySelector<HTMLButtonElement>("[data-plan-print]")?.addEventListener("click", () => window.print());
|
||||
document.querySelector<HTMLButtonElement>("[data-plan-clear]")?.addEventListener("click", () => { localStorage.removeItem(planStorageKey); render(); });
|
||||
render();
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
.plan-hero{padding:70px 0 45px;display:flex;justify-content:space-between;align-items:center;gap:40px}.plan-hero h1{margin:18px 0 20px;font:400 clamp(58px,7vw,100px)/.88 Georgia,serif;letter-spacing:-.06em}.plan-hero h1 em{color:#497074;font-style:normal}.plan-hero p{max-width:620px;color:var(--text-muted);font-size:17px;line-height:1.6}.plan-hero__mark{width:150px;height:150px;display:grid;place-items:center;border:1px solid #345155;border-radius:50%;background:var(--deep);color:var(--lime);font-size:76px;transform:rotate(45deg)}.plan-toolbar{display:flex;justify-content:space-between;align-items:center;padding:18px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);color:var(--text-muted);font-size:13px}.plan-toolbar>div{display:flex;gap:8px}.plan-content{padding:30px 0 100px}.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:14px}.plan-card{display:flex;flex-direction:column;gap:8px;min-height:220px;padding:22px;border:1px solid var(--border-soft);border-radius:15px;background:var(--surface);box-shadow:0 12px 30px var(--shadow-color)}.plan-card h2{margin:0;font:400 27px Georgia,serif}.plan-card>strong{font:400 22px Georgia,serif;color:var(--success)}.plan-card p{margin:5px 0 0;color:var(--text-muted);font-size:13px}.plan-card small{color:var(--text-subtle);font-size:11px}.plan-card__actions{display:flex;align-items:center;gap:15px;margin-top:auto}.plan-card__actions a{color:var(--text);font-size:13px;text-underline-offset:3px}.plan-card__actions button{border:0;background:none;color:var(--text-muted);font-size:12px;text-decoration:underline;text-underline-offset:3px}.plan-content>.state[hidden]{display:none!important}@media(max-width:720px){.plan-hero{padding:36px 0 25px}.plan-hero__mark{width:90px;height:90px;font-size:46px}.plan-toolbar{display:block}.plan-toolbar>div{margin-top:12px;flex-wrap:wrap}.plan-content{padding-top:20px}}@media print{.topbar,.alpha-banner,footer,.plan-hero__mark,.plan-toolbar button,.plan-card__actions button{display:none!important}.plan-hero{padding:0 0 20px}.plan-content{padding-top:0}.plan-card{break-inside:avoid;box-shadow:none}}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("saved spot appears on the plan page and can be removed or cleared", async ({ page }) => {
|
||||
await page.goto("/spots/vyunok-6331x6332");
|
||||
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", freshness: "2026-09-20T12:55:11.236309Z", confidence: "24" }])));
|
||||
await page.goto("/plan");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Вьюнок" })).toBeVisible();
|
||||
await expect(page.getByText("6331:6332")).toBeVisible();
|
||||
await expect(page.getByText("1 из 5 точек")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Убрать" }).click();
|
||||
await expect(page.getByRole("heading", { name: "План пока пуст" })).toBeVisible();
|
||||
|
||||
await page.goto("/spots/vyunok-6331x6332");
|
||||
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", freshness: "", confidence: "" }])));
|
||||
await page.goto("/plan");
|
||||
await page.getByRole("button", { name: "Очистить план" }).click();
|
||||
await expect(page.getByRole("heading", { name: "План пока пуст" })).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user