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> <style>
body { body {
background-color: #f5f5f5; background-color: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
} }
</style> </style>
</head> </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 LOGOUT_URL = 'http://localhost:8000/api/logout/';
export const SESSION_URL = 'http://localhost:8000/api/session/'; export const SESSION_URL = 'http://localhost:8000/api/session/';
export const WHOAMI_URL = 'http://localhost:8000/api/whoami/'; export const WHOAMI_URL = 'http://localhost:8000/api/whoami/';
export async function getCsrfToken(): Promise<string | null> { export async function getCsrfToken(): Promise<string | null> {
return Cookies.get('csrftoken') || null; return Cookies.get('csrftoken') || null;
} }
export async function getCsrfTokenAuth(): Promise<string | null> { export async function getCsrfTokenAuth(): Promise<string | null> {
const response = await fetch(CSRF_URL, {}); try {
console.log('CSRF Token Response:', response); await fetch(CSRF_URL, {});
return Cookies.get('csrftoken') || null; 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> { export async function checkAuthenticated(): Promise<boolean> {
const csrfToken = await getCsrfTokenAuth(); try {
if (!csrfToken) { const csrfToken = await getCsrfTokenAuth();
throw new Error('CSRF token not found'); 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> { export async function login(username: string, password: string): Promise<any> {
const csrfToken = await getCsrfTokenAuth(); try {
if (!csrfToken) { const csrfToken = await getCsrfTokenAuth();
throw new Error('CSRF token not found'); 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> { export async function logout(): Promise<void> {
const csrfToken = await getCsrfTokenAuth(); try {
if (!csrfToken) { const csrfToken = await getCsrfTokenAuth();
throw new Error('CSRF token not found'); 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, { export async function whoami(): Promise<any> {
method: 'POST', try {
headers: { const csrfToken = await getCsrfTokenAuth();
'Content-Type': 'application/json', if (!csrfToken) {
'X-CSRFToken': csrfToken throw new Error('CSRF token not found');
}, }
credentials: 'include'
});
if (!response.ok) { const response = await fetch(WHOAMI_URL, {
throw new Error(`Logout failed: ${response.statusText}`); 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 { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { checkAuthenticated, logout } from '$lib/auth'; import { checkAuthenticated, logout, whoami } from '$lib/auth';
import { import {
Collapse, Collapse,
Dropdown, Dropdown,
@ -19,30 +20,35 @@
// State for the navbar toggler // State for the navbar toggler
let isOpen = false; let isOpen = false;
// Check if user is authenticated (using localStorage token) // Authentication state
let isAuthenticated = false; 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 onMount(async () => {
$: if (typeof window !== 'undefined') { try {
Promise.resolve(checkAuthenticated()).then((result) => { const authStatus = await checkAuthenticated();
isAuthenticated = result; isAuthenticated = authStatus;
}); if (authStatus) {
} else { user = await whoami();
isAuthenticated = false; } else {
} user = null;
}
} catch (error) {
console.error('Authentication check failed:', error);
isAuthenticated = false;
user = null;
}
});
function handleLogout() { function handleLogout() {
// Clear authentication tokens
try { try {
logout(); logout();
isAuthenticated = false;
user = null;
goto('/');
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
} }
// Update auth status
isAuthenticated = false;
// Redirect to login page
goto('/');
} }
</script> </script>
<Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom"> <Navbar color="light" light expand="lg" fixed="top" class="custom-navbar border-bottom">
@ -70,30 +76,31 @@
</NavItem> </NavItem>
</Nav> </Nav>
<Nav navbar> <Nav navbar>
{#if isAuthenticated} {#if isAuthenticated === true && user}
<Dropdown nav inNavbar> <Dropdown nav inNavbar>
<DropdownToggle nav caret class="nav-full-height border border-top-0"> <DropdownToggle nav caret class="nav-full-height border border-top-0">
Account {user ?? 'Пользователь'}
</DropdownToggle> </DropdownToggle>
<DropdownMenu end> <DropdownMenu end>
<DropdownItem href="/user/account">Account Settings</DropdownItem> <DropdownItem href="/user/account">Учетная запись</DropdownItem>
<DropdownItem href="/user/templates">Saved Templates</DropdownItem> <DropdownItem href="/user/templates">Сохраненные сценарии</DropdownItem>
<DropdownItem href="/user/predictions">Prediction History</DropdownItem> <DropdownItem href="/user/predictions">История прогнозов</DropdownItem>
<DropdownItem href="/user/flights">Flight History</DropdownItem> <DropdownItem href="/user/flights">История слежения</DropdownItem>
<DropdownItem divider /> <DropdownItem divider />
<DropdownItem on:click={handleLogout}>Logout</DropdownItem> <DropdownItem on:click={handleLogout}>Выйти</DropdownItem>
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
{:else} {:else if isAuthenticated === false}
<NavItem> <NavItem>
<NavLink <NavLink
href="/login" href="/login"
class="nav-full-height border border-top-0" class="nav-full-height border border-top-0"
active={$page.url.pathname === '/login'}> active={$page.url.pathname === '/login'}>
Login Войти
</NavLink> </NavLink>
</NavItem> </NavItem>
{/if} {/if}
<!-- While isAuthenticated is null (loading), nothing is rendered in this block -->
</Nav> </Nav>
</div> </div>
</Navbar> </Navbar>

View file

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

View file

@ -75,12 +75,14 @@
<Button <Button
color="primary" color="primary"
size="sm" size="sm"
class="mb-2 w-100" class="mb-0 w-100"
> >
Редактировать сохраненные сценарии Редактировать сохраненные сценарии
<Icon name="journal-bookmark-fill" /> <Icon name="journal-bookmark-fill" />
</Button> </Button>
<hr />
<FormGroup spacing="mb-2"> <FormGroup spacing="mb-2">
<Label for="scenarioMode" class="form-label">Режим сценария:</Label> <Label for="scenarioMode" class="form-label">Режим сценария:</Label>
<InputGroup size="sm"> <InputGroup size="sm">
@ -92,8 +94,32 @@
</InputGroup> </InputGroup>
</FormGroup> </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"> <FormGroup spacing="mb-0">
<Label for="export" class="form-label">Экспортировать:</Label> <Label for="export" class="form-label">Экспортировать результат:</Label>
<InputGroup size="sm"> <InputGroup size="sm">
<Input type="select" id="export"> <Input type="select" id="export">
<option>JSON</option> <option>JSON</option>

View file

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

View file

@ -1,7 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { FlightParameters, RawTelemetry, Telemetry } from "./types"; import type { FlightParameters, RawTelemetry, Telemetry } from "./types";
import type { RawPrediction, Prediction } 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 => { export const readLocalStorage = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key); const item = localStorage.getItem(key);
@ -70,3 +70,9 @@ export const PredictionStore = writable<Prediction>(
); );
export const SavedPointsStore = writable<SavedPoint[]>([]); 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; lat: number;
lon: number; lon: number;
alt: 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> <main>
<Navbar /> <Navbar />
<div style="height: var(--navbar-height);"></div> <!-- Spacer for fixed navbar -->
<Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}> <Map bind:this={map} mode="prediction" bind:data={$PredictionStore} on:coordinatesSelected={handleCoordinateSelection}>
<PanelContainer bind:this={panelContainer} > <PanelContainer bind:this={panelContainer} >
<TabComponent <TabComponent

View file

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

View file

@ -1,12 +1,267 @@
<script lang="ts"> <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> </script>
<main> <main class="force-page-height">
<Navbar /> <Navbar />
<div class="container"> <div style="height: var(--navbar-height);"></div>
<h1>User Account</h1> <!-- Spacer for fixed navbar -->
<p>Manage your account settings here.</p> <div class="container my-4">
<!-- Add account management components or links here --> <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> </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>

View file

@ -59,7 +59,6 @@
position: relative; position: relative;
width: 100%; width: 100%;
height: calc(100vh - var(--navbar-height)); height: calc(100vh - var(--navbar-height));
top: var(--navbar-height);
} }
.coordinates-display { .coordinates-display {
@ -123,6 +122,12 @@
width: 1%; width: 1%;
} }
.force-page-height {
min-height: 100vh;
display: flex;
flex-direction: column;
}
@media (max-width: 767.98px) @media (max-width: 767.98px)
{ {
.coordinates-display { .coordinates-display {

View file

@ -0,0 +1,35 @@
<svg width="224" height="55" viewBox="0 0 224 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M128.048 26.5659H120.685C120.708 25.5886 120.583 24.725 120.31 23.975C120.037 23.2137 119.628 22.566 119.083 22.0319C118.549 21.4978 117.895 21.0944 117.122 20.8217C116.35 20.5376 115.48 20.3956 114.515 20.3956C112.651 20.3956 110.941 20.8615 109.384 21.7933C107.827 22.7251 106.515 24.0773 105.447 25.85C104.378 27.6113 103.64 29.742 103.231 32.2419C102.833 34.6509 102.856 36.6679 103.299 38.2929C103.742 39.9179 104.526 41.1451 105.651 41.9746C106.787 42.7928 108.202 43.2019 109.895 43.2019C110.941 43.2019 111.935 43.0712 112.878 42.8099C113.821 42.5371 114.679 42.1508 115.452 41.6508C116.236 41.1394 116.918 40.5201 117.497 39.7929C118.088 39.0656 118.549 38.2418 118.878 37.3213H126.293C125.827 38.9236 125.1 40.469 124.111 41.9576C123.134 43.4462 121.923 44.7757 120.48 45.9461C119.037 47.1052 117.395 48.0256 115.554 48.7074C113.713 49.3893 111.696 49.7302 109.503 49.7302C106.333 49.7302 103.611 49.0029 101.339 47.5484C99.0774 46.0939 97.4467 43.9973 96.4467 41.2587C95.4468 38.5202 95.2593 35.2191 95.8843 31.3556C96.5092 27.617 97.7308 24.4466 99.5489 21.8444C101.378 19.2308 103.6 17.2479 106.214 15.8957C108.839 14.5434 111.645 13.8673 114.634 13.8673C116.713 13.8673 118.594 14.1514 120.276 14.7196C121.958 15.2877 123.389 16.1173 124.571 17.2081C125.764 18.2877 126.662 19.6115 127.264 21.1796C127.867 22.7478 128.128 24.5432 128.048 26.5659Z" fill="white"/>
<path d="M85.6189 49.2529L76.8747 34.3726H74.3521L71.8805 49.2529H64.5L70.2953 14.3446H77.6758L75.3577 28.2875H76.8406L90.7494 14.3446H99.9367L83.6416 30.5374L94.7721 49.2529H85.6189Z" fill="white"/>
<path d="M58.449 49.2529H51.0685L55.8581 20.3786H50.881C49.4606 20.3786 48.2447 20.5888 47.2333 21.0092C46.2334 21.4183 45.4322 22.0206 44.83 22.816C44.2391 23.6114 43.847 24.5887 43.6539 25.7478C43.472 26.8955 43.5459 27.8557 43.8754 28.6284C44.2163 29.4011 44.8186 29.9806 45.6822 30.367C46.5572 30.7533 47.6992 30.9465 49.1083 30.9465H57.1195L56.1309 36.8782H46.9095C44.2391 36.8782 42.0289 36.435 40.2789 35.5487C38.5403 34.6623 37.3074 33.3896 36.5802 31.7306C35.8529 30.0602 35.6768 28.0659 36.0518 25.7478C36.4495 23.441 37.279 21.4354 38.5403 19.7308C39.813 18.015 41.4607 16.6911 43.4834 15.7593C45.5061 14.8162 47.8356 14.3446 50.4719 14.3446H64.2273L58.449 49.2529ZM42.2391 33.3669H50.1992L39.0687 49.2529H30.9382L42.2391 33.3669Z" fill="white"/>
<path d="M27.4586 0.703261C36.0581 -1.03272 44.1649 1.01002 50.8356 4.62122C43.0338 2.24607 34.7084 2.9756 27.5572 6.21008C26.4346 5.16755 24.9317 4.52852 23.2789 4.52845C19.8046 4.52845 16.9879 7.3452 16.9879 10.8194C16.9879 11.7015 17.1699 12.5409 17.4976 13.3028C15.1859 15.724 13.2463 18.54 11.8091 21.7052C6.20557 34.0461 11.8607 47.2099 22.3297 54.1445C12.4671 51.1328 3.20424 43.9147 1.25151 34.2422C-1.77463 19.2521 9.9587 4.23604 27.4586 0.703261Z" fill="#008DD2"/>
<path d="M36.7742 6.64835C44.1001 5.45592 51.1813 7.06493 56.7557 10.6396C43.9622 5.75811 28.9667 12.107 23.0516 25.0136C18.0326 35.9656 21.145 48.1648 29.866 54.9257C21.5728 51.7527 15.2724 44.7896 13.8289 35.9228C12.6366 28.5975 14.9995 21.4525 19.7244 16.0097C20.7356 16.7034 21.9592 17.1102 23.2782 17.1103C26.7524 17.1103 29.5692 14.2935 29.5692 10.8193C29.5691 10.1553 29.4649 9.5158 29.2742 8.91496C31.5966 7.85721 34.1112 7.08187 36.7742 6.64835Z" fill="#009846"/>
<path d="M27.242 10.7744C27.2669 12.9633 25.5126 14.7579 23.3236 14.7828C21.1347 14.8077 19.3401 13.0533 19.3152 10.8644C19.2904 8.67548 21.0447 6.88085 23.2336 6.85599C25.4225 6.83113 27.2172 8.58545 27.242 10.7744Z" fill="#C42526"/>
<path d="M136.082 50.1671L134.582 50.1671L134.582 13.1534L136.082 13.1534L136.082 50.1671Z" fill="#008DD2"/>
<path d="M190.118 45.3004H192.074C192.94 45.3004 193.616 45.5078 194.102 45.9225C194.588 46.3345 194.831 46.8856 194.831 47.5759C194.831 48.0248 194.721 48.4197 194.503 48.7606C194.284 49.0987 193.969 49.3629 193.557 49.5532C193.145 49.7407 192.65 49.8345 192.074 49.8345H188.98V43.289H190.714V48.4069H192.074C192.378 48.4069 192.628 48.3302 192.824 48.1768C193.02 48.0234 193.119 47.8274 193.122 47.5887C193.119 47.3359 193.02 47.1299 192.824 46.9708C192.628 46.8089 192.378 46.7279 192.074 46.7279H190.118V45.3004ZM195.534 49.8345V43.289H197.349V49.8345H195.534Z" fill="white"/>
<path d="M183.638 47.8274L185.411 43.289H186.792L184.239 49.8345H183.033L180.54 43.289H181.917L183.638 47.8274ZM181.503 43.289V49.8345H179.769V43.289H181.503ZM185.863 49.8345V43.289H187.576V49.8345H185.863Z" fill="white"/>
<path d="M175.58 49.9623C174.907 49.9623 174.327 49.8259 173.842 49.5532C173.359 49.2776 172.986 48.8884 172.725 48.3856C172.464 47.8799 172.333 47.2819 172.333 46.5915C172.333 45.9182 172.464 45.3273 172.725 44.8188C172.986 44.3103 173.354 43.914 173.829 43.6299C174.306 43.3458 174.866 43.2037 175.508 43.2037C175.94 43.2037 176.342 43.2733 176.714 43.4125C177.089 43.5489 177.415 43.7549 177.694 44.0304C177.975 44.306 178.194 44.6526 178.35 45.0702C178.506 45.485 178.584 45.9708 178.584 46.5276V47.0262H173.057V45.9012H176.876C176.876 45.6398 176.819 45.4083 176.705 45.2066C176.592 45.0049 176.434 44.8472 176.232 44.7336C176.033 44.6171 175.802 44.5588 175.538 44.5588C175.262 44.5588 175.018 44.6228 174.805 44.7506C174.594 44.8756 174.43 45.0446 174.31 45.2577C174.191 45.4679 174.13 45.7023 174.127 45.9608V47.0304C174.127 47.3543 174.187 47.6341 174.306 47.8699C174.428 48.1057 174.6 48.2875 174.822 48.4154C175.043 48.5432 175.306 48.6071 175.61 48.6071C175.812 48.6071 175.996 48.5787 176.164 48.5219C176.332 48.4651 176.475 48.3799 176.594 48.2662C176.714 48.1526 176.805 48.0134 176.867 47.8486L178.546 47.9594C178.461 48.3628 178.286 48.7151 178.022 49.0162C177.761 49.3145 177.422 49.5475 177.008 49.7151C176.596 49.8799 176.12 49.9623 175.58 49.9623Z" fill="white"/>
<path d="M165.799 44.7166V43.289H171.731V44.7166H169.622V49.8345H167.892V44.7166H165.799Z" fill="white"/>
<path d="M162.16 49.9623C161.49 49.9623 160.913 49.8202 160.43 49.5361C159.95 49.2492 159.581 48.8515 159.322 48.3429C159.066 47.8344 158.938 47.2492 158.938 46.5873C158.938 45.9168 159.068 45.3287 159.326 44.8231C159.588 44.3145 159.958 43.9182 160.438 43.6341C160.919 43.3472 161.49 43.2037 162.152 43.2037C162.723 43.2037 163.223 43.3074 163.652 43.5148C164.081 43.7222 164.42 44.0134 164.67 44.3884C164.92 44.7634 165.058 45.2037 165.083 45.7094H163.37C163.322 45.3827 163.194 45.1199 162.987 44.9211C162.782 44.7194 162.514 44.6185 162.181 44.6185C161.9 44.6185 161.654 44.6952 161.444 44.8486C161.237 44.9992 161.075 45.2194 160.958 45.5091C160.842 45.7989 160.784 46.1498 160.784 46.5617C160.784 46.9793 160.84 47.3344 160.954 47.627C161.071 47.9196 161.234 48.1427 161.444 48.2961C161.654 48.4495 161.9 48.5262 162.181 48.5262C162.389 48.5262 162.575 48.4836 162.74 48.3983C162.907 48.3131 163.045 48.1895 163.153 48.0276C163.264 47.8628 163.336 47.6654 163.37 47.4353H165.083C165.055 47.9353 164.919 48.3756 164.674 48.7563C164.433 49.1341 164.099 49.4296 163.673 49.6427C163.247 49.8557 162.742 49.9623 162.16 49.9623Z" fill="white"/>
<path d="M153.601 47.4396L155.958 43.289H157.747V49.8345H156.017V45.6711L153.669 49.8345H151.867V43.289H153.601V47.4396Z" fill="white"/>
<path d="M150.578 44.1626H148.711C148.677 43.9211 148.607 43.7066 148.502 43.5191C148.397 43.3288 148.262 43.1668 148.098 43.0333C147.933 42.8998 147.742 42.7975 147.526 42.7265C147.313 42.6555 147.082 42.62 146.832 42.62C146.38 42.62 145.987 42.7322 145.651 42.9566C145.316 43.1782 145.056 43.5021 144.872 43.9282C144.687 44.3515 144.595 44.8657 144.595 45.4708C144.595 46.093 144.687 46.6157 144.872 47.039C145.059 47.4623 145.321 47.7819 145.656 47.9978C145.991 48.2137 146.379 48.3217 146.819 48.3217C147.066 48.3217 147.295 48.289 147.505 48.2237C147.718 48.1583 147.907 48.0631 148.072 47.9381C148.237 47.8103 148.373 47.6555 148.481 47.4737C148.592 47.2918 148.669 47.0844 148.711 46.8515L150.578 46.86C150.529 47.2606 150.409 47.6469 150.215 48.0191C150.025 48.3884 149.768 48.7194 149.444 49.012C149.123 49.3018 148.74 49.5319 148.294 49.7023C147.85 49.87 147.349 49.9538 146.789 49.9538C146.011 49.9538 145.315 49.7776 144.701 49.4254C144.09 49.0731 143.607 48.5631 143.252 47.8955C142.9 47.2279 142.724 46.4197 142.724 45.4708C142.724 44.5191 142.903 43.7094 143.261 43.0418C143.619 42.3742 144.105 41.8657 144.718 41.5163C145.332 41.164 146.022 40.9879 146.789 40.9879C147.295 40.9879 147.764 41.0589 148.196 41.2009C148.63 41.343 149.015 41.5504 149.35 41.8231C149.686 42.093 149.958 42.4239 150.169 42.816C150.382 43.208 150.518 43.6569 150.578 44.1626Z" fill="white"/>
<path d="M220.463 35.9623C219.79 35.9623 219.21 35.8259 218.724 35.5532C218.241 35.2776 217.869 34.8884 217.608 34.3856C217.347 33.8799 217.216 33.2819 217.216 32.5915C217.216 31.9182 217.347 31.3273 217.608 30.8188C217.869 30.3103 218.237 29.914 218.712 29.6299C219.189 29.3458 219.748 29.2037 220.391 29.2037C220.822 29.2037 221.224 29.2733 221.597 29.4125C221.972 29.5489 222.298 29.7549 222.577 30.0304C222.858 30.306 223.077 30.6526 223.233 31.0702C223.389 31.485 223.467 31.9708 223.467 32.5276V33.0262H217.94V31.9012H221.758C221.758 31.6398 221.702 31.4083 221.588 31.2066C221.474 31.0049 221.317 30.8472 221.115 30.7336C220.916 30.6171 220.685 30.5588 220.42 30.5588C220.145 30.5588 219.9 30.6228 219.687 30.7506C219.477 30.8756 219.312 31.0446 219.193 31.2577C219.074 31.4679 219.013 31.7023 219.01 31.9608V33.0304C219.01 33.3543 219.07 33.6341 219.189 33.8699C219.311 34.1057 219.483 34.2875 219.704 34.4154C219.926 34.5432 220.189 34.6071 220.493 34.6071C220.695 34.6071 220.879 34.5787 221.047 34.5219C221.214 34.4651 221.358 34.3799 221.477 34.2662C221.596 34.1526 221.687 34.0134 221.75 33.8486L223.429 33.9594C223.344 34.3628 223.169 34.7151 222.905 35.0162C222.643 35.3145 222.305 35.5475 221.891 35.7151C221.479 35.8799 221.003 35.9623 220.463 35.9623Z" fill="white"/>
<path d="M211.878 33.4396L214.235 29.289H216.025V35.8345H214.295V31.6711L211.947 35.8345H210.144V29.289H211.878V33.4396Z" fill="white"/>
<path d="M203.113 35.8345V29.289H204.928V31.8288H205.439L207.221 29.289H209.351L207.038 32.5362L209.377 35.8345H207.221L205.606 33.512H204.928V35.8345H203.113Z" fill="white"/>
<path d="M199.027 35.9623C198.357 35.9623 197.78 35.8202 197.297 35.5361C196.817 35.2492 196.448 34.8515 196.189 34.3429C195.934 33.8344 195.806 33.2492 195.806 32.5873C195.806 31.9168 195.935 31.3287 196.193 30.8231C196.455 30.3145 196.826 29.9182 197.306 29.6341C197.786 29.3472 198.357 29.2037 199.019 29.2037C199.59 29.2037 200.09 29.3074 200.519 29.5148C200.948 29.7222 201.287 30.0134 201.537 30.3884C201.787 30.7634 201.925 31.2037 201.951 31.7094H200.237C200.189 31.3827 200.061 31.1199 199.854 30.9211C199.649 30.7194 199.381 30.6185 199.049 30.6185C198.767 30.6185 198.522 30.6952 198.311 30.8486C198.104 30.9992 197.942 31.2194 197.826 31.5091C197.709 31.7989 197.651 32.1498 197.651 32.5617C197.651 32.9793 197.708 33.3344 197.821 33.627C197.938 33.9196 198.101 34.1427 198.311 34.2961C198.522 34.4495 198.767 34.5262 199.049 34.5262C199.256 34.5262 199.442 34.4836 199.607 34.3983C199.774 34.3131 199.912 34.1895 200.02 34.0276C200.131 33.8628 200.203 33.6654 200.237 33.4353H201.951C201.922 33.9353 201.786 34.3756 201.541 34.7563C201.3 35.1341 200.966 35.4296 200.54 35.6427C200.114 35.8557 199.61 35.9623 199.027 35.9623Z" fill="white"/>
<path d="M191.881 35.9623C191.208 35.9623 190.628 35.8259 190.142 35.5532C189.659 35.2776 189.287 34.8884 189.026 34.3856C188.764 33.8799 188.634 33.2819 188.634 32.5915C188.634 31.9182 188.764 31.3273 189.026 30.8188C189.287 30.3103 189.655 29.914 190.13 29.6299C190.607 29.3458 191.166 29.2037 191.809 29.2037C192.24 29.2037 192.642 29.2733 193.014 29.4125C193.389 29.5489 193.716 29.7549 193.995 30.0304C194.276 30.306 194.495 30.6526 194.651 31.0702C194.807 31.485 194.885 31.9708 194.885 32.5276V33.0262H189.358V31.9012H193.176C193.176 31.6398 193.12 31.4083 193.006 31.2066C192.892 31.0049 192.735 30.8472 192.533 30.7336C192.334 30.6171 192.103 30.5588 191.838 30.5588C191.563 30.5588 191.318 30.6228 191.105 30.7506C190.895 30.8756 190.73 31.0446 190.611 31.2577C190.492 31.4679 190.431 31.7023 190.428 31.9608V33.0304C190.428 33.3543 190.487 33.6341 190.607 33.8699C190.729 34.1057 190.901 34.2875 191.122 34.4154C191.344 34.5432 191.607 34.6071 191.911 34.6071C192.112 34.6071 192.297 34.5787 192.465 34.5219C192.632 34.4651 192.776 34.3799 192.895 34.2662C193.014 34.1526 193.105 34.0134 193.168 33.8486L194.847 33.9594C194.762 34.3628 194.587 34.7151 194.323 35.0162C194.061 35.3145 193.723 35.5475 193.309 35.7151C192.897 35.8799 192.421 35.9623 191.881 35.9623Z" fill="white"/>
<path d="M187.489 29.289V35.8344H185.767V29.289H187.489ZM186.649 32.1611V33.593C186.51 33.6555 186.337 33.7151 186.129 33.7719C185.922 33.8259 185.704 33.8699 185.477 33.904C185.25 33.9381 185.037 33.9552 184.838 33.9552C183.898 33.9552 183.158 33.762 182.618 33.3756C182.078 32.9864 181.808 32.3657 181.808 31.5134V29.2805H183.521V31.5134C183.521 31.7663 183.564 31.9665 183.649 32.1143C183.737 32.262 183.876 32.3685 184.067 32.4339C184.26 32.4964 184.517 32.5276 184.838 32.5276C185.136 32.5276 185.429 32.4964 185.716 32.4339C186.003 32.3714 186.314 32.2805 186.649 32.1611Z" fill="white"/>
<path d="M176.207 33.4396L178.563 29.289H180.353V35.8345H178.623V31.6711L176.275 35.8345H174.472V29.289H176.207V33.4396Z" fill="white"/>
<path d="M169.131 33.8274L170.903 29.289H172.284L169.731 35.8345H168.525L166.033 29.289H167.409L169.131 33.8274ZM166.996 29.289V35.8345H165.261V29.289H166.996ZM171.355 35.8345V29.289H173.068V35.8345H171.355Z" fill="white"/>
<path d="M161.176 35.9623C160.505 35.9623 159.928 35.8202 159.446 35.5361C158.965 35.2492 158.596 34.8515 158.338 34.3429C158.082 33.8344 157.954 33.2492 157.954 32.5873C157.954 31.9168 158.083 31.3287 158.342 30.8231C158.603 30.3145 158.974 29.9182 159.454 29.6341C159.934 29.3472 160.505 29.2037 161.167 29.2037C161.738 29.2037 162.238 29.3074 162.667 29.5148C163.096 29.7222 163.436 30.0134 163.686 30.3884C163.936 30.7634 164.073 31.2037 164.099 31.7094H162.386C162.338 31.3827 162.21 31.1199 162.002 30.9211C161.798 30.7194 161.529 30.6185 161.197 30.6185C160.916 30.6185 160.67 30.6952 160.46 30.8486C160.252 30.9992 160.09 31.2194 159.974 31.5091C159.857 31.7989 159.799 32.1498 159.799 32.5617C159.799 32.9793 159.856 33.3344 159.97 33.627C160.086 33.9196 160.249 34.1427 160.46 34.2961C160.67 34.4495 160.916 34.5262 161.197 34.5262C161.404 34.5262 161.59 34.4836 161.755 34.3983C161.923 34.3131 162.061 34.1895 162.169 34.0276C162.279 33.8628 162.352 33.6654 162.386 33.4353H164.099C164.071 33.9353 163.934 34.3756 163.69 34.7563C163.448 35.1341 163.115 35.4296 162.688 35.6427C162.262 35.8557 161.758 35.9623 161.176 35.9623Z" fill="white"/>
<path d="M153.816 35.9623C153.154 35.9623 152.582 35.8216 152.099 35.5404C151.619 35.2563 151.248 34.8614 150.987 34.3557C150.725 33.8472 150.595 33.2577 150.595 32.5873C150.595 31.9111 150.725 31.3202 150.987 30.8145C151.248 30.306 151.619 29.9111 152.099 29.6299C152.582 29.3458 153.154 29.2037 153.816 29.2037C154.478 29.2037 155.049 29.3458 155.529 29.6299C156.012 29.9111 156.384 30.306 156.646 30.8145C156.907 31.3202 157.038 31.9111 157.038 32.5873C157.038 33.2577 156.907 33.8472 156.646 34.3557C156.384 34.8614 156.012 35.2563 155.529 35.5404C155.049 35.8216 154.478 35.9623 153.816 35.9623ZM153.825 34.556C154.126 34.556 154.377 34.4708 154.579 34.3003C154.781 34.127 154.933 33.8912 155.035 33.5929C155.14 33.2946 155.193 32.9552 155.193 32.5745C155.193 32.1938 155.14 31.8543 155.035 31.556C154.933 31.2577 154.781 31.0219 154.579 30.8486C154.377 30.6753 154.126 30.5887 153.825 30.5887C153.521 30.5887 153.265 30.6753 153.058 30.8486C152.853 31.0219 152.698 31.2577 152.593 31.556C152.491 31.8543 152.44 32.1938 152.44 32.5745C152.44 32.9552 152.491 33.2946 152.593 33.5929C152.698 33.8912 152.853 34.127 153.058 34.3003C153.265 34.4708 153.521 34.556 153.825 34.556Z" fill="white"/>
<path d="M148.157 35.8344L145.353 32.1143H144.723V35.8344H142.877V27.1072H144.723V30.593H145.093L147.991 27.1072H150.288L146.883 31.1555L150.446 35.8344H148.157Z" fill="white"/>
<path d="M196.076 21.9623C195.403 21.9623 194.823 21.8259 194.338 21.5532C193.855 21.2776 193.483 20.8884 193.221 20.3856C192.96 19.8799 192.829 19.2819 192.829 18.5915C192.829 17.9182 192.96 17.3273 193.221 16.8188C193.483 16.3103 193.85 15.914 194.325 15.6299C194.802 15.3458 195.362 15.2037 196.004 15.2037C196.436 15.2037 196.838 15.2733 197.21 15.4125C197.585 15.5489 197.911 15.7549 198.19 16.0304C198.471 16.306 198.69 16.6526 198.846 17.0702C199.002 17.485 199.081 17.9708 199.081 18.5276V19.0262H193.554V17.9012H197.372C197.372 17.6398 197.315 17.4083 197.201 17.2066C197.088 17.0049 196.93 16.8472 196.728 16.7336C196.529 16.6171 196.298 16.5588 196.034 16.5588C195.758 16.5588 195.514 16.6228 195.301 16.7506C195.09 16.8756 194.926 17.0446 194.806 17.2577C194.687 17.4679 194.626 17.7023 194.623 17.9608V19.0304C194.623 19.3543 194.683 19.6341 194.802 19.8699C194.924 20.1057 195.096 20.2875 195.318 20.4154C195.539 20.5432 195.802 20.6071 196.106 20.6071C196.308 20.6071 196.492 20.5787 196.66 20.5219C196.828 20.4651 196.971 20.3799 197.09 20.2662C197.21 20.1526 197.301 20.0134 197.363 19.8486L199.042 19.9594C198.957 20.3628 198.782 20.7151 198.518 21.0162C198.257 21.3145 197.919 21.5475 197.504 21.7151C197.092 21.8799 196.616 21.9623 196.076 21.9623Z" fill="white"/>
<path d="M187.492 19.4396L189.848 15.289H191.638V21.8345H189.908V17.6711L187.56 21.8345H185.757V15.289H187.492V19.4396Z" fill="white"/>
<path d="M178.726 21.8345V15.289H180.541V17.8288H181.053L182.834 15.289H184.965L182.651 18.5362L184.99 21.8345H182.834L181.219 19.512H180.541V21.8345H178.726Z" fill="white"/>
<path d="M174.641 21.9623C173.97 21.9623 173.393 21.8202 172.91 21.5361C172.43 21.2492 172.061 20.8515 171.802 20.3429C171.547 19.8344 171.419 19.2492 171.419 18.5873C171.419 17.9168 171.548 17.3287 171.807 16.8231C172.068 16.3145 172.439 15.9182 172.919 15.6341C173.399 15.3472 173.97 15.2037 174.632 15.2037C175.203 15.2037 175.703 15.3074 176.132 15.5148C176.561 15.7222 176.9 16.0134 177.15 16.3884C177.4 16.7634 177.538 17.2037 177.564 17.7094H175.851C175.802 17.3827 175.675 17.1199 175.467 16.9211C175.263 16.7194 174.994 16.6185 174.662 16.6185C174.381 16.6185 174.135 16.6952 173.925 16.8486C173.717 16.9992 173.555 17.2194 173.439 17.5091C173.322 17.7989 173.264 18.1498 173.264 18.5617C173.264 18.9793 173.321 19.3344 173.435 19.627C173.551 19.9196 173.714 20.1427 173.925 20.2961C174.135 20.4495 174.381 20.5262 174.662 20.5262C174.869 20.5262 175.055 20.4836 175.22 20.3983C175.388 20.3131 175.525 20.1895 175.633 20.0276C175.744 19.8628 175.817 19.6654 175.851 19.4353H177.564C177.535 19.9353 177.399 20.3756 177.155 20.7563C176.913 21.1341 176.579 21.4296 176.153 21.6427C175.727 21.8557 175.223 21.9623 174.641 21.9623Z" fill="white"/>
<path d="M164.885 16.7166V15.289H170.817V16.7166H168.708V21.8345H166.977V16.7166H164.885Z" fill="white"/>
<path d="M159.316 24.289C159.085 24.289 158.87 24.2705 158.668 24.2336C158.469 24.1995 158.304 24.1555 158.174 24.1015L158.583 22.7464C158.796 22.8117 158.987 22.8472 159.158 22.8529C159.331 22.8586 159.48 22.8188 159.605 22.7336C159.733 22.6484 159.837 22.5035 159.916 22.2989L160.023 22.022L157.675 15.289H159.584L160.939 20.0958H161.007L162.375 15.289H164.297L161.753 22.5418C161.631 22.8941 161.465 23.2009 161.254 23.4623C161.047 23.7265 160.784 23.9296 160.466 24.0717C160.148 24.2166 159.764 24.289 159.316 24.289Z" fill="white"/>
<path d="M151.058 21.8345V15.289H152.873V17.8288H153.385L155.166 15.289H157.297L154.983 18.5362L157.322 21.8345H155.166L153.551 19.512H152.873V21.8345H151.058Z" fill="white"/>
<path d="M149.576 21.8345H147.735V14.6157H146.491C146.136 14.6157 145.84 14.6683 145.605 14.7734C145.372 14.8756 145.197 15.0262 145.08 15.2251C144.964 15.4239 144.906 15.6683 144.906 15.958C144.906 16.245 144.964 16.485 145.08 16.6782C145.197 16.8714 145.372 17.0163 145.605 17.1129C145.838 17.2095 146.13 17.2577 146.482 17.2577H148.485V18.7407H146.184C145.517 18.7407 144.946 18.6299 144.471 18.4083C143.997 18.1867 143.634 17.8685 143.384 17.4538C143.134 17.0362 143.009 16.5376 143.009 15.958C143.009 15.3813 143.132 14.8799 143.376 14.4538C143.623 14.0248 143.98 13.6938 144.446 13.4609C144.914 13.2251 145.478 13.1072 146.137 13.1072H149.576V21.8345ZM144.867 17.8629H146.857L144.735 21.8345H142.698L144.867 17.8629Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB