Initial commit

This commit is contained in:
2026-03-16 22:05:19 +03:00
commit 8e78b8cb47
71 changed files with 7846 additions and 0 deletions

24
.gitignore vendored Executable file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

43
README.md Executable file
View File

@@ -0,0 +1,43 @@
# Автоматизированная распределенная система расчета энергии основного состояния молекул с помощью квантовых алгоритмов
Этот репозиторий содержит фронтенд-часть проекта для дипломной работы бакалавра.
## Структура репозитория
- `public/` — Публичные файлы (иконки, шрифты)
- `src/`
- `Components/` — Компоненты интерфейса
- `pages/` — Страницы приложения
- `Routes/` — Маршруты приложения
- `Stores/` — Логика для управления состоянием
- `Types/` — Определения типов для API
- `App.tsx` — Главный компонент приложения
- `GlobalVars.ts` — Глобальные переменные
- `main.tsx` — Точка входа в приложение
- `vite.config.ts` — Конфигурация для Vite
- `tsconfig.app.json` — Конфигурация TypeScript для приложения
- `tsconfig.json` — Основная конфигурация TypeScript
- `tsconfig.node.json` — Конфигурация TypeScript для Node.js (для Vite)
- `package.json` — Зависимости и скрипты проекта
- `package-lock.json` — Замороженные версии зависимостей
- `eslint.config.js` — Конфигурация для ESLint
## Установка и запуск
### Требования
- Node.js >= v24.1.0
- npm >= 11.6.2
### Установка зависимостей
```bash
npm install
```
### Компилирование и запуск сервера
```bash
npm run build
npm run preview
```

23
eslint.config.js Executable file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

14
index.html Executable file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="preload" href="/Quicking.otf" as="font" type="font/otf" crossorigin>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QMolSim</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4356
package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

43
package.json Executable file
View File

@@ -0,0 +1,43 @@
{
"name": "vkrb_frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^8.3.0",
"@mantine/hooks": "^8.3.0",
"@mantine/notifications": "^8.3.1",
"@tabler/icons-react": "^3.34.1",
"axios": "^1.13.2",
"immer": "^11.1.4",
"keycloak-js": "^26.2.3",
"miew-react": "^0.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-resizable-panels": "^4.5.4",
"react-router": "^7.9.6",
"vite-tsconfig-paths": "^5.1.4",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-helmet": "^6.1.11",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
}
}

BIN
public/Quicking.otf Executable file

Binary file not shown.

BIN
public/bitmap.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,8 @@
<!doctype html>
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

46
src/Api/ConvertBackendCalls.tsx Executable file
View File

@@ -0,0 +1,46 @@
import axios, { AxiosError } from "axios";
import type { ConvertSchema } from "Types/ApiCalls/ConvertBackendCallsTypes";
const openbableBackendRoot = "http://localhost:1654";
export async function ConvertMoleculeToStandart(
data: ConvertSchema,
): Promise<string | AxiosError> {
try {
const response = await axios.post(openbableBackendRoot + "/convert", {
text: data.inputText,
format: data.inputFormat,
convert_3d: data.make_3d,
add_hydrogen: data.add_h,
optimize_geometry: data.optimize,
});
// Response from the FastAPI JSONResponse
return response.data.molfile;
} catch (error) {
//Error handling
if (axios.isAxiosError(error)) {
// Do something with the axios error...
return error;
} else {
throw error;
}
}
}
export async function GetInFormats(): Promise<
{ [key: string]: string } | AxiosError
> {
try {
const response = await axios.get(openbableBackendRoot + "/informats");
return response.data;
} catch (error) {
//Error handling
if (axios.isAxiosError(error)) {
// Do something with the axios error...
return error;
} else {
throw error;
}
}
}

View File

@@ -0,0 +1,28 @@
import axios from "axios";
import { keycloakURI } from "GlobalVars";
import Keycloak from "keycloak-js";
const keycloak = new Keycloak({
url: keycloakURI,
realm: "dev-realm",
clientId: "react-frontend",
});
const api = axios.create({
baseURL: `${keycloak.authServerUrl}/realms/${keycloak.realm}`,
});
export default keycloak;
export async function getProfile(keycloak: Keycloak) {
return keycloak.loadUserProfile();
}
export const handleSubmit = async (profile) => {
try {
await api.put("/account", profile);
alert("Profile updated!");
} catch (err) {
console.error(err);
}
};

27
src/App.css Executable file
View File

@@ -0,0 +1,27 @@
.body {
height: 95%;
display: flex;
flex-direction: column;
position: relative;
}
#App {
height: 100svh;
}
.input {
flex: 0 1 autol;
}
#Header {
box-shadow: rgba(99, 99, 99, 0.2) 0px 0px 5px 0px;
}
#Navbar {
box-shadow: rgba(99, 99, 99, 0.2) 0px 0px 5px 0px;
}
.invisible_link {
text-decoration: inherit;
color: inherit;
}

104
src/App.tsx Executable file
View File

@@ -0,0 +1,104 @@
import "./App.css";
import { AppShell, useMantineTheme } from "@mantine/core";
import Header from "Components/Layout/Header/Header";
import "@mantine/notifications/styles.css";
import "@mantine/core/styles.css";
import { Notifications } from "@mantine/notifications";
import { useLayoutStore } from "Stores/LayoutStore";
import {
defaultAnimationDuration,
headerHeight,
sideMenuWidth,
} from "GlobalVars";
import Sidebar from "Components/Layout/Sidebar/Sidebar";
import { Outlet, useLocation } from "react-router";
import { Helmet } from "react-helmet";
import { useEffect } from "react";
import { useMediaQuery } from "@mantine/hooks";
import BreadCrumbs from "Routes/Breadcrumbs/Breadcrumbs";
import keycloak, { getProfile } from "Api/Keycloak/Keycloak";
import { AuthenticationStore } from "Stores/AuthenticationStore";
import type { KeycloakProfile } from "keycloak-js";
import { useNavigate } from "react-router";
function App() {
const { is_navbar_open, set_navbar_open } = useLayoutStore();
const { set_token, set_profile } = AuthenticationStore();
const navigate = useNavigate();
const theme = useMantineTheme();
const location = useLocation();
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.sm}`);
useEffect(() => {
if (isMobile)
setTimeout(() => {
set_navbar_open(false);
}, 100);
}, [location]);
useEffect(() => {
const refreshToken = () => {
keycloak
.updateToken(60)
.then((refreshed) => {
if (refreshed) {
set_token(keycloak.token);
}
})
.catch(() => {
keycloak.logout();
});
};
setInterval(refreshToken, 30000);
}, []);
return (
<>
<Helmet>
<title>QMolSim</title>
<meta
name="description"
content="Compute ground state energy of molecules using a distributed system"
/>
</Helmet>
<Notifications />
<AppShell
header={{ height: headerHeight }}
aside={{
width: sideMenuWidth,
breakpoint: "sm",
collapsed: {
desktop: !is_navbar_open,
mobile: !is_navbar_open,
},
}}
padding="md"
transitionDuration={defaultAnimationDuration}
>
<AppShell.Header id="Header" zIndex={1000}>
<Header />
</AppShell.Header>
<AppShell.Aside id="Navbar">
<Sidebar />
</AppShell.Aside>
<AppShell.Main className="body">
<>
<BreadCrumbs />
<Outlet />
</>
</AppShell.Main>
</AppShell>
</>
);
}
export default App;

View File

@@ -0,0 +1,58 @@
.tile {
--bgsize: 7px;
--bgoffset: 7px;
width: 25vw;
min-width: 275px;
max-width: 300px;
margin: 0px 10px;
padding: 20px 30px;
border: 1px solid;
border-color: var(--mantine-color-contrast-filled);
border-radius: var(--paper-radius);
transform: translateY(0px);
overflow: visible;
/*Move up on hover*/
&:hover {
transform: scale(105%);
}
/*Background 45 degree lines */
&::before,
&::after {
border-radius: var(--paper-radius);
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0) 33.33%,
var(--mantine-color-contrast-filled) 33.33%,
var(--mantine-color-contrast-filled) 50%,
rgba(255, 255, 255, 0) 50%,
rgba(255, 255, 255, 0) 83.33%,
var(--mantine-color-contrast-filled) 83.33%,
var(--mantine-color-contrast-filled) 100%
);
content: "";
display: block;
position: absolute;
z-index: -2;
top: var(--bgoffset);
left: var(--bgoffset);
width: 100%;
height: 100%;
background-size: var(--bgsize) var(--bgsize);
}
&::before {
top: 0px;
left: 0px;
background: var(--mantine-color-body);
z-index: -1;
}
}
/*make it so the button is on bottom of card*/
.card_text {
flex-grow: 1;
}

View File

@@ -0,0 +1,36 @@
import { Card, Text, Button, Title, Space } from "@mantine/core";
import "./CardWithButton.css";
import { Link } from "react-router";
import CustomButton from "Components/CustomButton/CustomButton";
interface ButtonWithTextProps {
title: string;
text: string;
buttonText: string;
link: string;
}
function ButtonWithText(props: ButtonWithTextProps) {
return (
<Card className="tile" shadow="sm" padding="lg" radius="md" withBorder>
<Text fw={650} mb="sm" size="md">
{props.title}
</Text>
<Text size="sm" c="dimmed" className="card_text">
{props.text}
</Text>
<Space h="md" />
<Link to={props.link} className="invisible_link">
<CustomButton
color="contrast"
text={props.buttonText}
style="color"
textAlign="center"
textSize="lg"
/>
</Link>
</Card>
);
}
export default ButtonWithText;

View File

@@ -0,0 +1,85 @@
import { Textarea } from "@mantine/core";
import { useEffect, useRef } from "react";
interface TextAreaProps {
text: string;
onTextChange: (text: string) => void;
}
export function CodeTextArea(props: TextAreaProps) {
const inputRef = useRef(null);
const lineRef = useRef(null);
//Synchronize scrolling between the
useEffect(() => {
const inputEl = document.querySelector(".MoleculeEditInput");
const lineEl = document.querySelector(".MoleculeEditLineNumber");
if (!inputEl || !lineEl) return;
let isSyncing = false; // prevents circular scroll events
let activeEl = null; // element currently being scrolled
const syncScroll = (source: Element, target: Element) => {
if (isSyncing) return;
isSyncing = true;
requestAnimationFrame(() => {
target.scrollTop = source.scrollTop;
isSyncing = false;
});
};
const onScroll = (e: Event) => {
activeEl = e.target;
if (activeEl === inputEl) {
syncScroll(inputEl, lineEl);
} else {
syncScroll(lineEl, inputEl);
}
};
inputEl.addEventListener("scroll", onScroll, { passive: true });
lineEl.addEventListener("scroll", onScroll, { passive: true });
return () => {
inputEl.removeEventListener("scroll", onScroll);
lineEl.removeEventListener("scroll", onScroll);
};
}, []);
return (
<Textarea
className="MoleculeEdit"
value={props.text}
ref={inputRef}
onChange={(e) => {
props.onTextChange(e.currentTarget.value);
}}
autoFocus
wrap="no-wrap"
leftSection={
<div className="MoleculeEditLineNumber" ref={lineRef}>
{Array.from({ length: props.text.split("\n").length }, (_, i) => {
if (i > 1) {
return i - 1;
} else {
return "";
}
}).join("\n")}
</div>
}
classNames={{
input: "MoleculeEditInput",
wrapper: "MoleculeEditWrapper",
}}
onKeyDown={(e) => {
// Stop arrow keys from reaching react-resizable-panels
if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)
) {
e.stopPropagation();
}
}}
></Textarea>
);
}

View File

@@ -0,0 +1,105 @@
.colored {
border: 2px solid transparent;
border-radius: 5px;
width: 100%;
display: flex;
padding-left: 5px;
padding-right: 5px;
}
.colored:hover {
background-color: color;
}
.textAlign-left {
text-align: left;
justify-content: left;
}
.textAlign-right {
text-align: right;
justify-content: right;
}
.textAlign-center {
text-align: center;
justify-content: center;
justify-items: center;
* {
align-content: center;
}
}
/* Variants */
.outline {
border-color: var(--color);
background: transparent;
color: var(--color);
}
.outline:hover {
background: color-mix(in srgb, var(--hovercolor) 20%, transparent);
}
/* ###################### */
.color {
background: var(--color);
color: var(--textcolor);
}
.color:hover {
background-color: var(--hovercolor);
}
.primary {
--color: var(--mantine-color-primary-filled);
--textcolor: var(--mantine-color-contrast-filled);
--hovercolor: var(--mantine-color-secondary-filled);
}
.secondary {
--color: var(--mantine-color-secondary-filled);
--textcolor: var(--mantine-color-secondaryContrast-filled);
--hovercolor: var(--mantine-color-primary-filled);
}
.contrast {
--color: var(--mantine-color-contrast-filled);
--textcolor: var(--mantine-color-primary-filled);
--hovercolor: var(--mantine-color-secondaryContrast-filled);
}
.secondary-contrast {
--color: var(--mantine-color-secondaryContrast-filled);
--textcolor: var(--mantine-color-secondary-filled);
--hovercolor: var(--mantine-color-contrast-filled);
}
.accent {
--color: var(--mantine-color-accent-filled);
--textcolor: white;
--hovercolor: var(--mantine-color-accent-filled-hover);
}
.warning {
--color: var(--mantine-color-yellow-filled);
--textcolor: white;
--hovercolor: var(--mantine-color-yellow-filled-hover);
}
.error {
--color: var(--mantine-color-red-filled);
--textcolor: white;
--hovercolor: var(--mantine-color-red-filled-hover);
}
/* ###################### */
.subtle {
color: var(--color);
}
.subtle:hover {
background-color: color-mix(in srgb, var(--color) 20%, transparent);
}

View File

@@ -0,0 +1,62 @@
import { Space, Text, UnstyledButton, type MantineSize } from "@mantine/core";
import type { ReactElement } from "react";
import "./CustomButton.css";
interface CustomButtonProps {
text?: string;
icon?: ReactElement;
style?: "outline" | "color" | "subtle";
color?:
| "primary"
| "secondary"
| "contrast"
| "secondary-cotrast"
| "accent"
| "warning"
| "error"
| string;
textAlign?: "left" | "center" | "right";
textSize?: MantineSize;
onClick?: React.MouseEventHandler;
disabled?: boolean;
}
function CustomButton({
text,
icon,
style = "color",
color = "contrast",
textAlign = "center",
textSize = "md",
onClick = () => {},
disabled = false,
}: CustomButtonProps) {
console.log(color);
return (
<UnstyledButton
className={`colored ${style} ${color} textAlign-${textAlign}`}
style={
color !== "primary" &&
color !== "secondary" &&
color !== "contrast" &&
color !== "secondary-contrast" &&
color !== "accent" &&
color !== "warning" &&
color !== "error"
? {
"--color": color,
"--textcolor": "white",
"--hovercolor": `color-mix(in srgb,${color} 90%, black)`,
}
: {}
}
onClick={onClick}
disabled={disabled}
>
{icon} {icon && <Space w="sm" />}
<Text size={textSize}>{text}</Text>
</UnstyledButton>
);
}
export default CustomButton;

View File

@@ -0,0 +1,23 @@
.logo {
height: 80%;
}
.headerLogo {
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-family: "Quicking";
letter-spacing: 1px;
cursor: pointer;
}
.header {
height: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0px 5px;
}

View File

@@ -0,0 +1,33 @@
import { Burger, Image, Text } from "@mantine/core";
import "./Header.css";
import { useLayoutStore } from "Stores/LayoutStore";
import { Link } from "react-router";
import { baseUrl } from "GlobalVars";
import { routes } from "Routes/Routes";
function Header() {
const { is_navbar_open, set_navbar_open } = useLayoutStore();
return (
<div className="header">
<Link className="headerLogo invisible_link" to={routes.MainPage.path}>
<Image
className="logo"
w="auto"
fit="contain"
src={baseUrl + "/bitmap.png"}
alt="image"
/>
<Text size="xl">QMolSim</Text>
</Link>
<Burger
aria-label="sidebar toggle"
opened={is_navbar_open}
onClick={() => {
set_navbar_open(!is_navbar_open);
}}
/>
</div>
);
}
export default Header;

View File

@@ -0,0 +1,64 @@
.sidebar {
height: 100%;
margin-top: 10px;
margin-bottom: 25px;
margin-left: 10px;
margin-right: 10px;
justify-content: space-between;
display: flex;
flex-direction: column;
}
.selected {
background-color: red;
}
.sidebar_top {
gap: 5px;
}
.sidebar_bottom {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
width: 100%;
}
.sidebar_bottom_top {
display: flex;
flex-direction: row;
align-items: center;
min-width: 0px;
margin-bottom: 10px;
}
.sidebar_bottom_bottom {
display: flex;
flex-direction: row;
width: 100%;
justify-content: space-evenly;
min-width: 0px;
gap: 10px;
}
.userInfo {
padding-left: 15px;
gap: 5px;
min-width: 0px;
}
.sidebar_bottom_button {
padding-left: 10px;
padding-right: 10px;
padding-top: 3px;
padding-bottom: 3px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.sidebar_bottom_button:hover {
background-color: var(--mantine-color-secondary-filled);
}

View File

@@ -0,0 +1,165 @@
import {
Avatar,
Button,
Divider,
Modal,
Stack,
Text,
Title,
UnstyledButton,
useMantineTheme,
} from "@mantine/core";
import "./Sidebar.css";
import {
IconDevicesPc,
IconFileDescription,
IconFlaskFilled,
IconLogout,
IconSettings2,
IconUsers,
type Icon,
type IconProps,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router";
import { useState, type ForwardRefExoticComponent } from "react";
import { routes } from "Routes/Routes";
import keycloak from "Api/Keycloak/Keycloak";
import { AuthenticationStore } from "Stores/AuthenticationStore";
import { baseUri, baseUrl } from "GlobalVars";
import CustomButton from "Components/CustomButton/CustomButton";
interface SubtleLinkButtonProps {
link: string;
Icon: ForwardRefExoticComponent<IconProps & React.RefAttributes<Icon>>;
text: string;
color: string;
selected?: boolean;
}
function SubtleLinkButton(props: SubtleLinkButtonProps) {
return (
<Link to={props.link} className="invisible_link">
<CustomButton
icon={<props.Icon color={props.color} size={20} />}
style="subtle"
color={props.selected ? props.color : "contrast"}
text={props.text}
textAlign="left"
/>
</Link>
);
}
function Sidebar() {
const { profile } = AuthenticationStore();
const [open, set_open] = useState(false);
const theme = useMantineTheme();
const location = useLocation(); // get current URL
return (
<div className="sidebar">
<Modal
opened={open}
onClose={() => set_open(false)}
title=<Title size="lg">Выйти?</Title>
centered
withCloseButton={false}
size="auto"
>
<div className="sidebar_bottom_bottom">
<CustomButton
text="Подтвердить"
onClick={() => keycloak.logout({ redirectUri: baseUri + baseUrl })}
textSize="lg"
color="contrast"
></CustomButton>
<CustomButton
onClick={() => set_open(false)}
text="Отменить"
textSize="lg"
style="outline"
color="contrast"
></CustomButton>
</div>
</Modal>
<div className="sidebar_top">
<SubtleLinkButton
link={routes.ExperimentsPage.path}
Icon={IconFlaskFilled}
text="Эксперименты"
color={theme.colors.teal[7]}
selected={location.pathname.startsWith(routes.ExperimentsPage.path)}
/>
<SubtleLinkButton
link={routes.MachinesPage.path}
Icon={IconDevicesPc}
text="Вычислительные системы"
color={theme.colors.violet[7]}
selected={location.pathname.startsWith(routes.MachinesPage.path)}
/>
<SubtleLinkButton
link={routes.TeamsPage.path}
Icon={IconUsers}
text="Команды"
color={theme.colors.grape[7]}
selected={location.pathname.startsWith(routes.TeamsPage.path)}
/>
<SubtleLinkButton
link={routes.DocumentationPage.path}
Icon={IconFileDescription}
text="Документация"
color={theme.colors.blue[7]}
selected={location.pathname.startsWith(routes.DocumentationPage.path)}
/>
</div>
<div className="sidebar_bottom">
{keycloak.authenticated && (
<>
<div className="sidebar_bottom_top">
<Avatar radius="xl" />
<Stack className="userInfo">
<Text size="md">{profile?.username}</Text>
<Text size="sm" c="dimmed" truncate="end">
{profile?.email}
</Text>
</Stack>
</div>
<Divider
color="secondaryContrast"
style={{ width: "100%", height: "5px", margin: "5px" }}
/>
<div className="sidebar_bottom_bottom">
<Link to={routes.SettingsPage.path} className="invisible_link">
<CustomButton
style="subtle"
icon={<IconSettings2 size={26} />}
text="Настройки"
color="contrast"
textSize="lg"
/>
</Link>
<CustomButton
style="subtle"
icon={<IconLogout size={26} />}
text="Выйти"
textSize="lg"
color="contrast"
onClick={() => {
set_open(true);
}}
/>
</div>
</>
)}
{!keycloak.authenticated && (
<UnstyledButton onClick={() => keycloak.login()}>
Войти / Зарегестрироваться
</UnstyledButton>
)}
</div>
</div>
);
}
export default Sidebar;

View File

@@ -0,0 +1,50 @@
.ExperimentsListCard {
height: 120px;
padding: 12px;
}
.ExperimentSectionWithLine {
border-right: 1px solid var(--mantine-color-contrast-filled);
padding-right: 10px;
}
.ExperimentPill {
background-color: var(--mantine-color-contrast-filled);
color: var(--mantine-color-primary-filled);
}
.ExperimentPill2 {
border: 2px solid var(--mantine-color-accent-filled);
background-color: transparent;
color: var(--mantine-color-accent-filled);
* {
width: 100%;
text-align: center;
}
}
.dateContainer {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: right;
gap: 5px;
.p {
text-wrap: nowrap;
}
}
.TopRightContainer {
justify-content: right;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 15px;
justify-items: center;
}
.RightExperimentSection {
text-align: right;
padding-right: 10px;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,120 @@
import { Card, Pill, SimpleGrid, Text, UnstyledButton } from "@mantine/core";
import "./ExperimentsListCard.css";
import { useNavigate } from "react-router";
import type { Experiment, TaskData } from "Types/Experiment/Experiment";
import { IconTrash } from "@tabler/icons-react";
import type { MouseEvent } from "react";
import { useExperimentStore } from "Stores/ExperimentStore";
function ExperimentsListCard(props: Experiment) {
const navigate = useNavigate();
const { selectExperiment, removeExperiment, tasks, teams } =
useExperimentStore();
const team = teams.find((team) => {
return team.id == props.team_id;
});
const experiment_tasks = tasks.filter((a) => a.id in props.tasks_ids);
const handleDelete = () => {
removeExperiment(props.id);
};
return (
<Card
className="ExperimentsListCard"
onClick={() => {
console.log(props.id);
selectExperiment(props.id);
navigate(props.id.toString());
}}
>
<SimpleGrid cols={3}>
<div className="ExperimentSectionWithLine">
<Text mb="sm" size="md" style={{ textDecorationLine: "underline" }}>
{props.name}
</Text>
<Text mb="sm" size="md">
Team: {team ? team.name : "ERROR"}
</Text>
<Pill className="ExperimentPill">
<Text mb="sm" size="md">
Статус: {props.experiment_status}
</Text>
</Pill>
</div>
<div className="ExperimentSectionWithLine">
<SimpleGrid cols={2} verticalSpacing="0px">
<Text>Задачи:</Text>
{experiment_tasks.map((task: TaskData<any>) => {
return (
<Text
size="md"
style={{
textWrap: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
{task.name}
</Text>
);
})}
{experiment_tasks.length == 0 ? (
<Pill
size="md"
className="ExperimentPill2"
style={{
textWrap: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
}}
>
Нет Задач
</Pill>
) : (
<></>
)}
</SimpleGrid>
</div>
<div className="RightExperimentSection">
<div className="TopRightContainer">
<div className="dateContainer">
<Text size="sm">{props.date_created.toLocaleDateString()} </Text>
<Text size="sm">{props.date_created.toLocaleTimeString()}</Text>
</div>
<UnstyledButton
onClick={(e: MouseEvent) => {
e.stopPropagation();
handleDelete();
}}
style={{ cursor: "pointer" }}
>
<IconTrash size={20} />
</UnstyledButton>
</div>
<div
style={{
flexGrow: 1,
display: "flex",
justifyContent: "right",
alignItems: "center",
}}
>
<Text>
{" "}
Тип эксперимента:{" "}
<Pill className="ExperimentPill2">{props.experiment_type}</Pill>
</Text>
</div>
<Text color="secondary" size="sm">
#{props.id}
</Text>
</div>
</SimpleGrid>
</Card>
);
}
export default ExperimentsListCard;

View File

@@ -0,0 +1,22 @@
.moleculeViewer {
height: 100%;
width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: auto;
position: relative;
overscroll-behavior: contain;
touch-action: none;
}
.atomEditor {
position: absolute;
border-radius: md;
right: 10px;
top: 10px;
}
.atomEditor * {
width: 100%;
height: auto;
}

View File

@@ -0,0 +1,269 @@
import Viewer from "miew-react";
import Miew from "miew";
import {
Paper,
UnstyledButton,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useEffect, useMemo, useRef, useState } from "react";
import "./MoleculeViewer.css";
import { notifications } from "@mantine/notifications";
import {
IconAlertHexagon,
IconArrowBarBoth,
IconRefresh,
} from "@tabler/icons-react";
import MoleculeViewerMenu from "./MoleculeViewerMenu";
import type { SelectedAtom } from "Types/Experiment/MoleculeEdit/MoleculeEdit";
const callbackOnResizeFinish = (
dom_elem: HTMLElement,
beginning_callback: () => void,
end_callback: () => void,
delay = 100, // ms after resize "finishes"
) => {
let timeoutId: number | undefined;
const resizeObserver = new ResizeObserver(() => {
beginning_callback();
if (timeoutId) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
end_callback();
}, delay);
});
resizeObserver.observe(dom_elem);
return () => resizeObserver.disconnect(); // cleanup helper
};
interface MoleculeViewerProps {
moleculeData: string;
selectedAtom: SelectedAtom | null;
setSelectedAtom: (selectedAtom: SelectedAtom | null) => void;
}
function MoleculeViewer(props: MoleculeViewerProps) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
// объект загруженного редактора молекул для редактирования
const [miew, setMiew] = useState<Miew | null>(null);
const molViewerRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState<boolean>(false);
const [viewingData, setViewingData] = useState<string>(props.moleculeData);
//при загрузке сохраняем объект miew
const onInitMiew = (miew: Miew) => {
setMiew(miew);
if (miew && viewingData) {
//прогружаем молекулу
miew
.load(viewingData, {
sourceType: "immediate",
fileType: "xyz",
})
.then(() => {
if (props.selectedAtom) {
props.setSelectedAtom(null);
}
})
.catch((error) => {
if (error.message == "Operation cancelled") {
return;
}
notifications.show({
color: "orange",
radius: "md",
title: "Ошибка при отображении молекулы",
message:
"Проверте правильность написания кода молекулы, в нем содержатся ошибки",
icon: <IconAlertHexagon />,
style: { paddingLeft: "5px" },
});
});
}
miew.setOptions({
settings: {
bg: {
color: theme.colors.secondaryDark[7],
},
fogAlpha: 0.7,
axes: true,
},
});
};
//мемоизируем объект чтобы он не перегружаля при изменении [miew] состояния
const MemoViewer = useMemo(
() => <Viewer onInit={onInitMiew} />,
[viewingData],
);
//Замена станартного обработчика нажатия на молекулы
useEffect(() => {
if (molViewerRef.current && miew) {
//INFO: при изменении размера окна обносить webgl
const cleanup = callbackOnResizeFinish(
molViewerRef.current,
handleResizeStart,
handleResizeEnd,
);
//@ts-expect-error Miew class not implementing typescript correctly
miew.removeEventListener("newpick");
//@ts-expect-error Miew class not implementing typescript correctly
miew.addEventListener("newpick", handleClick);
return () => {
cleanup();
//@ts-expect-error Miew class not implementing typescript correctly
miew.removeEventListener("newpick");
};
}
}, [molViewerRef, miew]);
//On selected Atom change, highlight it
useEffect(() => {
if (miew) {
if (!props.selectedAtom) {
//@ts-expect-error Miew class not implementing typescript correctly
miew.select("");
return;
}
//@ts-expect-error Miew class not implementing typescript correctly
miew.select("serial " + props.selectedAtom.serial, false);
//спрятать информацию встроенную
//@ts-expect-error Miew class not implementing typescript correctly
miew._msgAtomInfo.style.opacity = 0.0;
//@ts-expect-error Miew class not implementing typescript correctly
miew._msgAtomInfo.style.height = "0px";
//@ts-expect-error Miew class not implementing typescript correctly
miew._msgAtomInfo.style.overflow = "hidden";
}
}, [props.selectedAtom, miew]);
//при смене темы обновляем фон MoleculeViewer
useEffect(() => {
if (miew) {
miew.setOptions({
settings: {
bg: {
color: theme.colors.secondaryDark[7],
},
},
});
}
}, [colorScheme]);
//при нажятии на атом выбираем его
const handleClick = (pick: { type: string; obj: { atom: SelectedAtom } }) => {
if (miew) {
if (pick.obj.atom) {
props.setSelectedAtom(pick.obj.atom);
} else {
props.setSelectedAtom(null);
}
}
};
const handleResizeStart = () => {
setIsResizing(true);
};
const handleResizeEnd = () => {
//@ts-expect-error Miew class not implementing typescript correctly
miew._onResize();
//@ts-expect-error Miew class not implementing typescript correctly
miew._picker.handleResize();
setTimeout(() => {
setIsResizing(false);
}, 50);
};
const handleMouseEnter = () => {
document.body.style.overflow = "hidden"; // disable page scroll
};
const handleMouseLeave = () => {
document.body.style.overflow = "auto"; // re-enable scroll
};
const refreshDisplay = () => {
if (props.moleculeData != viewingData) {
setViewingData(props.moleculeData);
} else {
if (miew) {
//@ts-expect-error Miew class not implementing typescript correctly
miew.resetView();
}
}
};
return (
<div ref={molViewerRef} className="moleculeViewer">
<div
style={{
width: "100%",
height: "100%",
backgroundColor: theme.colors.secondaryDark[7],
borderRadius: "5px",
overflow: "hidden",
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{MemoViewer}
{isResizing && viewingData && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.secondaryDark[7], // optional dim
opacity: 0.5,
zIndex: 10,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<IconArrowBarBoth size={80} />
</div>
</div>
)}
</div>
{props.selectedAtom && (
<Paper className="atomEditor" radius="md" p="md">
<MoleculeViewerMenu selectedAtom={props.selectedAtom} miew={miew} />
</Paper>
)}
<div
style={{
position: "absolute",
left: "10px",
top: "10px",
display: "flex",
zIndex: 5,
width: "auto",
}}
>
<UnstyledButton onClick={refreshDisplay}>
<IconRefresh size={25} />
</UnstyledButton>
</div>
</div>
);
}
export default MoleculeViewer;

View File

@@ -0,0 +1,29 @@
import { Text, Divider } from "@mantine/core";
import Miew from "miew";
import "./MoleculeViewer.css";
import type { SelectedAtom } from "Types/Experiment/MoleculeEdit/MoleculeEdit";
interface MoleculeViewerMenuProps {
selectedAtom: SelectedAtom;
miew: Miew | null;
}
function MoleculeViewerMenu(props: MoleculeViewerMenuProps) {
return (
<>
<div className="moleculeViewerMenu">
<>
<Text ta="center">
Атом: {props.selectedAtom.name} ({props.selectedAtom.serial})
</Text>
<Divider my="sm" />
<Text ta="center">Положение X: {props.selectedAtom.position.x}</Text>
<Text ta="center">Положение Y: {props.selectedAtom.position.y}</Text>
<Text ta="center">Положение Z: {props.selectedAtom.position.z}</Text>
</>
</div>
</>
);
}
export default MoleculeViewerMenu;

View File

@@ -0,0 +1,16 @@
.PaginationContainer {
width: 100%;
flex: 1;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.PaginationContents {
flex-grow: 1;
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@@ -0,0 +1,12 @@
import { Pagination } from "@mantine/core";
import "./PaginationContainer.css";
import type { PropsWithChildren } from "react";
export function PaginationContainer(a: PropsWithChildren) {
return (
<div className="PaginationContainer">
<div className="PaginationContents">{a.children}</div>
<Pagination total={10} color="accent" />
</div>
);
}

View File

@@ -0,0 +1,45 @@
.background-light {
width: 100%;
height: 100%;
overflow: hidden;
background: white
linear-gradient(
135deg,
rgba(110, 156, 223, 0.125) 0%,
rgba(110, 156, 223, 0.125) 14.286%,
rgba(110, 156, 223, 0.25) 14.286%,
rgba(110, 156, 223, 0.25) 28.571%,
rgba(110, 156, 223, 0.375) 28.571%,
rgba(110, 156, 223, 0.375) 42.857%,
rgba(110, 156, 223, 0.5) 42.857%,
rgba(110, 156, 223, 0.5) 57.143%,
rgba(110, 156, 223, 0.725) 57.143%,
rgba(110, 156, 223, 0.725) 71.429%,
rgba(110, 156, 223, 0.85) 71.429%,
rgba(110, 156, 223, 0.85) 85.714%,
rgba(110, 156, 223, 1) 85.714% 100%
);
}
.background-dark {
width: 100%;
height: 100%;
overflow: hidden;
background: black
linear-gradient(
135deg,
rgba(36, 54, 162, 0.353) 0%,
rgba(36, 54, 162, 0.353) 14.286%,
rgba(36, 54, 162, 0.5) 14.286%,
rgba(36, 54, 162, 0.5) 28.571%,
rgba(36, 54, 162, 0.612) 28.571%,
rgba(36, 54, 162, 0.612) 42.857%,
rgba(36, 54, 162, 0.707) 42.857%,
rgba(36, 54, 162, 0.707) 57.143%,
rgba(36, 54, 162, 0.851) 57.143%,
rgba(36, 54, 162, 0.851) 71.429%,
rgba(36, 54, 162, 0.921) 71.429%,
rgba(36, 54, 162, 0.921) 85.714%,
rgba(36, 54, 162, 1) 85.714% 100%
);
}

View File

@@ -0,0 +1,13 @@
import { useMantineColorScheme } from "@mantine/core";
import "./Background.css";
function Background() {
const { colorScheme } = useMantineColorScheme();
return (
<div
className={colorScheme == "dark" ? "background-dark" : "background-light"}
/>
);
}
export default Background;

11
src/GlobalVars.ts Executable file
View File

@@ -0,0 +1,11 @@
export const baseUrl = "/quantum";
export const baseDomain = "localhost";
export const basePort = 8001;
export const keycloakURI = "http://auth.localhost";
export const baseUri = `http://${baseDomain}:${basePort}`;
//AppShell vars
export const headerHeight = 50;
export const sideMenuWidth = 280;
// Global Style vars
export const defaultAnimationDuration = 100;

View File

View File

@@ -0,0 +1,116 @@
import {
Button,
Center,
Modal,
Select,
Space,
Textarea,
TextInput,
Title,
UnstyledButton,
} from "@mantine/core";
import { useEffect, useState, type ChangeEvent } from "react";
import "./NewExperiment.css";
import { useExperimentStore } from "Stores/ExperimentStore";
import CustomButton from "Components/CustomButton/CustomButton";
interface NewExperimentModalProps {
isOpened: boolean;
setIsOpened: (opened: boolean) => void;
}
export function NewExperimentModal(props: NewExperimentModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const { addExperiment } = useExperimentStore();
//reset on open dialog
useEffect(() => {
if (props.isOpened) {
setName("");
setDescription("");
}
}, [props.isOpened]);
const handleClose = () => {
props.setIsOpened(false);
};
const handleCreateExperiment = () => {
//TODO: add logic for backend server
addExperiment({
id: 1,
name: name,
description: description,
team_id: 1,
tasks_ids: [],
date_created: new Date(),
experiment_status: "DRAFT",
experiment_type: "a",
});
props.setIsOpened(false);
};
return (
<Modal
opened={props.isOpened}
onClose={handleClose}
title=<Title size="xl">Новый эксперимент</Title>
centered
size="75%"
styles={{
content: { paddingLeft: "10px" },
title: { width: "100%" },
}}
>
<TextInput
value={name}
label="Имя эксперимента"
placeholder="Введите имя эксперимента"
required
onChange={(event) => {
setName(event.currentTarget.value);
}}
></TextInput>
<Space h="md" />
<Textarea
value={description}
label="Описание эксперимента"
placeholder="Введите описание экспериментаё"
minRows={4}
maxRows={10}
autosize
onChange={(event) => {
setDescription(event.currentTarget.value);
}}
></Textarea>
<Space h="md" />
<Select
value=""
label="Команда эксперимента"
placeholder="Выберите команду эксперимента"
searchable
data={[]}
onChange={() => {}}
/>
<Space h="md" />
<Center>
<CustomButton
disabled={name != "" ? false : true}
color="contrast"
onClick={handleCreateExperiment}
text="Создать эксперимент"
></CustomButton>
<Space w="md" />
<CustomButton
color="contrast"
style="outline"
onClick={() => {
props.setIsOpened(false);
}}
text="Отменить"
></CustomButton>
</Center>
</Modal>
);
}

View File

@@ -0,0 +1,3 @@
.StepperRoot {
flex-direction: row-reverse !important;
}

View File

@@ -0,0 +1,243 @@
import {
Button,
Center,
Modal,
Space,
Stepper,
Title,
Text,
Flex,
Select,
Textarea,
} from "@mantine/core";
import { useEffect, useState } from "react";
import "./NewMolecule.css";
import {
IconArchiveFilled,
IconArticleFilled,
IconFileFilled,
} from "@tabler/icons-react";
import {
ConvertMoleculeToStandart,
GetInFormats,
} from "Api/ConvertBackendCalls";
import { AxiosError } from "axios";
import { useMoleculeEditStore } from "Stores/MoleculeEditStore";
import CustomButton from "Components/CustomButton/CustomButton";
interface NewMoleculeModalProps {
isOpened: boolean;
setIsOpened: (opened: boolean) => void;
}
export function NewMoleculeModal(props: NewMoleculeModalProps) {
const { setCurrentMoleculeString } = useMoleculeEditStore();
const [activeStep, setActiveStep] = useState(0);
const [selectedMethod, setSelectedMethod] = useState(0);
const [inMolecule, setInMolecule] = useState("");
const [inFormat, setInFormat] = useState<string | null>();
const [inOptions, setInOptions] = useState<string[] | undefined>();
const getOptions = () => {
GetInFormats().then((data: { [key: string]: string } | AxiosError) => {
if (data instanceof AxiosError) {
//TODO: handle Error
} else {
const values = Object.keys(data);
setInOptions(values);
}
});
};
useEffect(() => {
getOptions();
}, []);
//reset on open dialog
useEffect(() => {
if (props.isOpened) {
setActiveStep(0);
setSelectedMethod(0);
setInMolecule("");
setInFormat(undefined);
if (inOptions == undefined) {
getOptions();
}
}
}, [props.isOpened]);
const handleClose = () => {
props.setIsOpened(false);
};
const handleConvert = () => {
if (inFormat) {
ConvertMoleculeToStandart({
inputText: inMolecule,
inputFormat: inFormat,
make_3d: true,
add_h: false,
optimize: false,
}).then((data: string | AxiosError) => {
if (data instanceof AxiosError) {
//TODO: handle Error
} else {
setCurrentMoleculeString(data);
props.setIsOpened(false);
}
});
} else {
//TODO: add no format selected handling
}
};
const handleEmptyMolecule = () => {
props.setIsOpened(false);
};
return (
<Modal
opened={props.isOpened}
onClose={handleClose}
title=<Title size="xl">Новая молекула</Title>
centered
size="75%"
styles={{
content: { paddingLeft: "10px" },
title: { width: "100%" },
}}
>
<Stepper
active={activeStep}
onStepClick={setActiveStep}
allowNextStepsSelect={false}
classNames={{ root: "StepperRoot" }}
styles={{
steps: {
paddingTop: "30px",
paddingBottom: "30px",
paddingLeft: "20px",
minWidth: "0px",
alignSelf: "center",
},
content: {
flex: 1,
},
}}
style={{ display: "flex", flexDirection: "row" }}
orientation="vertical"
>
<Stepper.Step>
<div style={{ width: "100%", textAlign: "center" }}>
<Title size="lg">Шаг 1: Способ добавления молекулы</Title>
<Space h="lg" />
<Flex justify="center" gap="md" wrap="wrap">
<Button
onClick={() => {
setSelectedMethod(1);
handleEmptyMolecule();
}}
style={{ height: "100px", flexGrow: "1" }}
size="lg"
>
<Flex direction="row" gap="lg">
<IconFileFilled size={40} />
<Center>
<Text>Пустая молекула</Text>
</Center>
</Flex>
</Button>
<Button
onClick={() => {
setSelectedMethod(2);
setActiveStep(1);
}}
style={{ height: "100px", flexGrow: "1" }}
>
<Flex direction="row" gap="lg">
<IconArchiveFilled size="40" />
<Center>
<Text>Из файла / Архива</Text>
</Center>
</Flex>
</Button>
<Button
onClick={() => {
setSelectedMethod(3);
setActiveStep(1);
}}
style={{ height: "100px", flexGrow: "1" }}
>
<Flex direction="row" gap="lg">
<IconArticleFilled size={40} />
<Center>
<Text>Другой формат</Text>
</Center>
</Flex>
</Button>
</Flex>
</div>
</Stepper.Step>
<Stepper.Step>
<div style={{ width: "100%", textAlign: "center" }}>
{selectedMethod == 2 && (
<div>
<Title size="lg">Шаг 2: Выбор файла</Title>
<Space h="lg" />
</div>
)}
{selectedMethod == 3 && (
<div>
<Title size="md">Шаг 2: Ввод молекулы</Title>
<Space h="lg" />
<Textarea
className="moleculeInput"
value={inMolecule}
onChange={(e) => setInMolecule(e.currentTarget.value)}
placeholder="Введите текст молекулы"
classNames={{
wrapper: "moleculeInputWrapper",
}}
onKeyDown={(e) => {
// Stop arrow keys from reaching react-resizable-panels
if (
[
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
].includes(e.key)
) {
e.stopPropagation();
}
}}
/>
<Space h="md" />
<Select
value={inFormat}
onChange={(value: string | null) => setInFormat(value)}
searchable
data={inOptions}
placeholder="Выберите формат"
classNames={{
input: "selectInFormat",
dropdown: "selectDropDown",
option: "selectDropDownOption",
}}
/>
<Space h="md" />
<CustomButton
color="accent"
onClick={handleConvert}
text="Преобразовать"
/>
</div>
)}
</div>
</Stepper.Step>
</Stepper>
</Modal>
);
}

View File

@@ -0,0 +1,26 @@
.DocumentationPage {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
padding-top: 25px;
}
.contents {
flex-grow: 1;
}
.tableOfContents {
position: sticky;
top: 60px;
width: 150px;
height: min-content;
}
.docTitle {
padding-bottom: 50px;
}
.hoverCard {
position: fixed;
bottom: 10px;
}

View File

@@ -0,0 +1,103 @@
import {
ActionIcon,
Box,
Divider,
Menu,
TableOfContents,
Title,
} from "@mantine/core";
import "./DocumentationPage.css";
import { IconMenu2 } from "@tabler/icons-react";
import { Helmet } from "react-helmet";
function DocumentationPage() {
return (
<>
<Helmet>
<title>Documentation | QMolSim</title>
<meta
name="description"
content="See the documentation on how to setup and use the qunatum computational system"
/>
</Helmet>
<Title order={1} className="docTitle">
Документация
</Title>
<Divider />
<div className="DocumentationPage">
<Box className="tableOfContents" visibleFrom="md">
<TableOfContents
variant="light"
color="accent"
size="sm"
radius="sm"
scrollSpyOptions={{
selector: "section h2",
}}
getControlProps={({ data }) => ({
onClick: () =>
data
.getNode()
.scrollIntoView({ behavior: "smooth", block: "center" }),
children: data.value,
})}
/>
</Box>
<Box className="hoverCard" hiddenFrom="md">
<Menu
width={280}
shadow="md"
openDelay={100}
closeDelay={100}
closeOnClickOutside
closeOnItemClick
floatingStrategy="fixed"
>
<Menu.Target>
<ActionIcon
aria-label="navigation"
variant="gradient"
gradient={{ from: "blue", to: "cyan", deg: 90 }}
radius={"50%"}
size={40}
>
<IconMenu2 size={25} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<TableOfContents
variant="light"
color="accent"
size="sm"
radius="sm"
scrollSpyOptions={{
selector: "section h2",
}}
getControlProps={({ data }) => ({
onClick: () =>
data
.getNode()
.scrollIntoView({ behavior: "smooth", block: "center" }),
children: data.value,
})}
/>
</Menu.Dropdown>
</Menu>
</Box>
<div className="contents">
<section id="introduction" style={{ height: 1000 }}>
<Title order={2}>Introduction</Title>
</section>
<section id="features" style={{ height: 1000 }}>
<Title order={2}>features</Title>
</section>
<section id="conclusion" style={{ height: 1000 }}>
<Title order={2}>Conslusion</Title>
</section>
</div>
</div>
</>
);
}
export default DocumentationPage;

View File

@@ -0,0 +1,16 @@
.ExperimentPage {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.experimentButtons {
display: flex;
gap: 20px;
flex-direction: row;
justify-content: right;
width: fit-content;
margin-left: auto;
flex-wrap: nowrap;
text-wrap: nowrap;
}

View File

@@ -0,0 +1,108 @@
import { Alert, Center, Text } from "@mantine/core";
import "./ExperimentPage.css";
import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer";
import { NewMoleculeModal } from "Modals/NewMolecule/NewMolecule";
import { useState } from "react";
import { Helmet } from "react-helmet";
import { IconPlus, IconSettings } from "@tabler/icons-react";
import { useParams } from "react-router";
import {
useExperimentStore,
useSelectedExperiment,
} from "Stores/ExperimentStore";
import CustomButton from "Components/CustomButton/CustomButton";
function ExperimentPage() {
const [isOpen, setIsOpen] = useState(false);
const { experiment_id } = useParams();
const { selectedExperimentId, selectExperiment } = useExperimentStore();
if (
!selectedExperimentId ||
(Number(experiment_id) != selectedExperimentId && experiment_id)
) {
console.log(Number(experiment_id));
selectExperiment(Number(experiment_id));
}
const experiment = useSelectedExperiment();
return (
<>
<Helmet>
<title>
{experiment
? "Experiment " + experiment.id + " | QMolSim"
: "Error |QmolSim"}
</title>
<meta
name="description"
content="See the documentation on how to setup and use the qunatum computational system"
/>
</Helmet>
{experiment && (
<div className="ExperimentPage">
<NewMoleculeModal
isOpened={isOpen}
setIsOpened={(toOpen: boolean) => setIsOpen(toOpen)}
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
marginBottom: "15px",
}}
>
<div className="experimentButtons">
<CustomButton
color="contrast"
onClick={() => {
setIsOpen(true);
}}
icon={<IconPlus />}
text="Добавить задачу"
/>
<CustomButton
color="contrast"
style="outline"
onClick={() => {
setIsOpen(true);
}}
icon={<IconSettings />}
text="Параметры эксперимента"
/>
</div>
</div>
{experiment.tasks_ids.length > 0 && (
<PaginationContainer>
{experiment.tasks_ids.map((task: ExperimentTask) => {
return <div>{task.data.name}</div>;
})}
</PaginationContainer>
)}
{experiment.tasks_ids.length == 0 && (
<Alert>
<Center>
<Text size={"xl"} c="contrast">
Нет задач
</Text>
</Center>
</Alert>
)}
</div>
)}
{!experiment && (
<Alert color="red">
<Center>
{" "}
<Text c="contrast" size={"xl"} size="md">
Ошибка. Эксперимент не найден
</Text>{" "}
</Center>
</Alert>
)}
</>
);
}
export default ExperimentPage;

View File

@@ -0,0 +1,14 @@
.ExperimentsPage {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.experimentsButtons {
display: flex;
gap: 20px;
flex-direction: row;
justify-content: right;
width: fit-content;
margin-left: auto;
}

View File

@@ -0,0 +1,67 @@
import { Helmet } from "react-helmet";
import "./ExperimentsPage.css";
import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer";
import { useState } from "react";
import { IconMicroscope } from "@tabler/icons-react";
import { NewExperimentModal } from "Modals/NewExperiment/NewExperiment";
import ExperimentsListCard from "Components/ListCard/ExperimentsListCard";
import { useExperimentStore } from "Stores/ExperimentStore";
import type { Experiment } from "Types/Experiment/Experiment";
import CustomButton from "Components/CustomButton/CustomButton";
function ExperimentsPage() {
const [isOpen, setIsOpen] = useState(false);
const { experiments, teams, tasks } = useExperimentStore();
return (
<>
<Helmet>
<title>Experiments Page | QMolSim</title>
<meta
name="description"
content="See the list of all of users experiments"
/>
</Helmet>
<NewExperimentModal isOpened={isOpen} setIsOpened={setIsOpen} />
<div className="ExperimentsPage">
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
marginBottom: "20px",
}}
>
<div className="experimentsButtons">
<CustomButton
color="contrast"
onClick={() => {
setIsOpen(true);
}}
icon={<IconMicroscope />}
text="Создать эксперимент"
/>
</div>
</div>
<PaginationContainer>
{experiments.map((exp: Experiment) => {
return (
<ExperimentsListCard
id={exp.id}
name={exp.name}
description={exp.description}
team_id={exp.team_id}
tasks_ids={exp.tasks_ids}
date_created={exp.date_created}
experiment_status={exp.experiment_status}
experiment_type="A"
/>
);
})}
</PaginationContainer>
</div>
</>
);
}
export default ExperimentsPage;

View File

@@ -0,0 +1,65 @@
.MoleculeEdit {
height: 100%;
display: flex;
}
.MoleculeEditWrapper {
display: flex !important;
flex: 1 !important;
min-width: 0;
min-height: 0;
background-color: var(--mantine-color-secondary-filled);
}
.MoleculeEditLineNumber {
height: 100%;
padding-top: calc(1px + var(--input-padding-y, 0rem));
padding-bottom: calc(1px + var(--input-padding-y, 0rem));
font-family:
"Fira Code", "Courier New", Courier, monospace; /* Monospace fonts */
font-size: 14px; /* Comfortable size */
line-height: 1.5; /* Spacing like an editor */
letter-spacing: 0; /* Keeps punctuation aligned */
white-space: pre-line;
overflow: scroll;
scrollbar-width: none;
overscroll-behavior: none;
cursor: default;
}
.MoleculeEditInput {
font-family:
"Fira Code", "Courier New", Courier, monospace; /* Monospace fonts */
font-size: 14px; /* Comfortable size */
line-height: 1.5; /* Spacing like an editor */
letter-spacing: 0; /* Keeps punctuation aligned */
white-space: pre; /* preserves spaces & tabs */
overflow-x: auto; /* horizontal scroll when needed */
overflow-y: auto; /* vertical scroll when needed */
word-wrap: normal; /* prevent wrapping */
overscroll-behavior: none;
background-color: var(--mantine-color-secondary-filled);
}
.Separator {
width: 10px;
}
.moleculeInput {
height: 250px;
}
.moleculeInputWrapper {
height: 100%;
}
.moleculeInputWrapper > * {
height: 100%;
}
.tabPanel {
flex: 1;
padding: 10px;
border-left: 1px solid var(--tab-border-color);
border-right: 1px solid var(--tab-border-color);
border-bottom: 1px solid var(--tab-border-color);
}

View File

@@ -0,0 +1,113 @@
import MoleculeViewer from "Components/MoleculeViewer/MoleculeViewer";
import {
Button,
Center,
Divider,
Tabs,
Text,
useMantineTheme,
} from "@mantine/core";
import { Group, Panel, Separator } from "react-resizable-panels";
import "./MoleculePage.css";
import {
IconCheck,
IconCode,
IconGripVertical,
IconList,
IconX,
} from "@tabler/icons-react";
import { useMoleculeEditStore } from "Stores/MoleculeEditStore";
import { CodeTextArea } from "Components/CodeTextArea/CodeTextArea";
import { Helmet } from "react-helmet";
function MoleculeEditorPage() {
const {
currentMoleculeString,
setCurrentMoleculeString,
selectedAtom,
setSelectedAtom,
} = useMoleculeEditStore();
const theme = useMantineTheme();
return (
<>
<Helmet>
<title>Molecule Page | QMolSim</title>
<meta
name="description"
content="Edit the atomic structure of a molecule"
/>
</Helmet>
<div className="experimentButtons">
<Button color="secondary" onClick={() => {}}>
<IconCheck style={{ marginRight: "10px" }} />
<Text c="contrast">Сохранить</Text>
</Button>
<Button color="contrast" onClick={() => {}}>
<IconX style={{ marginRight: "10px" }} color="black" />
<Text c="secondary">Отменить</Text>
</Button>
</div>
<div style={{ display: "flex", flexDirection: "column", height: "85vh" }}>
<Divider style={{ margin: "10px" }} />
<Group style={{ flex: 1, gap: "5px" }}>
<Panel minSize={200} defaultSize={500}>
<Tabs
variant="outline"
style={{
height: "100%",
display: "flex",
flexDirection: "column",
}}
radius="lg"
defaultValue="code"
classNames={{ panel: "tabPanel" }}
>
<Tabs.List>
<Tabs.Tab value="code" leftSection={<IconCode size={12} />}>
Код
</Tabs.Tab>
<Tabs.Tab value="list" leftSection={<IconList size={12} />}>
Список
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="code">
<CodeTextArea
text={currentMoleculeString}
onTextChange={(text: string) => {
setCurrentMoleculeString(text);
}}
/>
</Tabs.Panel>
<Tabs.Panel value="list">
<></>
</Tabs.Panel>
</Tabs>
</Panel>
<Separator>
<Center
style={{
height: "100%",
background: theme.colors.dark[4],
borderRadius: "5px",
}}
>
<IconGripVertical size={12} />
</Center>
</Separator>
<Panel style={{ flexShrink: 0 }} minSize={200}>
<MoleculeViewer
moleculeData={currentMoleculeString}
selectedAtom={selectedAtom}
setSelectedAtom={setSelectedAtom}
/>
</Panel>
</Group>
</div>
</>
);
}
export default MoleculeEditorPage;

41
src/Pages/MainPage/MainPage.css Executable file
View File

@@ -0,0 +1,41 @@
.background_holder {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
z-index: 0;
}
.slogan {
width: 100%;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5vh;
padding-bottom: 10vh;
z-index: 1;
}
.slogan > * {
font-family: "Quicking";
text-align: center;
color: var(--mantine-color-contrast-filled);
z-index: 1;
}
.tiles {
width: 100%;
align-items: stretch;
gap: 40px 0px;
}
#documentationButton {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 10vh;
text-decoration: underline;
}

63
src/Pages/MainPage/MainPage.tsx Executable file
View File

@@ -0,0 +1,63 @@
import { Title, Button, Group } from "@mantine/core";
import Background from "Components/StripedBg/Background";
import "./MainPage.css";
import { Helmet } from "react-helmet";
import ButtonWithText from "Components/CardWithButton/CardWithButton";
import { Link } from "react-router";
import { routes } from "Routes/Routes";
function MainPage() {
return (
<div>
<Helmet>
<title>Landing Page | QMolSim</title>
<meta
name="description"
content="Description of the main functionaity of the QMolSim service"
/>
</Helmet>
<div className="background_holder">
<Background />
</div>
<div className="slogan">
<Title size={50} textWrap="balance">
Quantum Chemistry Made easy
</Title>
</div>
<Group className="tiles" justify="space-evenly">
<ButtonWithText
title="Работа с молекулами"
text="Вводите информацию о молекулах любым удобным способом. Визуализируйте и
редактируйте молекулы в простом интерфейсе."
buttonText="Начать Эксперимент"
link={routes.ExperimentsPage.path}
/>
<ButtonWithText
title="Параллельные вычисления"
text="Запускайте квантовые вычисления сразу на нескольких машинах.
Упарвляйте и следите за машинами в одном месте."
buttonText="Подключить Машину"
link={routes.MachinesPage.path}
/>
<ButtonWithText
title="Работа в команде"
text="Создавайте команы с другими людьми и настраивайте доступ ко всем
аспектам работы"
buttonText="Создать Команду"
link={routes.TeamsPage.path}
/>
</Group>
<Link className="invisible_link" to={routes.DocumentationPage.path}>
<div id="documentationButton">
<Button variant="subtle" color="secondaryContrast">
&nbsp;Документация&nbsp;
</Button>
</div>
</Link>
</div>
);
}
export default MainPage;

View File

@@ -0,0 +1,4 @@
.TeamsPage {
flex-grow: 1;
display: flex;
}

View File

@@ -0,0 +1,22 @@
import { Helmet } from "react-helmet";
import "./TeamsPage.css";
import { PaginationContainer } from "Components/PaginationContainer/PaginationContainer";
function TeamsPage() {
return (
<>
<Helmet>
<title>Teams Page | QMolSim</title>
<meta
name="description"
content="See the documentation on how to setup and use the qunatum computational system"
/>
</Helmet>
<div className="TeamsPage">
<PaginationContainer />
</div>
</>
);
}
export default TeamsPage;

View File

@@ -0,0 +1,12 @@
.tabPanel {
flex-grow: 1;
padding: 10px;
border-left: 1px solid var(--tab-border-color);
border-right: 1px solid var(--tab-border-color);
border-bottom: 1px solid var(--tab-border-color);
}
.userPage {
flex-grow: 1;
display: flex;
}

View File

@@ -0,0 +1,166 @@
import {
Button,
Title,
Tabs,
TextInput,
SimpleGrid,
Chip,
Text,
Divider,
Switch,
} from "@mantine/core";
import {
IconChartCandle,
IconCheck,
IconUserFilled,
IconCancel,
IconSun,
IconMoonStars,
} from "@tabler/icons-react";
import keycloak from "Api/Keycloak/Keycloak";
import { useEffect } from "react";
import { Helmet } from "react-helmet";
import { AuthenticationStore } from "Stores/AuthenticationStore";
import "./UserPage.css";
import { useUserPreferencesStore } from "Stores/PreferencesStore";
import CustomButton from "Components/CustomButton/CustomButton";
function UserPage() {
const { profile, token } = AuthenticationStore();
const { theme, set_theme } = useUserPreferencesStore();
useEffect(() => {
console.log(token);
}, []);
return (
<>
<Helmet>
<title>User Page | QMolSim</title>
<meta
name="description"
content="See the documentation on how to setup and use the qunatum computational system"
/>
</Helmet>
<div className="userPage">
<Tabs
variant="outline"
style={{
display: "flex",
flexGrow: 1,
flexDirection: "column",
}}
radius="lg"
defaultValue="profile"
classNames={{ panel: "tabPanel" }}
>
<Tabs.List>
<Tabs.Tab
value="profile"
leftSection={<IconUserFilled size={20} />}
>
Профиль
</Tabs.Tab>
<Tabs.Tab
value="preference"
leftSection={<IconChartCandle size={20} />}
>
Предпочтения
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile">
<SimpleGrid verticalSpacing="lg" cols={2}>
<Title size="lg">Имя пользователя:</Title>
<TextInput size="md" value={profile?.username} />
<Title size="lg">Почта:</Title>
<TextInput size="md" value={profile?.email} />
<CustomButton color="accent" text="Сохранить" />
<CustomButton color="error" text="Отменить" />
</SimpleGrid>
<Divider my="lg" />
<SimpleGrid verticalSpacing={"lg"} cols={2}>
<Title
size="lg"
style={{ display: "flex", alignItems: "center" }}
>
Почта верифицирована:{" "}
<Chip mx="lg" size="auto" checked={profile?.emailVerified}>
{profile?.emailVerified ? (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<IconCheck
size={25}
color={"green"}
style={{ verticalAlign: "center", margin: "5px" }}
/>
</div>
) : (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<IconCancel
size={25}
color="red"
style={{
verticalAlign: "center",
margin: "5px",
}}
/>
</div>
)}
</Chip>
</Title>
{!profile?.emailVerified && (
<CustomButton
style="outline"
color="contrast"
text="Подтвердить почту"
/>
)}
{profile?.emailVerified && <div></div>}
<Title size="lg">Пароль:</Title>
<CustomButton
style="outline"
color="contrast"
text="Изменить пароль"
/>
</SimpleGrid>
</Tabs.Panel>
<Tabs.Panel value="preference">
<Switch
size="xl"
onChange={(event) =>
set_theme(!event.currentTarget.checked ? "dark" : "light")
}
onLabel={
<IconSun
size={16}
stroke={2.5}
color="var(--mantine-color-yellow-4)"
/>
}
offLabel={
<IconMoonStars
size={16}
stroke={2.5}
color="var(--mantine-color-blue-6)"
/>
}
/>
</Tabs.Panel>
</Tabs>
</div>
</>
);
}
export default UserPage;

View File

@@ -0,0 +1,10 @@
.mantine-Breadcrumbs-root {
height: fit-content;
z-index: 5;
color: var(--mantine-color-secondaryContrast-filled) !important;
}
.highlighted_breadcrumb {
color: var(--mantine-color-contrast-filled);
text-decoration: underline;
}

View File

@@ -0,0 +1,104 @@
import { Breadcrumbs } from "@mantine/core";
import { type ReactElement } from "react";
import { Link, type UIMatch } from "react-router";
import "./Breadcrumbs.css";
import { routes } from "Routes/Routes";
import { useLocation } from "react-router";
function getSubPaths(path: string) {
const parts = path.split("/").filter(Boolean);
const result = ["/"];
let current = "";
for (const part of parts) {
current += "/" + part;
result.push(current);
}
return result;
}
function testEqual(path: string, pattern: string) {
const patternParts = pattern.split("/").filter(Boolean);
const pathParts = path.split("/").filter(Boolean);
// Length must match
if (patternParts.length !== pathParts.length) {
return false;
}
for (let i = 0; i < patternParts.length; i++) {
const patternPart = patternParts[i];
const pathPart = pathParts[i];
// If pattern starts with ":" it's a variable → always matches
if (patternPart.startsWith(":")) {
continue;
}
// Otherwise must match exactly
if (patternPart !== pathPart) {
return false;
}
}
return true;
}
function BreadCrumbs() {
const unique_matches: string[] = getSubPaths(useLocation().pathname);
console.log(unique_matches);
//find the breadcrumbs for the matched pathes
const elements: ReactElement[] = [];
for (const prop in routes) {
for (const u_match in unique_matches) {
if (testEqual(unique_matches[u_match], routes[prop].path)) {
console.log(unique_matches[u_match], routes[prop].path);
for (const i in routes[prop].breadcrumbs(unique_matches[u_match])) {
if (elements.length + 1 != unique_matches.length) {
elements.push(
<Link
className="invisible_link"
to={unique_matches[u_match]}
key={unique_matches[u_match]}
>
{routes[prop].breadcrumbs(unique_matches[u_match])[i]}
</Link>,
);
} else {
elements.push(
<div
className="highlighted_breadcrumb"
key={unique_matches[u_match]}
>
{routes[prop].breadcrumbs(unique_matches[u_match])[i]}
</div>,
);
}
}
//last breadcrumb
}
}
}
//wrong count of breadcrumbs found, meaning error
if (elements.length != unique_matches.length) {
elements.push(
<div className="highlighted_breadcrumb" key={routes.ErrorPage.path}>
{routes.ErrorPage.breadcrumbs("")}
</div>,
);
}
const isGood = elements.length > 1;
return (
<Breadcrumbs separator="/" mb="xs">
{isGood && elements}
{!isGood && <p></p>}
</Breadcrumbs>
);
}
export default BreadCrumbs;

17
src/Routes/ErrorPage.tsx Executable file
View File

@@ -0,0 +1,17 @@
import { Alert } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
function ErrorPage() {
return (
<Alert
variant="filled"
color="red"
title="Page not found"
icon={<IconExclamationCircle />}
>
The page you are trying to access does not exist or has been moved.
</Alert>
);
}
export default ErrorPage;

View File

@@ -0,0 +1,23 @@
import keycloak from "Api/Keycloak/Keycloak";
import { useEffect, useRef } from "react";
import { Outlet } from "react-router";
export default function AuthGuard() {
const loginStarted = useRef(false);
useEffect(() => {
if (!keycloak.authenticated && !loginStarted.current) {
loginStarted.current = true;
keycloak.login({
redirectUri: window.location.href,
});
}
}, []);
if (!keycloak.authenticated) {
return <div>Redirecting to login...</div>;
}
return <Outlet />;
}

99
src/Routes/Routes.tsx Executable file
View File

@@ -0,0 +1,99 @@
import { createBrowserRouter } from "react-router";
import MainPage from "Pages/MainPage/MainPage";
import { baseUrl } from "../GlobalVars";
import App from "../App";
import ErrorPage from "./ErrorPage";
import DocumentationPage from "Pages/DocumentationPage/DocumentationPage";
import { IconHome } from "@tabler/icons-react";
import type { ReactElement } from "react";
import MoleculeEditorPage from "Pages/ExperimentsPage/MoleculePage";
import TeamsPage from "Pages/TeamsPage/TeamsPage";
import UserPage from "Pages/UserPage/UserPage";
import ExperimentPage from "Pages/ExperimentsPage/ExperimentPage";
import ExperimentsPage from "Pages/ExperimentsPage/ExperimentsPage";
import AuthGuard from "./RouterAuhGuard";
export const routes: {
[id: string]: { path: string; breadcrumbs: (path: string) => ReactElement[] };
} = {
MainPage: { path: "/", breadcrumbs: () => [<IconHome />] },
DocumentationPage: {
path: "/documentation",
breadcrumbs: () => [<>Документация</>],
},
ExperimentsPage: {
path: "/experiments",
breadcrumbs: () => [<>Эксперименты</>],
},
ExperimentPage: {
path: "/experiments/:experiment_id",
breadcrumbs: (path: string) => [
<>Эксперимент #{path.split("/")[path.split("/").length - 1]}</>,
],
},
MoleculePage: {
path: "/experiments/:experiment_id/:molecule_id",
breadcrumbs: (path: string) => [
<>Молекула #{path.split("/")[path.split("/").length - 1]}</>,
],
},
MachinesPage: {
path: "/machines",
breadcrumbs: () => [<>Вычислительные системы</>],
},
TeamsPage: { path: "/teams", breadcrumbs: () => [<>Команды</>] },
ErrorPage: { path: "/*", breadcrumbs: () => [<>Ошибка</>] },
SettingsPage: { path: "/settings", breadcrumbs: () => [<>Настройки</>] },
};
const router = createBrowserRouter(
[
{
path: "/",
element: <App />,
children: [
{
path: routes.MainPage.path,
Component: MainPage,
},
{
path: routes.DocumentationPage.path,
Component: DocumentationPage,
},
{
path: routes.SettingsPage.path,
Component: UserPage,
},
{
element: <AuthGuard />, // 🔒 everything below requires auth
children: [
{
path: routes.ExperimentsPage.path,
Component: ExperimentsPage,
},
{
path: routes.ExperimentPage.path,
Component: ExperimentPage,
},
{
path: routes.MoleculePage.path,
Component: MoleculeEditorPage,
},
{
path: routes.TeamsPage.path,
Component: TeamsPage,
},
],
},
{
path: routes.ErrorPage.path,
Component: ErrorPage,
},
],
},
],
{ basename: baseUrl },
);
export default router;

View File

@@ -0,0 +1,19 @@
import type { KeycloakProfile } from "keycloak-js";
import { create } from "zustand";
interface AuthernticationStoreState {
token: string | undefined;
profile: KeycloakProfile | null;
set_profile: (profile: KeycloakProfile | null) => void;
set_token: (token: string | undefined) => void;
}
export const AuthenticationStore = create<AuthernticationStoreState>()(
(set) => ({
token: undefined,
profile: null,
set_profile: (profile: KeycloakProfile | null) =>
set(() => ({ profile: profile })),
set_token: (token: string | undefined) => set(() => ({ token: token })),
}),
);

View File

@@ -0,0 +1,138 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import type { Experiment, TaskData } from "Types/Experiment/Experiment";
import type { Team } from "Types/User/User";
interface ExperimentStoreState {
experiments: Experiment[];
tasks: TaskData[];
teams: Team[];
selectedExperimentId?: number;
selectedTaskId?: number;
selectedTeamId?: number;
selectExperiment: (id: number | undefined) => void;
selectTask: (id: number | undefined) => void;
addExperiment: (experiment: Experiment) => void;
updateExperiment: (id: number, data: Partial<Experiment>) => void;
removeExperiment: (id: number) => void;
addTask: (experimentId: number, task: TaskData) => void;
updateTask: (taskId: number, data: Partial<TaskData>) => void;
removeTask: (experimentId: number, taskId: number) => void;
addTeam: (team: Team) => void;
updateTeam: (teamId: number, data: Partial<TaskData>) => void;
removeTeam: (teamId: number) => void;
}
export const useExperimentStore = create<ExperimentStoreState>()(
immer((set) => ({
experiments: [],
tasks: [],
teams: [],
selectedExperimentId: undefined,
selectedTaskId: undefined,
selectedTeamId: undefined,
selectExperiment: (id) =>
set((state) => {
state.selectedExperimentId = id;
}),
selectTask: (id) =>
set((state) => {
state.selectedTaskId = id;
}),
addExperiment: (experiment) =>
set((state) => {
state.experiments.push(experiment);
}),
updateExperiment: (id, data) =>
set((state) => {
const exp = state.experiments.find((e) => e.id === id);
if (!exp) return;
Object.assign(exp, data);
}),
removeExperiment: (id) =>
set((state) => {
state.experiments = state.experiments.filter((e) => e.id !== id);
if (state.selectedExperimentId === id) {
state.selectedExperimentId = undefined;
state.selectedTaskId = undefined;
}
}),
addTask: (experimentId, task) =>
set((state) => {
const exp = state.experiments.find((e) => e.id === experimentId);
if (!exp) return;
exp.tasks_ids.push(task.id);
state.tasks.push(task);
}),
updateTask: (taskId, data) =>
set((state) => {
const task = state.tasks.find((t) => t.id === taskId);
if (!task) return;
Object.assign(task, data);
}),
removeTask: (experimentId, taskId) =>
set((state) => {
const exp = state.experiments.find((e) => e.id === experimentId);
if (!exp) return;
state.tasks = state.tasks.filter((t) => t.data.id !== taskId);
exp.tasks_ids = exp.tasks_ids.filter((t) => t !== taskId);
if (state.selectedTaskId === taskId) {
state.selectedTaskId = undefined;
}
}),
addTeam: (team) =>
set((state) => {
state.teams.push(team);
}),
updateTeam: (teamID, data) =>
set((state) => {
const team = state.teams.find((t) => t.id === teamID);
if (!team) return;
Object.assign(team, data);
}),
removeTeam: (teamID) =>
set((state) => {
state.teams = state.teams.filter((t) => t.id !== teamID);
state.experiments = state.experiments.filter(
(t) => t.team_id !== teamID,
);
if (state.selectedTeamId === teamID) {
state.selectedTeamId = undefined;
}
}),
})),
);
export const useSelectedExperiment = () =>
useExperimentStore((s) =>
s.experiments.find((e) => e.id === s.selectedExperimentId),
);
export const useSelectedTask = () =>
useExperimentStore((s) => {
return s.tasks.find((t) => t.id === s.selectedTaskId);
});

12
src/Stores/LayoutStore.tsx Executable file
View File

@@ -0,0 +1,12 @@
import { create } from "zustand";
interface LayoutStore {
is_navbar_open: boolean;
set_navbar_open: (is_dark: boolean) => void;
}
export const useLayoutStore = create<LayoutStore>()((set) => ({
is_navbar_open: false,
set_navbar_open: (is_navbar_open) =>
set(() => ({ is_navbar_open: is_navbar_open })),
}));

View File

@@ -0,0 +1,17 @@
import type { SelectedAtom } from "Types/Experiment/MoleculeEdit/MoleculeEdit";
import { create } from "zustand";
interface MoleculeEditState {
currentMoleculeString: string;
setCurrentMoleculeString: (setCurrentMoleculeString: string) => void;
selectedAtom: SelectedAtom | null;
setSelectedAtom: (selectedAtom: SelectedAtom | null) => void;
}
export const useMoleculeEditStore = create<MoleculeEditState>()((set) => ({
currentMoleculeString: "",
setCurrentMoleculeString: (molString) =>
set(() => ({ currentMoleculeString: molString })),
selectedAtom: null,
setSelectedAtom: (atom) => set(() => ({ selectedAtom: atom })),
}));

17
src/Stores/PreferencesStore.tsx Executable file
View File

@@ -0,0 +1,17 @@
import { create } from "zustand";
interface UserPreferencesState {
show_hints: boolean;
set_show_hints: (is_dark: boolean) => void;
theme: "light" | "dark";
set_theme: (theme: "light" | "dark") => void;
}
export const useUserPreferencesStore = create<UserPreferencesState>()(
(set) => ({
show_hints: true,
set_show_hints: (show_hints) => set(() => ({ show_hints: show_hints })),
theme: "dark",
set_theme: (theme) => set(() => ({ theme: theme })),
}),
);

View File

@@ -0,0 +1,7 @@
export interface ConvertSchema {
inputText: string;
inputFormat: string;
add_h: boolean;
make_3d: boolean;
optimize: boolean;
}

View File

@@ -0,0 +1,41 @@
//export interface Experiment {}
import type { Team } from "Types/User/User";
export type ExperimentStatus =
| "DRAFT"
| "QUEUE"
| "PROCESSING"
| "SUCCESS"
| "ERROR";
export interface Experiment {
id: number;
name: string;
description: string;
team_id: number;
date_created: Date;
experiment_status: ExperimentStatus;
experiment_type: string;
tasks_ids: number[];
}
export interface TaskTypePlugin<TData = any> {
type: string;
// how it appears in the experiment task list
ListItem: React.ComponentType<TaskData<TData>>;
// full editor UI when clicking task
Editor: React.ComponentType<TaskEditorProps<TData>>;
}
export interface TaskData<TData = any> {
id: number;
name: string;
description: string;
data: TData;
}
export interface TaskEditorProps<TData = any> {
data: TData;
setData: (data: TData) => void;
}

View File

@@ -0,0 +1,5 @@
export interface SelectedAtom {
serial: number;
name: string;
position: { x: number; y: number; z: number };
}

20
src/Types/User/User.tsx Normal file
View File

@@ -0,0 +1,20 @@
export interface Team {
id: number;
name: string;
description: string;
creation_date: Date;
creator: TeamMember;
team_members: TeamMember[];
}
export interface TeamMember {
username: string;
email: string;
permissions: Permission[];
date_accepted: Date;
}
export interface Permission {
id: number;
name: string;
}

29
src/index.css Executable file
View File

@@ -0,0 +1,29 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
@font-face {
font-family: "Quicking"; /* Define a name for your font */
src: url("/Quicking.otf") format("opentype"); /* Specify the font file and format */
font-weight: normal; /* Optional: Define the weight of this font variant */
font-style: normal; /* Optional: Define the style of this font variant */
}
:root {
--mantine-color-body: var(--mantine-color-primary-filled) !important;
}

97
src/main.tsx Executable file
View File

@@ -0,0 +1,97 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import "@mantine/core/styles.css";
import { RouterProvider } from "react-router";
import router from "Routes/Routes.tsx";
import {
colorsTuple,
createTheme,
localStorageColorSchemeManager,
MantineProvider,
virtualColor,
} from "@mantine/core";
import keycloak from "Api/Keycloak/Keycloak";
import { AuthenticationStore } from "Stores/AuthenticationStore";
import { useUserPreferencesStore } from "Stores/PreferencesStore";
const colorSchemeManager = localStorageColorSchemeManager({
key: "my-app-color-scheme",
});
const checkIsDarkSchemePreferred = () =>
window?.matchMedia?.("(prefers-color-scheme:dark)")?.matches ?? false;
const theme = createTheme({
colors: {
primaryDark: colorsTuple("#212529"),
primaryLight: colorsTuple("#f1f3f5"),
secondaryDark: colorsTuple("#484d53"),
secondaryLight: colorsTuple("#b9bec4"),
contrastDark: colorsTuple("#f1f3f5"),
contrastLight: colorsTuple("#212529"),
secondaryContrastDark: colorsTuple("#b9bec4"),
secondaryContrastLight: colorsTuple("#484d53"),
primary: virtualColor({
name: "primary",
dark: "primaryDark",
light: "primaryLight",
}),
secondary: virtualColor({
name: "secondary",
dark: "secondaryDark",
light: "secondaryLight",
}),
contrast: virtualColor({
name: "contrast",
dark: "contrastDark",
light: "contrastLight",
}),
secondaryContrast: virtualColor({
name: "secondaryContrast",
dark: "secondaryContrastDark",
light: "secondaryContrastLight",
}),
accent: virtualColor({
name: "accent",
dark: "blue",
light: "blue",
}),
},
});
async function bootstrap() {
const authenticated = await keycloak.init({
onLoad: "check-sso",
pkceMethod: "S256",
silentCheckSsoRedirectUri:
"http://localhost:8001/quantum/silent-check-sso.html",
});
if (authenticated) {
const profile = await keycloak.loadUserProfile();
AuthenticationStore.setState({ token: keycloak.token, profile: profile });
}
useUserPreferencesStore.setState({
theme: checkIsDarkSchemePreferred() ? "dark" : "light",
});
createRoot(document.getElementById("root")!).render(<AppProviders />);
}
function AppProviders() {
const themePreference = useUserPreferencesStore((state) => state.theme);
return (
<MantineProvider
colorSchemeManager={colorSchemeManager}
forceColorScheme={themePreference}
theme={theme}
>
<RouterProvider router={router} />
</MantineProvider>
);
}
bootstrap();

1
src/vite-env.d.ts vendored Executable file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

28
tsconfig.app.json Executable file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": "src"
},
"include": ["src"]
}

7
tsconfig.json Executable file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
}

25
tsconfig.node.json Executable file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Executable file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { baseDomain, basePort, baseUrl } from "./src/GlobalVars";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
server: {
host: baseDomain,
port: basePort,
},
base: baseUrl,
});