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

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;
}