servers and apps selects in modal

This commit is contained in:
2025-03-21 19:29:58 +05:00
parent d9556bae2d
commit 15dbaab168
12 changed files with 254 additions and 71 deletions
+3
View File
@@ -8,6 +8,7 @@
"@react-router/dev": "^7.3.0",
"@tailwindcss/vite": "^4.0.13",
"@tanstack/react-query": "^5.67.3",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"ky": "^1.7.5",
"motion": "^12.5.0",
@@ -324,6 +325,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg=="],
"@uidotdev/usehooks": ["@uidotdev/usehooks@2.4.1", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.8.0", "", { "dependencies": { "@swc/core": "^1.10.15" }, "peerDependencies": { "vite": "^4 || ^5 || ^6" } }, "sha512-T4sHPvS+DIqDP51ifPqa9XIRAz/kIvIi8oXcnOZZgHmMotgmmdxe/DD5tMFlt5nuIRzT0/QuiwmKlH0503Aapw=="],
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
+1
View File
@@ -14,6 +14,7 @@
"@react-router/dev": "^7.3.0",
"@tailwindcss/vite": "^4.0.13",
"@tanstack/react-query": "^5.67.3",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"ky": "^1.7.5",
"motion": "^12.5.0",
+19 -15
View File
@@ -1,36 +1,40 @@
import React from "react";
import { clsx as cn } from "clsx";
import React from 'react';
import { clsx as cn } from 'clsx';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
variant?: "link" | "primary" | "secondary" | "tertiary";
variant?: 'link' | 'primary' | 'secondary' | 'tertiary';
className?: string;
size?: "small" | "medium" | "large";
size?: 'small' | 'medium' | 'large';
onlyIcon?: boolean;
ref?: React.RefObject<HTMLButtonElement | null>;
}
function Button({
children,
variant = "primary",
size = "medium",
variant = 'primary',
size = 'medium',
onlyIcon,
className,
ref,
...props
}: ButtonProps) {
return (
<button
ref={ref}
{...props}
className={cn(
"transition-all rounded-lg flex items-center justify-center",
variant !== "link" && [
size === "small" && (onlyIcon ? "p-2" : "px-3 py-2 gap-2"),
size === "medium" && (onlyIcon ? "p-3.5" : "px-5 py-3.5 gap-3.5"),
size === "large" && (onlyIcon ? "p-4" : "px-6 py-4 gap-4"),
'transition-all rounded-lg flex items-center justify-center',
variant !== 'link' && [
size === 'small' && (onlyIcon ? 'p-2' : 'px-3 py-2 gap-2'),
size === 'medium' && (onlyIcon ? 'p-3.5' : 'px-5 py-3.5 gap-3.5'),
size === 'large' && (onlyIcon ? 'p-4' : 'px-6 py-4 gap-4'),
],
variant === "link" && "text-sm text-black/50 w-fit",
variant === "primary" && "bg-[#1E1E1E] text-white",
variant === "secondary" && "bg-white",
variant === "tertiary" && "bg-transparent text-[#767676] hover:bg-black/5",
variant === 'link' && 'text-sm text-black/50 w-fit',
variant === 'primary' && 'bg-[#1E1E1E] text-white',
variant === 'secondary' && 'bg-white',
variant === 'tertiary' &&
'bg-transparent text-[#767676] hover:bg-black/5',
className
)}
>
+74 -17
View File
@@ -1,28 +1,85 @@
import { useState } from 'react';
import { IServer } from '../types/IServer';
import Button from './Button';
import { useClickAway } from '@uidotdev/usehooks';
import ArrowDownIcon from './icons/ArrowDownIcon';
interface Props {
servers: IServer[];
value: IServer;
onChange: (server: IServer) => void;
}
export default function DesktopSelect({ servers }: Props) {
export default function DesktopSelect({ servers, value, onChange }: Props) {
const [isOpen, setIsOpen] = useState(false);
const ref = useClickAway<HTMLDivElement>(() => setIsOpen(false));
return (
<div>
{servers.map(({ id, name, sessions }) => (
<div key={id} className="flex gap-[0.278vw] items-center">
{sessions && sessions?.length > 0 && (
<div
className="rounded-full w-[0.417vw] aspect-square"
style={{
backgroundColor:
sessions[sessions.length - 1].status === 'started'
? '#EF3C26'
: '#108C33',
}}
/>
)}
<p className="text-[0.972vw] leading-[115%]">{name}</p>
<div
ref={ref}
className="relative outline-black/10 outline w-[13.889vw] rounded-[0.556vw]"
>
<Button
onlyIcon
variant="tertiary"
className="px-[0.833vw] py-[0.417vw] !justify-between w-full"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex gap-[0.278vw] items-center">
<div
className="rounded-full w-[0.417vw] aspect-square"
style={{
backgroundColor:
value.sessions &&
value.sessions?.length > 0 &&
value.sessions[value.sessions.length - 1].status === 'started'
? '#EF3C26'
: '#108C33',
}}
/>
<p className="text-[0.972vw] leading-[115%]">{value.name}</p>
</div>
))}
<div
className={`w-[1.389vw] transition-transform h-[1.389vw]${
isOpen ? ' rotate-180' : ''
}`}
>
<ArrowDownIcon />
</div>
</Button>
{isOpen && (
<div className="absolute z-1 top-full w-full bg-white rounded-[0.556vw] outline outline-black/10">
{servers.map((server) => (
<Button
key={server.id}
onlyIcon
variant="tertiary"
className="px-[0.833vw] py-[0.417vw] !justify-start w-full !first:rounded-t-[0.556vw] !last:rounded-b-[0.556vw]"
onClick={() => {
onChange(server);
setIsOpen(false);
}}
>
<div className="flex gap-[0.278vw] items-center">
<div
className="rounded-full w-[0.417vw] aspect-square"
style={{
backgroundColor:
server.sessions &&
server.sessions?.length > 0 &&
server.sessions[server.sessions.length - 1].status ===
'started'
? '#EF3C26'
: '#108C33',
}}
/>
<p className="text-[0.972vw] leading-[115%]">{server.name}</p>
</div>
</Button>
))}
</div>
)}
</div>
);
}
+52 -21
View File
@@ -1,28 +1,59 @@
interface IOption {
id: string;
import { useState } from 'react';
import Button from './Button';
import { useClickAway } from '@uidotdev/usehooks';
import ArrowDownIcon from './icons/ArrowDownIcon';
interface Props {
options: string[];
value: string;
onChange: (option: string) => void;
}
interface ISelectProps<T extends IOption>
extends React.SelectHTMLAttributes<HTMLSelectElement> {
multiple?: boolean;
options: T[];
selected?: T;
// onClose: () => void;
}
export default function Select({ options, value, onChange }: Props) {
const [isOpen, setIsOpen] = useState(false);
const ref = useClickAway<HTMLDivElement>(() => setIsOpen(false));
export default function Select<T extends IOption>({
multiple,
options,
selected,
onClose,
...props
}: ISelectProps<T>) {
return (
<select {...props}>
{options.map(({ id, value }) => (
<option key={id}>{value}</option>
))}
</select>
<div
ref={ref}
className="relative outline-black/10 outline w-[13.889vw] rounded-[0.556vw]"
>
<Button
onlyIcon
variant="tertiary"
className="px-[0.833vw] py-[0.417vw] !justify-between w-full"
onClick={() => setIsOpen(!isOpen)}
>
<p className="text-[0.972vw] leading-[115%]">{value}</p>
<div
className={`w-[1.389vw] transition-transform h-[1.389vw]${
isOpen ? ' rotate-180' : ''
}`}
>
<ArrowDownIcon />
</div>
</Button>
{isOpen && (
<div className="absolute top-full w-full bg-white rounded-[0.556vw] outline outline-black/10">
{options.map((option) => (
<Button
key={option}
onlyIcon
variant="tertiary"
className="px-[0.833vw] py-[0.417vw] !justify-start w-full !first:rounded-t-[0.556vw] !last:rounded-b-[0.556vw]"
onClick={() => {
onChange(option);
setIsOpen(false);
}}
>
<div className="flex gap-[0.278vw] items-center">
<p className="text-[0.972vw] leading-[115%]">{option}</p>
</div>
</Button>
))}
</div>
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export default function ArrowDownIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="m5.834 8.333 4.167 4.167 4.166-4.167"
stroke="#767676"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+68 -5
View File
@@ -1,13 +1,41 @@
import { IServer } from "../../types/IServer.ts";
import Button from "../Button.tsx";
import Input from "../Input.tsx";
import DisplayIcon from "../icons/DisplayIcon.tsx";
import { useEffect } from 'react';
import { useState } from 'react';
import { IServer } from '../../types/IServer.ts';
import Button from '../Button.tsx';
import DesktopSelect from '../DesktopSelect.tsx';
import Input from '../Input.tsx';
import DisplayIcon from '../icons/DisplayIcon.tsx';
import Select from '../Select.tsx';
import { useQueryClient } from '@tanstack/react-query';
import { IUser } from '../../types/IUser.ts';
import { IApp } from '../../types/IApp.ts';
interface Props {
servers: IServer[];
}
export default function CreateSessionModal({ servers }: Props) {
const queryClient = useQueryClient();
const user = queryClient.getQueryData<IUser>(['me']);
const [selectedServer, setSelectedServer] = useState<IServer | undefined>(
servers.find(
({ sessions }) =>
!sessions || !sessions.length || sessions[0].status === 'ended'
)
);
const [selectedApp, setSelectedApp] = useState<IApp | undefined>(
user?.company.apps.filter((app) => app.serverId === selectedServer?.id)[0]
);
useEffect(() => {
setSelectedApp(
user?.company.apps.filter((app) => app.serverId === selectedServer?.id)[0]
);
}, [selectedServer, user?.company.apps]);
return (
<div className="w-[34.375vw] rounded-[0.833vw] bg-white p-[1.667vw] flex flex-col min-h-[calc(100dvh-0.972vw*2)] justify-between gap-[1.111vw]">
<div className="gap-y-[1.111vw] flex flex-col justify-between">
@@ -45,13 +73,48 @@ export default function CreateSessionModal({ servers }: Props) {
<Input placeholder="sample@mail.ru" type="email" />
</div>
</div>
<div className="flex flex-col gap-y-[0.556vw]">
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">Стол</p>
{selectedServer && (
<DesktopSelect
servers={servers}
value={selectedServer}
onChange={setSelectedServer}
/>
)}
</div>
<p className="text-[0.694vw] text-black/30 w-[13.889vw] leading-[115%] self-end">
При запуске нового сеанса текущий будет завершен принудительно.
</p>
</div>
{user && selectedServer && (
<div className="flex justify-between items-center">
<p className="text-[0.972vw]">Проекты</p>
<div className="outline outline-black/10 rounded-[0.556vw] w-[13.889vw]">
{selectedApp && (
<Select
options={user.company.apps
.filter((app) => app.serverId === selectedServer.id)
.map((app) => app.name)}
value={selectedApp?.name}
onChange={(option) =>
setSelectedApp(
user.company.apps.find((app) => app.name === option)
)
}
/>
)}
</div>
</div>
)}
<hr className="border-black/10" />
</div>
<div className="flex justify-between">
<Button className="bg-[#F9F9F9] px-[2.222vw] py-[0.556vw] !rounded-[0.556vw]">
<p className="text-black font-medium text-[0.972vw]">Отменить</p>
</Button>
<Button className="bg-[#2D68F6] px-[2.222vw] py-[0.556vw] !rounded-[0.556vw]">
<Button className="bg-[#EF3C26] px-[2.222vw] py-[0.556vw] !rounded-[0.556vw]">
<p className="text-[0.972vw] font-medium">Запустить сеанс</p>
</Button>
</div>
+7 -7
View File
@@ -1,15 +1,15 @@
import { Navigate, Outlet } from "react-router";
import useAuthStore from "../stores/useAuthStore";
import api from "../utils/api";
import { useQuery } from "@tanstack/react-query";
import { IUser } from "../types/IUser";
import { Navigate, Outlet } from 'react-router';
import useAuthStore from '../stores/useAuthStore';
import api from '../utils/api';
import { useQuery } from '@tanstack/react-query';
import { IUser } from '../types/IUser';
function ProtectedPage() {
const { token } = useAuthStore();
const { data: user, isLoading } = useQuery({
queryKey: ["me"],
queryFn: () => api.get("auth/me").json<IUser>(),
queryKey: ['me'],
queryFn: () => api.get('users/me').json<IUser>(),
enabled: !!token,
});
+1
View File
@@ -5,4 +5,5 @@ export interface IApp {
companyId: string;
createdAt: Date;
updatedAt: Date;
serverId: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { IApp } from "./IApp";
import { IServer } from "./IServer";
import { IUser } from "./IUser";
export interface ICompany {
id: string;
name: string;
apps: IApp[];
servers: IServer[];
users: IUser[];
}
+1 -5
View File
@@ -1,6 +1,4 @@
import { IApp } from "./IApp";
import { IClient } from "./IClient";
import { ISession } from "./ISession";
import { ISession } from './ISession';
export interface IServer {
id: string;
@@ -9,6 +7,4 @@ export interface IServer {
location: string;
companyId: string;
sessions?: ISession[];
client?: IClient;
app?: IApp;
}
+4 -1
View File
@@ -1,6 +1,9 @@
import { ICompany } from "./ICompany";
export interface IUser {
id: string;
email: string;
fullname: string;
companyId: string;
company: ICompany
}