Add profile and template pages (scaffolding)

This commit is contained in:
ThePetrovich 2025-07-03 18:39:04 +08:00
parent 41668498ea
commit cb67c5d93d
18 changed files with 1067 additions and 158 deletions

View file

@ -17,6 +17,9 @@
<style>
body {
background-color: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
}
</style>
</head>

19
src/lib/api/profiles.ts Normal file
View file

@ -0,0 +1,19 @@
/* API functions for SavedFlightProfile */
import type {SavedFlightProfile } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
export function getSavedFlightProfiles(): Promise<SavedFlightProfile[]> {
return getAPI<SavedFlightProfile[]>("/saved-profiles/");
}
export function saveFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> {
return postAPI<SavedFlightProfile>("/saved-profiles/", profile);
}
export function updateFlightProfile(profile: SavedFlightProfile): Promise<SavedFlightProfile> {
return putAPI<SavedFlightProfile>(`/saved-profiles/${profile.id}/`, profile);
}
export function deleteFlightProfile(id: number): Promise<void> {
return deleteAPI<void>(`/saved-profiles/${id}/`);
}

19
src/lib/api/templates.ts Normal file
View file

@ -0,0 +1,19 @@
/* API functions for SavedScenarioTemplate */
import type { SavedScenarioTemplate } from "$lib/types";
import { getAPI, postAPI, putAPI, deleteAPI } from "./base";
export function getSavedScenarioTemplates(): Promise<SavedScenarioTemplate[]> {
return getAPI<SavedScenarioTemplate[]>("/saved-templates/");
}
export function saveScenarioTemplate(template: SavedScenarioTemplate): Promise<SavedScenarioTemplate> {
return postAPI<SavedScenarioTemplate>("/saved-templates/", template);
}
export function updateScenarioTemplate(template: SavedScenarioTemplate): Promise<SavedScenarioTemplate> {
return putAPI<SavedScenarioTemplate>(`/saved-templates/${template.id}/`, template);
}
export function deleteScenarioTemplate(id: number): Promise<void> {
return deleteAPI<void>(`/saved-templates/${id}/`);
}

View file

@ -5,76 +5,129 @@ export const LOGIN_URL = 'http://localhost:8000/api/login/';
export const LOGOUT_URL = 'http://localhost:8000/api/logout/';
export const SESSION_URL = 'http://localhost:8000/api/session/';
export const WHOAMI_URL = 'http://localhost:8000/api/whoami/';
export async function getCsrfToken(): Promise<string | null> {
return Cookies.get('csrftoken') || null;
}
export async function getCsrfTokenAuth(): Promise<string | null> {
const response = await fetch(CSRF_URL, {});
console.log('CSRF Token Response:', response);
return Cookies.get('csrftoken') || null;
try {
await fetch(CSRF_URL, {});
return Cookies.get('csrftoken') || null;
} catch (error) {
console.error('Failed to get CSRF token:', error);
return Promise.reject(error);
}
}
export async function checkAuthenticated(): Promise<boolean> {
const csrfToken = await getCsrfTokenAuth();
if (!csrfToken) {
throw new Error('CSRF token not found');
try {
const csrfToken = await getCsrfTokenAuth();
if (!csrfToken) {
throw new Error('CSRF token not found');
}
const response = await fetch(SESSION_URL, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
});
if (!response.ok) {
throw new Error(`Authentication check failed: ${response.statusText}`);
}
const data = await response.json();
return data.isAuthenticated;
} catch (error) {
console.error('Authentication check failed:', error);
return Promise.reject(error);
}
const response = await fetch(SESSION_URL, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
});
let data = await (response as Response).json();
return data.isAuthenticated;
}
export async function login(username: string, password: string): Promise<void> {
const csrfToken = await getCsrfTokenAuth();
if (!csrfToken) {
throw new Error('CSRF token not found');
export async function login(username: string, password: string): Promise<any> {
try {
const csrfToken = await getCsrfTokenAuth();
if (!csrfToken) {
throw new Error('CSRF token not found');
}
const response = await fetch(LOGIN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ username, password }),
credentials: 'include'
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Login failed: ${response.statusText} - ${errorData.detail || ''}`);
}
return await response.json();
} catch (error) {
console.error('Login failed:', error);
return Promise.reject(error);
}
const response = await fetch(LOGIN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ username, password }),
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Login failed: ${response.statusText}`);
}
const data = await response.json();
return data;
}
export async function logout(): Promise<void> {
const csrfToken = await getCsrfTokenAuth();
if (!csrfToken) {
throw new Error('CSRF token not found');
try {
const csrfToken = await getCsrfTokenAuth();
if (!csrfToken) {
throw new Error('CSRF token not found');
}
const response = await fetch(LOGOUT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Logout failed: ${response.statusText}`);
}
console.log('Logout successful');
} catch (error) {
console.error('Logout failed:', error);
return Promise.reject(error);
}
}
const response = await fetch(LOGOUT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include'
});
export async function whoami(): Promise<any> {
try {
const csrfToken = await getCsrfTokenAuth();
if (!csrfToken) {
throw new Error('CSRF token not found');
}
if (!response.ok) {
throw new Error(`Logout failed: ${response.statusText}`);
const response = await fetch(WHOAMI_URL, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Whoami failed: ${response.statusText}`);
}
const data = await response.json();
if (!data || !data.username) {
throw new Error('No user data found');
}
return data.username;
} catch (error) {
console.error('Whoami failed:', error);
return Promise.reject(error);
}
console.log('Logout successful');
return;
}

View file

@ -0,0 +1,44 @@
<script>
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from "@sveltestrap/sveltestrap";
let {
isOpen = $bindable(false),
title = 'Confirm Action',
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmVariant = 'primary',
cancelVariant = 'secondary',
onconfirm,
oncancel,
children
} = $props();
function handleConfirm() {
onconfirm?.();
isOpen = false;
}
function handleCancel() {
oncancel?.();
isOpen = false;
}
</script>
<Modal {isOpen} toggle={handleCancel} fade={false} backdrop={true}>
<ModalHeader toggle={handleCancel}>{title}</ModalHeader>
<ModalBody>
{#if children}
{@render children()}
{:else}
Вы действительно хотите продолжить?
{/if}
</ModalBody>
<ModalFooter>
<Button color={cancelVariant} on:click={handleCancel}>
{cancelText}
</Button>
<Button color={confirmVariant} on:click={handleConfirm}>
{confirmText}
</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,36 @@
<!-- Footer -->
<footer class="bg-dark text-bg-dark mt-auto">
<div class="container pt-5">
<div class="row gy-5">
<div class="col-lg-3 mw-lg-2">
<div class="mb-4">
<a class="navbar-brand" href="/">
<img
src="/logo-full-ru-dark.svg"
class="d-inline-block align-middle img-fluid"
alt="ООО «ЯКС»"
width="250"
/>
</a>
</div>
</div>
<div class="col-lg-8 offset-lg-1">
</div>
</div>
</div>
<div class="container pb-4">
<div class="row">
<div class="col-6 small">
<div>Copyright © 2024 ООО «Якутские Космические Системы»</div>
</div>
<div class="col-6 text-end small">
<div>
<p>
<a class="text-decoration-none" href="/usage_policy">Условия использования</a> -
<a class="text-decoration-none" href="/privacy">Политика конфиденциальности</a>
</p>
</div>
</div>
</div>
</div>
</footer>

View file

@ -1,7 +1,8 @@
<script>
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { checkAuthenticated, logout } from '$lib/auth';
import { checkAuthenticated, logout, whoami } from '$lib/auth';
import {
Collapse,
Dropdown,
@ -19,30 +20,35 @@
// State for the navbar toggler
let isOpen = false;
// Check if user is authenticated (using localStorage token)
let isAuthenticated = false;
// Authentication state
let isAuthenticated: boolean | null = null; // null represents the initial, unknown state
let user: string | null = null;
// This should be reactive to changes in auth status
$: if (typeof window !== 'undefined') {
Promise.resolve(checkAuthenticated()).then((result) => {
isAuthenticated = result;
});
} else {
isAuthenticated = false;
}
onMount(async () => {
try {
const authStatus = await checkAuthenticated();
isAuthenticated = authStatus;
if (authStatus) {
user = await whoami();
} else {
user = null;
}
} catch (error) {
console.error('Authentication check failed:', error);
isAuthenticated = false;
user = null;
}
});
function handleLogout() {
// Clear authentication tokens
try {
logout();
isAuthenticated = false;
user = null;
goto('/');
} catch (error) {
console.error('Logout failed:', error);
}
// Update auth status
isAuthenticated = false;
// Redirect to login page
goto('/');
}
</script>
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
@ -70,30 +76,31 @@
</NavItem>
</Nav>
<Nav navbar>
{#if isAuthenticated}
{#if isAuthenticated === true && user}
<Dropdown nav inNavbar>
<DropdownToggle nav caret class="nav-full-height border border-top-0">
Account
{user ?? 'Пользователь'}
</DropdownToggle>
<DropdownMenu end>
<DropdownItem href="/user/account">Account Settings</DropdownItem>
<DropdownItem href="/user/templates">Saved Templates</DropdownItem>
<DropdownItem href="/user/predictions">Prediction History</DropdownItem>
<DropdownItem href="/user/flights">Flight History</DropdownItem>
<DropdownItem href="/user/account">Учетная запись</DropdownItem>
<DropdownItem href="/user/templates">Сохраненные сценарии</DropdownItem>
<DropdownItem href="/user/predictions">История прогнозов</DropdownItem>
<DropdownItem href="/user/flights">История слежения</DropdownItem>
<DropdownItem divider />
<DropdownItem on:click={handleLogout}>Logout</DropdownItem>
<DropdownItem on:click={handleLogout}>Выйти</DropdownItem>
</DropdownMenu>
</Dropdown>
{:else}
{:else if isAuthenticated === false}
<NavItem>
<NavLink
href="/login"
class="nav-full-height border border-top-0"
active={$page.url.pathname === '/login'}>
Login
Войти
</NavLink>
</NavItem>
{/if}
<!-- While isAuthenticated is null (loading), nothing is rendered in this block -->
</Nav>
</div>
</Navbar>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { TableHandler } from '@vincjo/datatables';
import { Modal,
import { TableHandler } from "@vincjo/datatables";
import {
Modal,
Button,
FormGroup,
Label,
@ -10,26 +11,26 @@
Pagination,
PaginationItem,
PaginationLink,
} from '@sveltestrap/sveltestrap';
import { onMount } from 'svelte';
import { addToast } from '$lib/components/Toast.svelte';
import type { SavedPoint } from '$lib/types';
import { SavedPointsStore } from '$lib/stores';
import { getSavedPoints, savePoint, updatePoint, deletePoint } from '$lib/api/points';
} from "@sveltestrap/sveltestrap";
import { onMount } from "svelte";
import { addToast } from "$lib/components/Toast.svelte";
import type { SavedPoint } from "$lib/types";
import { SavedPointsStore } from "$lib/stores";
import { getSavedPoints, savePoint, updatePoint, deletePoint } from "$lib/api/points";
// Props
let { isOpen = $bindable(false), onClose = () => {}, onChange = () => {} } = $props();
// Runes
let selectedPoint = $state<SavedPoint | null>(null);
let newPoint = $state<SavedPoint>({ id: 0, name: '', lat: 0, lon: 0, alt: 0 });
let newPoint = $state<SavedPoint>({ id: 0, name: "", lat: 0, lon: 0, alt: 0 });
let isEditing = $state(false);
let isAlertVisible = $state(false);
let alertText = $state('');
let alertText = $state("");
// Table handler
let table = $derived(new TableHandler($SavedPointsStore, { rowsPerPage: 10 }));
let search = $derived(table.createSearch(['name']));
let search = $derived(table.createSearch(["name"]));
$effect(() => {
onChange();
@ -59,48 +60,54 @@
}
function handleDeletePoint(point: SavedPoint) {
deletePoint(point.id).then(() => {
$SavedPointsStore = $SavedPointsStore.filter(p => p.id !== point.id);
SavedPointsStore.set($SavedPointsStore);
addToast({
header: 'Точка удалена',
body: `Точка "${point.name}" успешно удалена.`,
color: 'success',
deletePoint(point.id)
.then(() => {
$SavedPointsStore = $SavedPointsStore.filter((p) => p.id !== point.id);
SavedPointsStore.set($SavedPointsStore);
addToast({
header: "Точка удалена",
body: `Точка "${point.name}" успешно удалена.`,
color: "success",
});
})
.catch((error) => {
showAlert(`Ошибка при удалении точки: ${error.message}`);
console.error("Ошибка при удалении точки:", error);
});
}).catch(error => {
showAlert(`Ошибка при удалении точки: ${error.message}`);
console.error('Ошибка при удалении точки:', error);
});
}
function handleSavePoint() {
if (isEditing && selectedPoint) {
updatePoint(newPoint).then(updatedPoint => {
$SavedPointsStore = $SavedPointsStore.map(p => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: 'Точка обновлена',
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: 'success',
updatePoint(newPoint)
.then((updatedPoint) => {
$SavedPointsStore = $SavedPointsStore.map((p) => (p.id === updatedPoint.id ? updatedPoint : p));
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка обновлена",
body: `Точка "${updatedPoint.name}" успешно обновлена.`,
color: "success",
});
})
.catch((error) => {
showAlert(`Ошибка при обновлении точки: ${error.message}`);
});
}).catch(error => {
showAlert(`Ошибка при обновлении точки: ${error.message}`);
});
} else {
savePoint(newPoint).then(savedPoint => {
$SavedPointsStore = [...$SavedPointsStore, savedPoint];
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: 'Точка сохранена',
body: `Точка "${savedPoint.name}" успешно сохранена.`,
color: 'success',
savePoint(newPoint)
.then((savedPoint) => {
$SavedPointsStore = [...$SavedPointsStore, savedPoint];
SavedPointsStore.set($SavedPointsStore);
resetForm();
addToast({
header: "Точка сохранена",
body: `Точка "${savedPoint.name}" успешно сохранена.`,
color: "success",
});
})
.catch((error) => {
showAlert(`Ошибка при сохранении точки: ${error.message}`);
console.error("Ошибка при сохранении точки:", error);
});
}).catch(error => {
showAlert(`Ошибка при сохранении точки: ${error.message}`);
console.error('Ошибка при сохранении точки:', error);
});
}
}
@ -111,12 +118,12 @@
export function hideAlert() {
isAlertVisible = false;
alertText = '';
alertText = "";
}
export function resetForm() {
selectedPoint = null;
newPoint = { id: 0, name: '', lat: 0, lon: 0, alt: 0 };
newPoint = { id: 0, name: "", lat: 0, lon: 0, alt: 0 };
isEditing = false;
hideAlert();
}
@ -130,21 +137,24 @@
<div class="modal-body">
<div class="position-relative mb-2">
<Input
type="text"
class="form-control-sm pe-5"
placeholder="Поиск по названию..."
bind:value={search.value}
oninput={() => search.set()}
type="text"
class="form-control-sm pe-5"
placeholder="Поиск по названию..."
bind:value={search.value}
oninput={() => search.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={() => { search.value = ''; search.set(); }}
disabled={!search.value}
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={() => {
search.value = "";
search.set();
}}
disabled={!search.value}
>
<Icon name="x" style="font-size: 16px;" />
<Icon name="x" style="font-size: 16px;" />
</Button>
</div>
<div bind:this={table.element} class="table-responsive">
@ -196,13 +206,23 @@
<!-- Form for adding/editing points -->
<div>
<h5>{isEditing ? 'Редактирование точки' : 'Добавить новую точку'}</h5>
<Alert color="danger" isOpen={isAlertVisible} toggle={() => (isAlertVisible = false)} fade={false}
class="mb-2">
<h5>{isEditing ? "Редактирование точки" : "Добавить новую точку"}</h5>
<Alert
color="danger"
isOpen={isAlertVisible}
toggle={() => (isAlertVisible = false)}
fade={false}
class="mb-2"
>
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<form onsubmit={(e) => { e.preventDefault(); handleSavePoint(); }}>
<form
onsubmit={(e) => {
e.preventDefault();
handleSavePoint();
}}
>
<div class="mb-2">
<Label for="name" class="small">Название точки:</Label>
<Input class="form-control-sm" type="text" id="name" bind:value={newPoint.name} required />
@ -210,22 +230,43 @@
<div class="d-flex gap-2">
<FormGroup class="flex-grow-1">
<Label for="lat" class="small">Широта:</Label>
<Input class="form-control-sm" type="number" step="any" id="lat" bind:value={newPoint.lat} required />
<Input
class="form-control-sm"
type="number"
step="any"
id="lat"
bind:value={newPoint.lat}
required
/>
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label for="lon" class="small">Долгота:</Label>
<Input class="form-control-sm" type="number" step="any" id="lon" bind:value={newPoint.lon} required />
<Input
class="form-control-sm"
type="number"
step="any"
id="lon"
bind:value={newPoint.lon}
required
/>
<span class="form-text">Градусы</span>
</FormGroup>
<FormGroup class="flex-grow-1">
<Label for="alt" class="small">Высота:</Label>
<Input class="form-control-sm" type="number" step="any" id="alt" bind:value={newPoint.alt} required />
<Input
class="form-control-sm"
type="number"
step="any"
id="alt"
bind:value={newPoint.alt}
required
/>
<span class="form-text">Метры над ур. моря</span>
</FormGroup>
</div>
<Button type="submit" color="success" size="sm">
{isEditing ? 'Обновить точку' : 'Сохранить точку'}
{isEditing ? "Обновить точку" : "Сохранить точку"}
</Button>
{#if isEditing}
<Button size="sm" type="button" color="secondary" onclick={resetForm}>Отмена</Button>

View file

@ -75,12 +75,14 @@
<Button
color="primary"
size="sm"
class="mb-2 w-100"
class="mb-0 w-100"
>
Редактировать сохраненные сценарии
<Icon name="journal-bookmark-fill" />
</Button>
<hr />
<FormGroup spacing="mb-2">
<Label for="scenarioMode" class="form-label">Режим сценария:</Label>
<InputGroup size="sm">
@ -92,8 +94,32 @@
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="scenarioMode" class="form-label">Модель атмосферы:</Label>
<InputGroup size="sm">
<Input type="select" id="scenarioMode">
<option>GFS (0.25°)</option>
<option>GFS (0.5°)</option>
</Input>
</InputGroup>
</FormGroup>
<FormGroup spacing="mb-2">
<Label for="scenarioMode" class="form-label">Набор данных:</Label>
<InputGroup size="sm">
<Input type="select" id="scenarioMode">
<option>Выбрать автоматически</option>
<!-- TODO ручка апи для доступных наборов -->
<option>20250701-00</option>
<option>20250701-06</option>
</Input>
</InputGroup>
</FormGroup>
<hr />
<FormGroup spacing="mb-0">
<Label for="export" class="form-label">Экспортировать:</Label>
<Label for="export" class="form-label">Экспортировать результат:</Label>
<InputGroup size="sm">
<Input type="select" id="export">
<option>JSON</option>

View file

@ -146,6 +146,7 @@
<style>
.select-container {
position: relative;
cursor: default;
}
.dropdown-menu {

View file

@ -1,7 +1,7 @@
import { writable } from "svelte/store";
import type { FlightParameters, RawTelemetry, Telemetry } from "./types";
import type { RawPrediction, Prediction } from "./types";
import type { SavedPoint } from "./types";
import type { SavedPoint, SavedFlightProfile, SavedScenarioTemplate } from "./types";
export const readLocalStorage = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key);
@ -70,3 +70,9 @@ export const PredictionStore = writable<Prediction>(
);
export const SavedPointsStore = writable<SavedPoint[]>([]);
// stub
export const SavedFlightProfilesStore = writable<SavedFlightProfile[]>([]);
// stub
export const SavedScenarioTemplatesStore = writable<SavedScenarioTemplate[]>([]);

View file

@ -101,4 +101,16 @@ export interface SavedPoint {
lat: number;
lon: number;
alt: number;
}
export interface SavedFlightProfile {
id: number;
name: string;
rate_profile_data: object;
}
export interface SavedScenarioTemplate {
id: number;
name: string;
template_data: object;
}

View file

@ -80,6 +80,7 @@
<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} >
<TabComponent

View file

@ -12,6 +12,7 @@
<main>
<Navbar />
<div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar -->
<Map>
<TelemetryPanel
/>

View file

@ -1,12 +1,267 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Navbar from "$lib/components/Navbar.svelte";
import {
Card,
CardHeader,
CardBody,
Button,
FormGroup,
Label,
Input,
InputGroup,
InputGroupText,
Icon,
} from "@sveltestrap/sveltestrap";
import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte";
import Footer from "$lib/components/Footer.svelte";
let editMode = false;
let showToken = false;
type ConfirmConfig = {
title: string;
body: string;
confirmText: string;
confirmVariant?: string;
onConfirm: () => void;
};
// 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;
}
</script>
<main>
<main class="force-page-height">
<Navbar />
<div class="container">
<h1>User Account</h1>
<p>Manage your account settings here.</p>
<!-- Add account management components or links here -->
<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">
<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>
<!-- 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>
</main>
<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>
</ConfirmationPrompt>

View file

@ -0,0 +1,345 @@
<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 Navbar from "$lib/components/Navbar.svelte";
import Footer from "$lib/components/Footer.svelte";
import ConfirmationPrompt from "$lib/components/ConfirmationPrompt.svelte";
import { addToast } from "$lib/components/Toast.svelte";
// TODO: Implement these imports
import { SavedPointsStore, SavedFlightProfilesStore, SavedScenarioTemplatesStore } from "$lib/stores";
import { getSavedPoints, deletePoint } from "$lib/api/points";
import { getSavedFlightProfiles, deleteFlightProfile } from "$lib/api/profiles";
import { getSavedScenarioTemplates, deleteScenarioTemplate } from "$lib/api/templates";
import type { SavedPoint, SavedFlightProfile, SavedScenarioTemplate } from "$lib/types";
// Table handlers
let pointsTable = $derived(new TableHandler($SavedPointsStore, { rowsPerPage: 5 }));
let pointsSearch = $derived(pointsTable.createSearch(["name"]));
let profilesTable = $derived(new TableHandler($SavedFlightProfilesStore, { rowsPerPage: 5 }));
let profilesSearch = $derived(profilesTable.createSearch(["name"]));
let templatesTable = $derived(new TableHandler($SavedScenarioTemplatesStore, { rowsPerPage: 5 }));
let templatesSearch = $derived(templatesTable.createSearch(["name"]));
onMount(async () => {
// Mock data for demonstration. Replace with API calls.
$SavedPointsStore = [
{ id: 1, name: "Baikonur Cosmodrome", lat: 45.96, lon: 63.3, alt: 90 },
{ id: 2, name: "Kennedy Space Center", lat: 28.57, lon: -80.64, alt: 3 },
];
$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} },
];
$SavedScenarioTemplatesStore = [
{ id: 1, name: "Summer Launch from Baikonur", template_data: {description: "Standard summer conditions test."} },
{ id: 2, name: "Winter Launch from KSC", template_data: {description: "High wind scenario."} },
];
/*
// 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' }));
},
});
}
</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">
<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>
<!-- Main Content -->
<div class="col-md-9 col-lg-10">
<!-- Saved Points -->
<Card class="mb-4">
<CardHeader>
<h5 class="mb-0">Точки запуска</h5>
</CardHeader>
<CardBody>
<Input
type="text"
class="form-control-sm mb-2"
placeholder="Поиск по названию..."
bind:value={pointsSearch.value}
oninput={() => pointsSearch.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 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="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 />
</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>
</ConfirmationPrompt>