Build Dockerized MVP scaffold and records importer
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import type { Activity } from "../lib/api";
|
||||
import { ago, kg } from "../lib/api";
|
||||
const { item } = Astro.props as { item: Activity };
|
||||
---
|
||||
<article class="card">
|
||||
<div class="card-top"><div><span class="eyebrow">{item.waterbody}</span><h2>{item.fish}</h2></div><div class="score" aria-label={`Активность ${item.activity_score} из 100`}><strong>{item.activity_score}</strong><span>активность</span></div></div>
|
||||
<a class="coordinates" href={`/spots/${item.spot_id}`}>Точка {item.x}:{item.y} <span>→</span></a>
|
||||
<div class="bait"><span>Рабочая приманка</span><strong>{item.best_bait ?? "не указана"}</strong></div>
|
||||
<dl class="stats"><div><dt>Уловов</dt><dd>{item.catches}</dd></div><div><dt>Игроков</dt><dd>{item.unique_players}</dd></div><div><dt>Средний</dt><dd>{kg(item.average_weight_g)}</dd></div><div><dt>Максимум</dt><dd>{kg(item.max_weight_g)}</dd></div></dl>
|
||||
<p class="explanation">{item.explanation}</p>
|
||||
<div class="fresh"><span class="pulse"></span> Обновлено {ago(item.last_confirmed_at)} · уверенность {item.confidence_score}/100</div>
|
||||
</article>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
const { title = "RF4 Spotter" } = Astro.props;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><title>{title}</title></head>
|
||||
<body>
|
||||
<header class="site-header"><a href="/" class="brand"><span>RF4</span> Spotter</a><nav><a href="/">Активность</a><a href="/records">Рекорды</a></nav><p>Свежие точки без догадок</p></header>
|
||||
<main><slot /></main>
|
||||
<footer>Неофициальный проект. Данные демонстрационные.</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
export type Activity = {
|
||||
spot_id: string; waterbody_slug: string; waterbody: string; fish_slug: string;
|
||||
fish: string; x: number; y: number; best_bait: string | null; catches: number;
|
||||
unique_players: number; average_weight_g: number; max_weight_g: number;
|
||||
last_confirmed_at: string; activity_score: number; confidence_score: number;
|
||||
explanation: string;
|
||||
};
|
||||
|
||||
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[] };
|
||||
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null };
|
||||
export type DictionaryItem = { id: string; slug: string; name_ru: string };
|
||||
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null };
|
||||
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
||||
|
||||
const base = import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||
|
||||
export async function api<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${base}${path}`);
|
||||
if (!response.ok) throw new Error(`API ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function kg(grams: number) { return `${(grams / 1000).toFixed(2)} кг`; }
|
||||
export function ago(value: string) {
|
||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
|
||||
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import ActivityCard from "../components/ActivityCard.astro";
|
||||
import { api, type Activity, type DictionaryItem } from "../lib/api";
|
||||
|
||||
const params = Astro.url.searchParams;
|
||||
const hours = params.get("hours") ?? "24";
|
||||
const waterbody = params.get("waterbody") ?? "";
|
||||
const fish = params.get("fish") ?? "";
|
||||
const sort = params.get("sort") ?? "activity";
|
||||
let items: Activity[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||
let unavailable = false;
|
||||
try {
|
||||
[items, fishes, waterbodies] = await Promise.all([
|
||||
api<Activity[]>(`/api/v1/activity?hours=${hours}&waterbody=${waterbody}&fish=${fish}&sort=${sort}`),
|
||||
api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")
|
||||
]);
|
||||
} catch { unavailable = true; }
|
||||
---
|
||||
<Layout title="Что клюёт сейчас — RF4 Spotter">
|
||||
<section class="hero"><div><span class="eyebrow">Сводка активности</span><h1>Что клюёт<br/><em>прямо сейчас</em></h1></div><p>Свежие подтверждения, рабочие приманки и честная оценка надёжности данных.</p></section>
|
||||
<form class="filters" method="get">
|
||||
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
|
||||
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
|
||||
<label>Период<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>Сначала<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>
|
||||
<button>Показать</button>
|
||||
</form>
|
||||
<div class="section-heading"><h2>Активные точки</h2><span>{items.length} комбинации</span></div>
|
||||
{unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте обновить страницу позже.</p></div> : items.length ? <div class="grid">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>За этот период данных нет</h2><p>Измените фильтры или выберите более длинный период.</p></div>}
|
||||
</Layout>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { api, kg, type ImportRun, type OfficialRecord } from "../lib/api";
|
||||
const params = Astro.url.searchParams;
|
||||
const fish = params.get("fish") ?? "";
|
||||
const waterbody = params.get("waterbody") ?? "";
|
||||
let records: OfficialRecord[] = [], runs: ImportRun[] = [], unavailable = false;
|
||||
try { [records, runs] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?fish=${fish}&waterbody=${waterbody}`), api<ImportRun[]>("/api/v1/imports?limit=1")]); } catch { unavailable = true; }
|
||||
const last = runs[0];
|
||||
---
|
||||
<Layout title="Официальные рекорды — RF4 Spotter">
|
||||
<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>Slug рыбы<input name="fish" value={fish} placeholder="pike" /></label><label>Slug водоёма<input name="waterbody" value={waterbody} placeholder="vyunok" /></label><button>Фильтровать</button></form>
|
||||
<div class="section-heading"><h2>Последние записи</h2><span>{records.length} показано</span></div>
|
||||
{unavailable ? <div class="state"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата</span></div>{records.map(record => <article class="record-row"><strong>{record.fish}</strong><strong>{kg(record.weight_g)}</strong><span>{record.waterbody}</span><span>{record.bait ?? "—"}</span><span>{record.player_name ?? "—"}</span><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state"><h2>Рекорды ещё не импортированы</h2><p>Запустите <code>python -m app.cli import-records</code>. Пустой результат не подменяется демоданными.</p></div>}
|
||||
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||
</Layout>
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { api, kg, type Catch, type Spot } from "../../lib/api";
|
||||
const { id } = Astro.params;
|
||||
let spot: Spot | null = null, catches: Catch[] = [], unavailable = false;
|
||||
try { [spot, catches] = await Promise.all([api<Spot>(`/api/v1/spots/${id}`), api<Catch[]>(`/api/v1/spots/${id}/catches`)]); } catch { unavailable = true; }
|
||||
---
|
||||
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
|
||||
<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></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></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>
|
||||
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div><div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div></div><aside><span class="eyebrow">Лучшие приманки</span><ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol><p class="note">Статистика построена только по одобренным демо-наблюдениям.</p></aside></section>
|
||||
</>}
|
||||
</Layout>
|
||||
@@ -0,0 +1,22 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Unbounded:wght@600;700&display=swap');
|
||||
:root { color-scheme: dark; --bg:#07110f; --panel:#0d1c18; --line:#20332d; --ink:#f2f4e9; --muted:#93a69f; --accent:#d7f45b; --orange:#ff985a; font-family:Manrope,system-ui,sans-serif; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:radial-gradient(circle at 15% -10%,#173b30 0,transparent 30rem),var(--bg); color:var(--ink); min-height:100vh; }
|
||||
a { color:inherit; }
|
||||
.site-header, main, footer { width:min(1180px,calc(100% - 40px)); margin:auto; }
|
||||
.site-header { height:86px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
|
||||
.brand { text-decoration:none; font-family:Unbounded,sans-serif; font-weight:700; font-size:19px; }.brand span{color:var(--accent)}
|
||||
.site-header nav{display:flex;gap:24px}.site-header nav a{font-size:13px;color:#bdcbc6;text-decoration:none}.site-header nav a:hover{color:var(--accent)}
|
||||
.site-header p, footer { color:var(--muted); font-size:13px; } footer{padding:45px 0 30px}
|
||||
.hero { padding:78px 0 55px; display:grid; grid-template-columns:1.4fr .6fr; align-items:end; gap:30px; }
|
||||
.hero h1,.spot-hero h1 { font:700 clamp(42px,7vw,86px)/.98 Unbounded,sans-serif; letter-spacing:-.055em; margin:14px 0 0; }.hero h1 em{color:var(--accent);font-style:normal}.hero>p{font-size:18px;line-height:1.65;color:#b9c6c1;max-width:420px}
|
||||
.eyebrow { color:var(--accent); font-size:11px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; }
|
||||
.filters { padding:18px; background:#0b1915cc; border:1px solid var(--line); display:grid; grid-template-columns:repeat(4,1fr) auto; gap:12px; border-radius:16px; position:sticky; top:10px; z-index:2; backdrop-filter:blur(16px); }
|
||||
label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em; } select { display:block; width:100%; margin-top:7px; border:0; color:var(--ink); background:#14251f; padding:12px; border-radius:8px; font:600 14px Manrope; } button{align-self:end;border:0;border-radius:8px;background:var(--accent);color:#102015;font-weight:800;padding:13px 24px;cursor:pointer}
|
||||
.section-heading { display:flex; align-items:center; justify-content:space-between; margin:50px 0 20px; }.section-heading h2{font:600 22px Unbounded;margin:0}.section-heading span{color:var(--muted);font-size:13px}
|
||||
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:18px; }.card{padding:28px;background:linear-gradient(145deg,#10231dcf,#0a1714);border:1px solid var(--line);border-radius:18px}.card-top{display:flex;justify-content:space-between;gap:20px}.card h2{font:700 30px Unbounded;margin:8px 0}.score{text-align:center;background:#182b22;border-radius:50%;width:82px;height:82px;display:flex;flex-direction:column;justify-content:center;flex:none}.score strong{font:700 27px Unbounded;color:var(--accent)}.score span{font-size:8px;text-transform:uppercase;color:var(--muted)}
|
||||
.coordinates{display:flex;justify-content:space-between;background:var(--accent);color:#0b1713;padding:14px 17px;border-radius:9px;text-decoration:none;font-weight:800;margin:20px 0}.bait{display:flex;flex-direction:column;gap:5px}.bait span,.fresh{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;border-block:1px solid var(--line);padding:18px 0;margin:20px 0}.stats div{display:flex;flex-direction:column-reverse}.stats dt{font-size:10px;color:var(--muted)}.stats dd{font-weight:700;margin:0 0 3px}.explanation{min-height:48px;color:#bac8c3;font-size:13px;line-height:1.6}.pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px}.state{padding:50px;border:1px dashed #385148;text-align:center;border-radius:16px;color:var(--muted)}.state h1,.state h2{color:var(--ink)}
|
||||
.back{display:inline-block;margin:45px 0 25px;color:var(--muted);text-decoration:none}.spot-hero{display:flex;justify-content:space-between;align-items:center;padding:40px;background:linear-gradient(130deg,#142c24,#0b1714);border:1px solid var(--line);border-radius:20px}.spot-hero h1{font-size:clamp(36px,6vw,72px)}.spot-hero p{color:var(--muted)}.pin{font:700 40px Unbounded;color:var(--accent);border:1px solid #38523e;border-radius:50%;width:170px;height:170px;display:grid;place-content:center}.pin span{color:var(--orange)}.periods{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin:20px 0;border-radius:14px;overflow:hidden}.periods div{background:var(--panel);padding:25px;text-align:center}.periods strong{display:block;font:700 32px Unbounded;color:var(--accent)}.periods span{font-size:12px;color:var(--muted)}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:22px}.catch-list article{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding:16px 2px}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{color:var(--muted);font-size:12px;margin-top:4px}aside{background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:25px;margin-top:50px}aside li{padding:11px 0;border-bottom:1px solid var(--line)}.note{font-size:12px;line-height:1.6;color:var(--muted)}
|
||||
@media(max-width:800px){.site-header p{display:none}.hero{grid-template-columns:1fr;padding-top:50px}.filters{position:static;grid-template-columns:1fr 1fr}.filters button{grid-column:1/-1}.grid,.detail-grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.spot-hero{padding:25px}.pin{display:none}.periods div{padding:18px 8px}.periods strong{font-size:24px}}
|
||||
@media(max-width:480px){.site-header,main,footer{width:min(100% - 24px,1180px)}.filters{grid-template-columns:1fr}.card{padding:20px}.hero h1{font-size:40px}.periods span{font-size:10px}}
|
||||
.records-hero{padding:70px 0 45px;display:flex;align-items:end;justify-content:space-between;gap:30px}.records-hero h1{font:700 clamp(42px,7vw,78px)/1 Unbounded;margin:15px 0 0;letter-spacing:-.05em}.records-hero h1 em{color:var(--accent);font-style:normal}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px;align-items:center;color:#c6d1cd}.source-status small{grid-column:2;color:var(--muted)}.status-dot{width:9px;height:9px;border-radius:50%;background:#66736e}.status-dot.success{background:var(--accent)}.status-dot.failed{background:#ff6f61}.record-filters{display:flex;gap:12px;padding:18px;border:1px solid var(--line);background:var(--panel);border-radius:14px}.record-filters label{flex:1}.record-filters input{display:block;width:100%;margin-top:7px;padding:12px;border:0;border-radius:8px;background:#14251f;color:var(--ink)}.record-table{border:1px solid var(--line);border-radius:14px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:16px 18px;border-bottom:1px solid var(--line);align-items:center}.record-row:last-child{border:0}.record-row span,.record-row time{font-size:13px;color:#aebdb7}.record-head{background:#14251f;text-transform:uppercase;letter-spacing:.07em}.record-head span{font-size:10px;color:var(--muted)}.official-note{color:var(--muted);font-size:12px;margin-top:20px}.official-note a{color:#bccf61}@media(max-width:800px){.site-header nav{gap:12px}.records-hero{display:block}.source-status{margin-top:30px}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}}
|
||||
Reference in New Issue
Block a user