feat: polish

This commit is contained in:
Anatoly Antonov 2026-04-22 01:27:38 +09:00
parent 2e6177fe74
commit 4bd927bb4e
137 changed files with 6357 additions and 137560 deletions

22
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,22 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { initI18n } from '$i18n';
import { authStore } from '$auth/store';
import ToastContainer from '$ui/Toast.svelte';
let { children } = $props();
let ready = $state(false);
onMount(async () => {
await initI18n();
await authStore.refresh().catch(() => {});
ready = true;
});
</script>
{#if ready}
{@render children?.()}
{/if}
<ToastContainer />

5
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,5 @@
// Pure SPA: all rendering happens in the browser against a Django REST backend.
// Disable SSR and prerender globally so every route is just a client-side chunk.
export const ssr = false;
export const prerender = false;
export const trailingSlash = 'ignore';

View file

@ -1,7 +1,22 @@
<script>
import Navbar from '$lib/components/Navbar.svelte';
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$auth';
onMount(() => {
// Root route funnels users into the predict workspace by default. Once
// we have a proper landing page this can render marketing content
// instead of redirecting.
const unsub = authStore.subscribe((state) => {
if (state.status === 'authenticated') goto('/predict');
else if (state.status === 'anonymous') goto('/login');
});
return () => unsub();
});
</script>
<main>
<Navbar />
</main>
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>

View file

@ -1 +0,0 @@
export const ssr =false;

View file

@ -1,97 +1,5 @@
<script>
import { goto } from '$app/navigation';
import { login } from '$lib/auth';
let username = '';
let password = '';
let error = '';
let isLoading = false;
async function handleLogin() {
if (!username || !password) {
error = 'Please enter both username and password';
return;
}
isLoading = true;
error = '';
console.log("Sending request:", username, password);
// login request
try {
await login(username, password);
goto('/'); // Redirect after successful login
} catch (err) {
if (err instanceof Error) {
error = err.message || 'Invalid credentials';
} else {
error = 'Invalid credentials';
}
} finally {
isLoading = false;
}
}
<script lang="ts">
import { LoginForm } from '$features/auth';
</script>
<main class="container pt-3">
<div class="text-center mt-5 mb-4">
<img src="/logo-lg.svg" alt="ООО ЯКС" width="300" class="rounded-3" />
<h2 class="text-center mt-4 mb-5">Стратосферные полеты | ООО ЯКС</h2>
</div>
<div class="row">
<div class="col-12 col-md-6 col-lg-4 offset-md-3 offset-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Вход в учетную запись</h5>
{#if error}
<div class="alert alert-danger mb-4" role="alert">{error}</div>
{/if}
<form on:submit|preventDefault={handleLogin} class="mt-4">
<div class="form-floating mb-3">
<input
type="text"
class="form-control"
id="username"
placeholder="Имя пользователя"
bind:value={username}
required
/>
<label for="username">Имя пользователя</label>
</div>
<div class="form-floating mb-3">
<input
type="password"
class="form-control"
id="password"
placeholder="Пароль"
bind:value={password}
required
/>
<label for="password">Пароль</label>
</div>
<button
type="submit"
class="btn btn-primary w-100"
disabled={isLoading}
>
{#if isLoading}
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Вход...
{:else}
Войти
{/if}
</button>
<a href="/" class="btn btn-secondary mt-3 w-100">Назад</a>
</form>
</div>
</div>
</div>
</div>
</main>
<LoginForm />

View file

@ -1 +0,0 @@
export const ssr =false;

View file

@ -1,130 +1,113 @@
<script lang="ts">
import Map from "$lib/components/Map.svelte";
import ControlPanel from "$lib/components/ControlPanel.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import PanelContainer from "$lib/components/PanelContainer.svelte";
import ScenarioPanel from "$lib/components/ScenarioPanel.svelte";
import TabComponent from "$lib/components/ui/TabComponent.svelte";
import PointEditor from "$lib/components/editors/PointEditor.svelte";
import { onMount } from "svelte";
import { PredictionStore } from "$lib/stores";
import { addToast, removeToast } from "$lib/components/ui/Toast.svelte";
import ToastContainer from '$lib/components/ui/Toast.svelte';
import GenericPanel from "$lib/components/GenericPanel.svelte";
import TimeLine from "$lib/components/TimeLine.svelte";
import { onMount } from 'svelte';
import { Map as MapView } from '$map';
import type { IMap } from '$map';
import { startCoordinateSelection } from '$map';
import { Navbar } from '$features/auth';
import { PanelContainer, TabBar } from '$ui';
import { addToast, removeToast } from '$ui';
import { ControlPanel, ScenarioPanel } from '$features/prediction';
import {
WorkspacesPanel,
WorkspaceRenderer,
workspacesStore,
} from '$features/workspaces';
import { SettingsPanel } from '$features/settings';
import { TimeLine } from '$features/timeline';
import { t } from '$i18n';
import { requireAuthenticated } from '$auth';
let map: Map | null = null;
let panelContainer: PanelContainer | null = null;
let controlPanel: ControlPanel | null = null;
let selectionToastId: string | null = null;
let activeTabLeft: 'control' | 'scenario' | 'about' = 'scenario';
let activeTabRight: 'layers' | 'settings' | 'results' = 'results';
type LeftTab = 'scenario' | 'conditions' | 'about';
type RightTab = 'results' | 'workspaces' | 'settings';
onMount(() => {
PredictionStore.subscribe((data) => {
if (data) {
map?.clearMapLayers();
}
});
console.log("ControlPanel mounted");
console.log(panelContainer);
let mapComponent = $state<MapView | null>(null);
let controlPanelRef = $state<ControlPanel | null>(null);
let selectionToastId: string | null = null;
let selectionDispose: (() => void) | null = null;
if (panelContainer) {
let element = panelContainer.getElement();
if (!element) return;
let leftTab = $state<LeftTab>('scenario');
let rightTab = $state<RightTab>('workspaces');
// Disable click and scroll propagation to prevent map interaction
element.addEventListener('click', (e) => e.stopPropagation());
element.addEventListener('dblclick', (e) => e.stopPropagation());
element.addEventListener('mousedown', (e) => e.stopPropagation());
element.addEventListener('touchstart', (e) => e.stopPropagation());
element.addEventListener('wheel', (e) => e.stopPropagation());
}
});
onMount(async () => {
const ok = await requireAuthenticated('/login');
if (!ok) return;
if ($workspacesStore.items.length === 0) {
workspacesStore.add({ name: $t('workspaces.defaultName', { n: 1 }) });
}
});
function handleClickSelectOnMap() {
if (map) {
map.startSelection();
console.log("Selection mode enabled");
if (!selectionToastId) {
selectionToastId = addToast({
header: "Режим выбора координат",
body: "Кликните на карту, чтобы выбрать координаты",
color: "info",
persistent: true,
onRemoveCallback: () => {
selectionToastId = null;
map?.stopSelection();
console.log("Selection mode disabled");
}
});
}
}
}
function handleMapReady(_map: IMap) {
// Map is ready; consumers inside <MapView /> already have access via context.
}
function handleCoordinateSelection(event: CustomEvent<{ lat: number; lng: number }>) {
const { lat, lng } = event.detail;
controlPanel?.updateLaunchPosition(lat, lng);
console.log(`Selected coordinates: ${lat}, ${lng}`);
if (selectionToastId) {
removeToast(selectionToastId);
selectionToastId = null;
}
}
function handleSelectOnMap() {
const map = mapComponent?.getInstance();
if (!map) return;
function handleTimeUpdate(event: CustomEvent<{ index: number; lat: number; lng: number; alt: number; datetime: Date }>) {
const { lat, lng } = event.detail;
map?.updateAnimatedMarker(lat, lng);
}
selectionDispose = startCoordinateSelection(map, ({ lat, lng }) => {
controlPanelRef?.updateLaunchPosition(lat, lng);
if (selectionToastId) {
removeToast(selectionToastId);
selectionToastId = null;
}
selectionDispose = null;
});
selectionToastId = addToast({
header: $t('selection.header'),
body: $t('selection.body'),
color: 'info',
persistent: true,
onRemove: () => {
selectionDispose?.();
selectionDispose = null;
selectionToastId = null;
},
});
}
</script>
<main>
<Navbar />
<div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar -->
<Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}>
<PanelContainer bind:this={panelContainer} position="left">
<TabComponent
tabs={[
{ id: 'scenario', icon: 'file-earmark-play', label: 'Сценарий' },
{ id: 'control', icon: 'sliders', label: 'Условия' },
{ id: 'about', icon: 'info-circle', label: 'О проекте' }
]}
bind:activeTab={activeTabLeft}
/>
<div>
{#if activeTabLeft === 'control'}
<ControlPanel onSelectOnMapClick={handleClickSelectOnMap} bind:this={controlPanel} />
{:else if activeTabLeft === 'scenario'}
<ScenarioPanel />
{:else if activeTabLeft === 'about'}
<!-- <AboutPanel /> -->
{/if}
</div>
</PanelContainer>
<PanelContainer position="right">
<TabComponent
justify="end"
tabs={[
{ id: 'results', icon: 'bar-chart-line', label: 'Результаты' },
{ id: 'layers', icon: 'layers', label: 'Слои' },
{ id: 'settings', icon: 'gear', label: 'Настройки' },
]}
bind:activeTab={activeTabRight}
/>
<div>
{#if activeTabRight === 'results'}
<GenericPanel />
{:else if activeTabRight === 'layers'}
<GenericPanel />
{/if}
</div>
</PanelContainer>
<ToastContainer />
{#if $PredictionStore}
<TimeLine prediction={$PredictionStore} on:timeUpdate={handleTimeUpdate} />
{/if}
</Map>
<Navbar />
<div style="height: var(--navbar-height);"></div>
<MapView bind:this={mapComponent} onReady={handleMapReady}>
<WorkspaceRenderer />
<PanelContainer position="left">
<TabBar
tabs={[
{ id: 'scenario', icon: 'file-earmark-play', label: $t('panel.scenario') },
{ id: 'conditions', icon: 'sliders', label: $t('panel.conditions') },
{ id: 'about', icon: 'info-circle', label: $t('panel.about') },
]}
bind:active={leftTab} />
<div>
{#if leftTab === 'scenario'}
<ScenarioPanel />
{:else if leftTab === 'conditions'}
<ControlPanel bind:this={controlPanelRef} onSelectOnMapClick={handleSelectOnMap} />
{/if}
</div>
</PanelContainer>
<PanelContainer position="right">
<TabBar
justify="end"
tabs={[
{ id: 'workspaces', icon: 'layers', label: $t('panel.workspaces') },
{ id: 'results', icon: 'bar-chart-line', label: $t('panel.results') },
{ id: 'settings', icon: 'gear', label: $t('panel.settings') },
]}
bind:active={rightTab} />
<div>
{#if rightTab === 'workspaces'}
<WorkspacesPanel />
{:else if rightTab === 'settings'}
<SettingsPanel />
{/if}
</div>
</PanelContainer>
<TimeLine />
</MapView>
</main>

View file

@ -1 +0,0 @@
export const ssr =false;

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,22 @@
<script>
import Map from '$lib/components/Map.svelte';
import TelemetryPanel from '$lib/components/TelemetryPanel.svelte';
import Navbar from '$lib/components/Navbar.svelte';
// import BurstCalculator from './BurstCalculator.svelte';
<script lang="ts">
import { onMount } from 'svelte';
import { Map as MapView } from '$map';
import { Navbar } from '$features/auth';
import { PanelContainer } from '$ui';
import { TelemetryPanel } from '$features/tracking';
import { requireAuthenticated } from '$auth';
let coordinates = {
lat: '56.3576',
lng: '39.8666'
}
onMount(() => {
requireAuthenticated('/login');
});
</script>
<main>
<Navbar />
<div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar -->
<Map>
<TelemetryPanel
/>
</Map>
</main>
<Navbar />
<div style="height: var(--navbar-height);"></div>
<MapView>
<PanelContainer position="left">
<TelemetryPanel />
</PanelContainer>
</MapView>
</main>

View file

@ -1 +0,0 @@
export const ssr =false;

View file

@ -1,267 +1,84 @@
<script lang="ts">
import Navbar from "$lib/components/Navbar.svelte";
import {
Card,
CardHeader,
CardBody,
Button,
FormGroup,
Label,
Input,
InputGroup,
InputGroupText,
Icon,
} from "@sveltestrap/sveltestrap";
import { onMount } from 'svelte';
import {
Card,
CardHeader,
CardBody,
Button,
FormGroup,
Label,
Input,
} from '@sveltestrap/sveltestrap';
import { Navbar } from '$features/auth';
import { Footer } from '$features/footer';
import { ConfirmationPrompt } from '$ui';
import { authStore, requireAuthenticated } from '$auth';
import { goto } from '$app/navigation';
import { t } from '$i18n';
import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte";
import Footer from "$lib/components/Footer.svelte";
let showConfirm = $state(false);
let confirmTitle = $state('');
let confirmBody = $state('');
let confirmAction = $state<() => void>(() => {});
let editMode = false;
let showToken = false;
onMount(async () => {
if (!(await requireAuthenticated('/login'))) return;
});
type ConfirmConfig = {
title: string;
body: string;
confirmText: string;
confirmVariant?: string;
onConfirm: () => void;
};
function ask(title: string, body: string, action: () => void) {
confirmTitle = title;
confirmBody = body;
confirmAction = action;
showConfirm = true;
}
// State for the single confirmation prompt
let showConfirm = false;
let confirmConfig: ConfirmConfig = {
title: "",
body: "",
confirmText: "",
confirmVariant: "primary",
onConfirm: () => {},
};
function openConfirmation(config: Partial<ConfirmConfig>) {
confirmConfig = { ...confirmConfig, ...config } as ConfirmConfig;
showConfirm = true;
}
function handleDeleteAccount() {
openConfirmation({
title: "Подтвердите удаление",
body: "Вы уверены, что хотите удалить свою учетную запись? Это действие необратимо.",
confirmText: "Удалить",
confirmVariant: "danger",
onConfirm: confirmDeleteAccount,
});
}
function handleResetSettings() {
openConfirmation({
title: "Подтвердите сброс",
body: "Вы уверены, что хотите сбросить учетную запись? Это также удалит все сохранные сценарии, шаблоны и точки запуска.",
confirmText: "Сбросить",
confirmVariant: "warning",
onConfirm: confirmResetSettings,
});
}
function handleGenerateToken() {
openConfirmation({
title: "Подтвердите создание токена",
body: "Генерация нового токена API приведет к прекращению действия старого токена. Приложения, использующие старый токен, перестанут работать. Вы уверены, что хотите создать новый токен?",
confirmText: "Создать",
confirmVariant: "primary",
onConfirm: confirmGenerateToken,
});
}
function handleLogout() {
openConfirmation({
title: "Подтвердите выход",
body: "Вы уверены, что хотите выйти из учетной записи? Вы будете перенаправлены на страницу входа.",
confirmText: "Выйти",
onConfirm: confirmLogout,
});
}
function confirmDeleteAccount() {
// Implement account deletion logic
console.log("Account deleted");
}
function confirmResetSettings() {
// Implement settings reset logic
console.log("Settings reset");
}
function confirmGenerateToken() {
// Implement token generation logic
console.log("New token generated");
}
function confirmLogout() {
// Implement logout logic
console.log("Logged out");
}
function handleConfirm() {
if (confirmConfig.onConfirm) {
confirmConfig.onConfirm();
}
showConfirm = false;
}
async function handleLogout() {
ask($t('nav.logout'), '', async () => {
await authStore.logout();
goto('/');
});
}
</script>
<main class="force-page-height">
<Navbar />
<div style="height: var(--navbar-height);"></div>
<!-- Spacer for fixed navbar -->
<div class="container my-4">
<div class="row">
<!-- Side Navigation -->
<div class="col-md-3 col-lg-2 mb-4">
<nav class="nav nav-pills flex-column">
<a class="nav-link active" href="/user/account">Учетная запись</a>
<a class="nav-link" href="/user/templates">Сохраненные сценарии</a>
<a class="nav-link" href="#api-tokens">История прогнозов</a>
<a class="nav-link" href="#actions">История слежения</a>
</nav>
</div>
<Navbar />
<div style="height: var(--navbar-height);"></div>
<div class="container my-4">
<div class="row">
<div class="col-md-3 col-lg-2 mb-4">
<nav class="nav nav-pills flex-column">
<a class="nav-link active" href="/user/account">{$t('nav.account')}</a>
<a class="nav-link" href="/user/templates">{$t('nav.scenarios')}</a>
<a class="nav-link" href="/user/predictions">{$t('nav.predictionHistory')}</a>
<a class="nav-link" href="/user/flights">{$t('nav.trackingHistory')}</a>
</nav>
</div>
<!-- Main Content -->
<div class="col-md-9 col-lg-10">
<!-- Account Information -->
<Card class="mb-4">
<CardHeader>
<h5 class="mb-0">Основная информация</h5>
</CardHeader>
<CardBody>
<div class="row">
<div class="col-md-6">
<FormGroup>
<Label for="username">Имя пользователя:</Label>
<Input id="username" value="user123" readonly disabled />
</FormGroup>
</div>
<div class="col-md-6">
<FormGroup>
<Label for="email">Email:</Label>
<Input
id="email"
type="email"
value="user@example.com"
readonly={!editMode}
disabled={!editMode}
/>
</FormGroup>
</div>
</div>
<FormGroup>
<Label for="fullname">Полное имя:</Label>
<Input id="fullname" value="Иван Иванов" readonly={!editMode} disabled={!editMode} />
</FormGroup>
{#if editMode}
<Button color="success" on:click={() => (editMode = false)}>Сохранить</Button>
<Button color="secondary" on:click={() => (editMode = false)}>Отменить</Button>
{:else}
<Button color="primary" on:click={() => (editMode = true)}>Редактировать</Button>
{/if}
</CardBody>
</Card>
<!-- Password Change -->
<Card class="mb-4">
<CardHeader>
<h5 class="mb-0">Смена пароля</h5>
</CardHeader>
<CardBody>
<FormGroup>
<Label for="currentPassword">Текущий пароль:</Label>
<Input id="currentPassword" type="password" />
</FormGroup>
<div class="row">
<div class="col-md-6">
<FormGroup>
<Label for="newPassword">Новый пароль:</Label>
<Input id="newPassword" type="password" />
</FormGroup>
</div>
<div class="col-md-6">
<FormGroup>
<Label for="confirmPassword">Повтор пароля:</Label>
<Input id="confirmPassword" type="password" />
</FormGroup>
</div>
</div>
<Button color="primary">Изменить пароль</Button>
</CardBody>
</Card>
<!-- API Token -->
<Card class="mb-4">
<CardHeader>
<h5 class="mb-0">Токен API</h5>
</CardHeader>
<CardBody>
<FormGroup>
<Label for="apiToken">Токен доступа</Label>
<InputGroup>
<div class="position-relative flex-grow-1">
<Input
id="apiToken"
class="form-control pe-5"
type={showToken ? "text" : "password"}
value="abc123def456..."
readonly
/>
<Button
size="sm"
color="white"
class="position-absolute top-50 end-0 translate-middle-y me-2 rounded-circle d-flex align-items-center justify-content-center"
style="width: 32px; height: 32px; border: none; color: var(--bs-secondary); z-index: 10;"
on:click={() => {
showToken = !showToken;
}}
>
<Icon name={showToken ? "eye-slash" : "eye"} style="font-size: 16px;" />
</Button>
</div>
<Button>
<Icon name="clipboard" />
</Button>
</InputGroup>
</FormGroup>
<Button color="warning" on:click={handleGenerateToken}>Сгенерировать новый токен</Button>
</CardBody>
</Card>
<!-- Account Actions -->
<Card>
<CardHeader>
<h5 class="mb-0">Действия с аккаунтом</h5>
</CardHeader>
<CardBody>
<div class="d-grid gap-2 d-md-flex">
<Button color="secondary" on:click={handleLogout}>Выйти</Button>
<!-- spacer -->
<span class="d-none d-md-inline-block flex-grow-1"></span>
<Button color="warning" on:click={handleResetSettings}>Сбросить настройки</Button>
<Button color="danger" on:click={handleDeleteAccount}>Удалить аккаунт</Button>
</div>
</CardBody>
</Card>
</div>
</div>
</div>
<Footer />
<div class="col-md-9 col-lg-10">
<Card class="mb-4">
<CardHeader><h5 class="mb-0">{$t('nav.account')}</h5></CardHeader>
<CardBody>
<FormGroup>
<Label>{$t('login.username')}</Label>
<Input value={$authStore.username ?? ''} readonly disabled />
</FormGroup>
<div class="d-grid gap-2 d-md-flex">
<Button color="secondary" on:click={handleLogout}>{$t('nav.logout')}</Button>
</div>
</CardBody>
</Card>
</div>
</div>
</div>
<Footer />
</main>
<!-- Single Dynamic Confirmation Prompt -->
<ConfirmationPrompt
bind:isOpen={showConfirm}
title={confirmConfig.title}
confirmText={confirmConfig.confirmText}
confirmVariant={confirmConfig.confirmVariant || "primary"}
cancelText="Отмена"
onconfirm={handleConfirm}
oncancel={() => (showConfirm = false)}
>
<p>{confirmConfig.body}</p>
bind:isOpen={showConfirm}
title={confirmTitle}
confirmText={$t('editor.save')}
cancelText={$t('editor.cancel')}
onconfirm={confirmAction}
oncancel={() => (showConfirm = false)}>
<p>{confirmBody}</p>
</ConfirmationPrompt>

View file

@ -1 +0,0 @@
export const ssr =false;

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Card, CardHeader, CardBody } from '@sveltestrap/sveltestrap';
import { Navbar } from '$features/auth';
import { Footer } from '$features/footer';
import { requireAuthenticated } from '$auth';
import { t } from '$i18n';
onMount(() => requireAuthenticated('/login'));
</script>
<main class="force-page-height">
<Navbar />
<div style="height: var(--navbar-height);"></div>
<div class="container my-4">
<div class="row">
<div class="col-md-3 col-lg-2 mb-4">
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="/user/account">{$t('nav.account')}</a>
<a class="nav-link" href="/user/templates">{$t('nav.scenarios')}</a>
<a class="nav-link" href="/user/predictions">{$t('nav.predictionHistory')}</a>
<a class="nav-link active" href="/user/flights">{$t('nav.trackingHistory')}</a>
</nav>
</div>
<div class="col-md-9 col-lg-10">
<Card>
<CardHeader><h5 class="mb-0">{$t('nav.trackingHistory')}</h5></CardHeader>
<CardBody>
<p class="text-muted small mb-0">TODO: wire to tracking history endpoint.</p>
</CardBody>
</Card>
</div>
</div>
</div>
<Footer />
</main>

View file

@ -1 +0,0 @@
export const ssr =false;

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Card, CardHeader, CardBody } from '@sveltestrap/sveltestrap';
import { Navbar } from '$features/auth';
import { Footer } from '$features/footer';
import { requireAuthenticated } from '$auth';
import { t } from '$i18n';
onMount(() => requireAuthenticated('/login'));
</script>
<main class="force-page-height">
<Navbar />
<div style="height: var(--navbar-height);"></div>
<div class="container my-4">
<div class="row">
<div class="col-md-3 col-lg-2 mb-4">
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="/user/account">{$t('nav.account')}</a>
<a class="nav-link" href="/user/templates">{$t('nav.scenarios')}</a>
<a class="nav-link active" href="/user/predictions">{$t('nav.predictionHistory')}</a>
<a class="nav-link" href="/user/flights">{$t('nav.trackingHistory')}</a>
</nav>
</div>
<div class="col-md-9 col-lg-10">
<Card>
<CardHeader><h5 class="mb-0">{$t('nav.predictionHistory')}</h5></CardHeader>
<CardBody>
<p class="text-muted small mb-0">TODO: wire to prediction history endpoint.</p>
</CardBody>
</Card>
</div>
</div>
</div>
<Footer />
</main>

View file

@ -1 +0,0 @@
export const ssr =false;

View file

@ -1,379 +1,145 @@
<script lang="ts">
import { onMount } from "svelte";
import { TableHandler } from "@vincjo/datatables";
import {
Card,
CardHeader,
CardBody,
Button,
Input,
Icon,
Pagination,
PaginationItem,
PaginationLink,
} from "@sveltestrap/sveltestrap";
import { onMount } from 'svelte';
import { TableHandler } from '@vincjo/datatables';
import {
Card,
CardHeader,
CardBody,
Button,
Input,
Icon,
Pagination,
PaginationItem,
PaginationLink,
} from '@sveltestrap/sveltestrap';
import { Navbar } from '$features/auth';
import { Footer } from '$features/footer';
import { ConfirmationPrompt, addToast } from '$ui';
import { PointEditor, pointsStore } from '$features/prediction';
import { pointsApi } from '$api';
import type { SavedPoint } from '$domain';
import { requireAuthenticated } from '$auth';
import { t } from '$i18n';
import Navbar from "$lib/components/Navbar.svelte";
import Footer from "$lib/components/Footer.svelte";
import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte";
import PointEditor from "$lib/components/editors/PointEditor.svelte";
import ToastContainer from "$lib/components/ui/Toast.svelte";
import { addToast } from "$lib/components/ui/Toast.svelte";
let editPoint = $state<SavedPoint | null>(null);
let toDelete = $state<SavedPoint | null>(null);
// TODO: Implement these imports
import { SavedPointsStore, SavedFlightProfilesStore, SavedScenarioStore } from "$lib/stores";
import { getSavedPoints, deletePoint } from "$lib/api/points";
import { getSavedFlightProfiles, deleteFlightProfile } from "$lib/api/profiles";
import { getSavedScenarios, deleteScenario } from "$lib/api/scenarios";
import type { SavedPoint, SavedFlightProfile, SavedScenario } from "$lib/types";
const table = $derived(new TableHandler($pointsStore, { rowsPerPage: 5 }));
const search = $derived(table.createSearch(['name']));
// Table handlers
let pointsTable = $derived(new TableHandler($SavedPointsStore, { rowsPerPage: 5 }));
let pointsSearch = $derived(pointsTable.createSearch(["name"]));
onMount(async () => {
if (!(await requireAuthenticated('/login'))) return;
try {
pointsStore.set(await pointsApi.list());
} catch (err: unknown) {
addToast({ header: $t('common.error'), body: (err as Error).message, color: 'danger' });
}
});
let profilesTable = $derived(new TableHandler($SavedFlightProfilesStore, { rowsPerPage: 5 }));
let profilesSearch = $derived(profilesTable.createSearch(["name"]));
let templatesTable = $derived(new TableHandler($SavedScenarioStore, { rowsPerPage: 5 }));
let templatesSearch = $derived(templatesTable.createSearch(["name"]));
let editPoint: SavedPoint | null = $state(null);
onMount(async () => {
// Mock data for demonstration. Replace with API calls.
const pts = await getSavedPoints();
$SavedPointsStore = pts;
SavedPointsStore.set($SavedPointsStore);
$SavedFlightProfilesStore = [
{ id: 1, name: "Standard Weather Balloon", rate_profile_data: {ascent_rate: 5, descent_rate: 8, burst_altitude: 30000} },
{ id: 2, name: "High Altitude Probe", rate_profile_data: {ascent_rate: 6, descent_rate: 10, burst_altitude: 40000} },
];
/*
// TODO: Uncomment when API is ready
const [points, profiles, templates] = await Promise.all([
getSavedPoints(),
getSavedFlightProfiles(),
getSavedScenarioTemplates()
]);
$SavedPointsStore = points;
$SavedFlightProfilesStore = profiles;
$SavedScenarioTemplatesStore = templates;
*/
});
// --- Confirmation Prompt Logic ---
type ConfirmConfig = {
title: string;
body: string;
confirmText: string;
confirmVariant?: string;
onConfirm: () => void;
};
let showConfirm = $state(false);
let confirmConfig = $state<ConfirmConfig>({
title: "",
body: "",
confirmText: "",
onConfirm: () => {},
});
function openConfirmation(config: Partial<ConfirmConfig>) {
confirmConfig = { ...confirmConfig, ...config } as ConfirmConfig;
showConfirm = true;
}
function handleConfirm() {
if (confirmConfig.onConfirm) {
confirmConfig.onConfirm();
}
showConfirm = false;
}
// --- Delete Handlers ---
function handleDelete<T extends { id: number; name: string }>(
item: T,
deleteFn: (id: number) => Promise<any>,
store: any,
itemName: string,
) {
openConfirmation({
title: `Подтвердите удаление`,
body: `Вы уверены, что хотите удалить ${itemName} "${item.name}"?`,
confirmText: "Удалить",
confirmVariant: "danger",
onConfirm: () => {
// deleteFn(item.id).then(() => { // TODO: Uncomment when API is ready
store.update((items: T[]) => items.filter((i) => i.id !== item.id));
addToast({
header: `${itemName} удален`,
body: `${itemName} "${item.name}" успешно удален.`,
color: "success",
});
// }).catch(error => addToast({ header: 'Ошибка', body: `Не удалось удалить ${itemName}: ${error.message}`, color: 'danger' }));
},
});
}
function handleEditPoint(point: SavedPoint) {
editPoint = point;
}
async function confirmDelete() {
if (!toDelete) return;
try {
await pointsApi.delete(toDelete.id);
pointsStore.update((items) => items.filter((p) => p.id !== toDelete!.id));
addToast({ header: $t('common.success'), body: toDelete.name, color: 'success' });
} catch (err: unknown) {
addToast({ header: $t('common.error'), body: (err as Error).message, color: 'danger' });
} finally {
toDelete = null;
}
}
</script>
<main class="force-page-height">
<Navbar />
<div style="height: var(--navbar-height);"></div>
<!-- Spacer for fixed navbar -->
<div class="container my-4">
<div class="row">
<!-- Side Navigation -->
<div class="col-md-3 col-lg-2 mb-4">
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="/user/account">Учетная запись</a>
<a class="nav-link active" href="/user/templates">Сохраненные сценарии</a>
<a class="nav-link" href="#/">История прогнозов</a>
<a class="nav-link" href="#/">История слежения</a>
</nav>
</div>
<Navbar />
<div style="height: var(--navbar-height);"></div>
<div class="container my-4">
<div class="row">
<div class="col-md-3 col-lg-2 mb-4">
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="/user/account">{$t('nav.account')}</a>
<a class="nav-link active" href="/user/templates">{$t('nav.scenarios')}</a>
<a class="nav-link" href="/user/predictions">{$t('nav.predictionHistory')}</a>
<a class="nav-link" href="/user/flights">{$t('nav.trackingHistory')}</a>
</nav>
</div>
<!-- Main Content -->
<div class="col-md-9 col-lg-10">
<!-- Saved Points -->
<Card class="mb-4">
<CardHeader>
<h5 class="mb-0">Точки запуска</h5>
</CardHeader>
<CardBody>
<div class="position-relative mb-2">
<Input
type="text"
class="form-control-sm pe-5"
placeholder="Поиск по названию..."
bind:value={pointsSearch.value}
oninput={() => pointsSearch.set()}
/>
<Button
size="sm"
color="white"
class="position-absolute top-50 end-0 translate-middle-y me-2 rounded-circle d-flex align-items-center justify-content-center"
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white);"
onclick={() => {
pointsSearch.value = "";
pointsSearch.set();
}}
disabled={!pointsSearch.value}
>
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Название</th>
<th>Широта</th>
<th>Долгота</th>
<th>Высота</th>
<th class="fit"></th>
</tr>
</thead>
<tbody>
{#each pointsTable.rows as row}
<tr>
<td>{row.name}</td>
<td>{row.lat.toFixed(4)} °</td>
<td>{row.lon.toFixed(4)} °</td>
<td>{row.alt} м</td>
<td class="fit">
<Button color="primary" size="sm" onclick={() => handleEditPoint(row)}>
<Icon name="pencil" />
</Button>
<Button
color="danger"
size="sm"
onclick={() =>
handleDelete(row, deletePoint, SavedPointsStore, "Точка")}
>
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination aria-label="Points page navigation" size="sm">
<PaginationItem>
<PaginationLink previous onclick={() => pointsTable.setPage("previous")} />
</PaginationItem>
{#each pointsTable.pagesWithEllipsis as page}
<PaginationItem active={pointsTable.currentPage === page}>
<PaginationLink onclick={() => pointsTable.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next onclick={() => pointsTable.setPage("next")} />
</PaginationItem>
</Pagination>
</CardBody>
</Card>
<!-- Saved Flight Profiles -->
<Card class="mb-4">
<CardHeader>
<h5 class="mb-0">Профили полета</h5>
</CardHeader>
<CardBody>
<Input
type="text"
class="form-control-sm mb-2"
placeholder="Поиск по названию..."
bind:value={profilesSearch.value}
oninput={() => profilesSearch.set()}
/>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Название</th>
<th>Скороподъемность</th>
<th>Скорость снижения</th>
<th>Высота разрыва</th>
<th class="fit"></th>
</tr>
</thead>
<tbody>
{#each profilesTable.rows as row}
<tr>
<td>{row.name}</td>
<td>{row.rate_profile_data.ascent_rate} м/с</td>
<td>{row.rate_profile_data.descent_rate} м/с</td>
<td>{row.rate_profile_data.burst_altitude} м</td>
<td class="fit">
<Button
color="danger"
size="sm"
onclick={() =>
handleDelete(
row,
deleteFlightProfile,
SavedFlightProfilesStore,
"Профиль",
)}
>
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination aria-label="Profiles page navigation" size="sm">
<PaginationItem>
<PaginationLink previous onclick={() => profilesTable.setPage("previous")} />
</PaginationItem>
{#each profilesTable.pagesWithEllipsis as page}
<PaginationItem active={profilesTable.currentPage === page}>
<PaginationLink onclick={() => profilesTable.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next onclick={() => profilesTable.setPage("next")} />
</PaginationItem>
</Pagination>
</CardBody>
</Card>
<!-- Saved Scenario Templates -->
<Card>
<CardHeader>
<h5 class="mb-0">Сценарии</h5>
</CardHeader>
<CardBody>
<Input
type="text"
class="form-control-sm mb-2"
placeholder="Поиск по названию..."
bind:value={templatesSearch.value}
oninput={() => templatesSearch.set()}
/>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Название</th>
<th>Описание</th>
<th class="fit"></th>
</tr>
</thead>
<tbody>
{#each templatesTable.rows as row}
<tr>
<td>{row.name}</td>
<td>{row.template_data.description}</td>
<td class="fit">
<Button
color="danger"
size="sm"
onclick={() =>
handleDelete(
row,
deleteScenarioTemplate,
SavedScenarioTemplatesStore,
"Шаблон",
)}
>
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination aria-label="Templates page navigation" size="sm">
<PaginationItem>
<PaginationLink previous onclick={() => templatesTable.setPage("previous")} />
</PaginationItem>
{#each templatesTable.pagesWithEllipsis as page}
<PaginationItem active={templatesTable.currentPage === page}>
<PaginationLink onclick={() => templatesTable.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next onclick={() => templatesTable.setPage("next")} />
</PaginationItem>
</Pagination>
</CardBody>
</Card>
</div>
</div>
</div>
<Footer />
<div class="col-md-9 col-lg-10">
<Card class="mb-4">
<CardHeader><h5 class="mb-0">{$t('points.items')}</h5></CardHeader>
<CardBody>
<Input
type="text"
class="form-control-sm mb-2"
placeholder={$t('editor.searchPlaceholder')}
bind:value={search.value}
oninput={() => search.set()} />
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{$t('points.name')}</th>
<th>{$t('points.lat')}</th>
<th>{$t('points.lon')}</th>
<th>{$t('points.alt')}</th>
<th class="fit"></th>
</tr>
</thead>
<tbody>
{#each table.rows as row}
<tr>
<td>{row.name}</td>
<td>{row.lat.toFixed(4)} °</td>
<td>{row.lon.toFixed(4)} °</td>
<td>{row.alt} м</td>
<td class="fit">
<Button color="primary" size="sm" onclick={() => (editPoint = row)}>
<Icon name="pencil" />
</Button>
<Button color="danger" size="sm" onclick={() => (toDelete = row)}>
<Icon name="trash" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination size="sm">
<PaginationItem>
<PaginationLink previous onclick={() => table.setPage('previous')} />
</PaginationItem>
{#each table.pagesWithEllipsis as page}
<PaginationItem active={table.currentPage === page}>
<PaginationLink onclick={() => table.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next onclick={() => table.setPage('next')} />
</PaginationItem>
</Pagination>
</CardBody>
</Card>
</div>
</div>
</div>
<Footer />
</main>
<ConfirmationPrompt
bind:isOpen={showConfirm}
title={confirmConfig.title}
confirmText={confirmConfig.confirmText}
confirmVariant={confirmConfig.confirmVariant || "danger"}
cancelText="Отмена"
onconfirm={handleConfirm}
oncancel={() => (showConfirm = false)}
>
<p>{confirmConfig.body}</p>
isOpen={toDelete !== null}
title={$t('editor.delete')}
confirmText={$t('editor.delete')}
cancelText={$t('editor.cancel')}
confirmVariant="danger"
onconfirm={confirmDelete}
oncancel={() => (toDelete = null)}>
{#if toDelete}
<p>Delete "{toDelete.name}"?</p>
{/if}
</ConfirmationPrompt>
<PointEditor
point={editPoint}
isOpen={editPoint !== null}
onClose={() => { editPoint = null; pointsTable.setRows($SavedPointsStore) }}
editor={true}
closeOnSave={true}
closeOnDelete={true}
/>
<ToastContainer />
point={editPoint}
isOpen={editPoint !== null}
onClose={() => (editPoint = null)} />

View file

@ -1 +0,0 @@
export const ssr = false;