upd
This commit is contained in:
+28
-143
@@ -8,9 +8,9 @@ import BlendStream from "./components/blendings/BlendStream";
|
||||
import StreamButton from "./components/StreamButton";
|
||||
import "react-rangeslider/lib/index.css";
|
||||
import "./components/RangeSlider.css";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ProjectCard from "./components/ProjectCard";
|
||||
import MoreProjectButton from "./components/MoreProjectButton";
|
||||
// import MoreProjectButton from "./components/MoreProjectButton";
|
||||
import ExampleCard from "./components/ExampleCard";
|
||||
import FeatureVideoViewBox from "./components/FeatureVideoViewBox";
|
||||
import Button from "./components/Button";
|
||||
@@ -20,15 +20,36 @@ import BlendingClients from "./components/blendings/BlendingClients";
|
||||
import Heading2 from "./components/Headings/Heading2";
|
||||
// import PlayIcon from "./components/icons/PlayIcon";
|
||||
import VideoSliderMobile from "./components/VideoSliderMobile";
|
||||
// import { isMobile } from "react-device-detect";
|
||||
import IProject from "./types/IProject";
|
||||
import api from "./utils/api";
|
||||
|
||||
function App() {
|
||||
const [selectedVideo, setSelectedVideo] = useState<string>(
|
||||
"https://graff.estate/videos/features/virtual_tour.mp4"
|
||||
);
|
||||
const [isShowProjects, setIsShowProjects] = useState<boolean>(false);
|
||||
// const [isShowProjects, setIsShowProjects] = useState<boolean>(false);
|
||||
|
||||
const [projects, setProjects] = useState<IProject[]>([]);
|
||||
|
||||
async function getProjects() {
|
||||
try {
|
||||
const projects: IProject[] = await api.get("projects").json();
|
||||
|
||||
setProjects(projects);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getProjects();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-white 2xl:px-10 xl:px-8 sm:px-6 px-4 overflow-x-clip">
|
||||
<div className="min-h-screen 2xl:px-10 xl:px-8 sm:px-6 px-4 overflow-x-clip">
|
||||
<div className="relative conatiner mx-auto 2xl:max-w-screen-2xl">
|
||||
<div className="flex justify-between py-6 2xl:mb-28 xl:mb-[88px] sm:mb-12 mb-14">
|
||||
<div>
|
||||
@@ -408,145 +429,9 @@ function App() {
|
||||
<div className="flex flex-col gap-16 2xl:mb-40 sm:mb-[120px] mb-20">
|
||||
<Heading2>Проекты</Heading2>
|
||||
<div className="grid xl:grid-cols-3 sm:grid-cols-2 2xl:gap-8 xl:gap-6 gap-3">
|
||||
<ProjectCard
|
||||
title="ЖК «Голос в сердце города»"
|
||||
company="Голос Девелопмент, Челябинск"
|
||||
image="/images/projects/photo01.jpg"
|
||||
stream
|
||||
stage={1}
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Кама»"
|
||||
company="ГК Альфа, Пермь"
|
||||
image="/images/projects/photo02.jpg"
|
||||
stage={2}
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Август»"
|
||||
company="Родина Девелопмент, Тюмень"
|
||||
image="/images/projects/photo03.jpg"
|
||||
stage={4}
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «DNS Сити»"
|
||||
company="НКС Девелопмент, Владивосток"
|
||||
image="/images/projects/photo04.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Upside Towers»"
|
||||
company="Upside Development, Москва"
|
||||
image="/images/projects/photo05.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Scala City»"
|
||||
company="Рисан, Пенза"
|
||||
image="/images/projects/photo06.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Мираполис»"
|
||||
company="ГК Основа, Москва"
|
||||
image="/images/projects/photo07.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Ориент»"
|
||||
company="СК+, Хабаровск"
|
||||
image="/images/projects/photo08.jpg"
|
||||
/>
|
||||
|
||||
{!isShowProjects ? (
|
||||
<MoreProjectButton handleClick={() => setIsShowProjects(true)} />
|
||||
) : (
|
||||
<>
|
||||
<ProjectCard
|
||||
title="ЖК «Новатор»"
|
||||
company="СБК, Тюмень"
|
||||
image="/images/projects/photo09.jpg"
|
||||
stream
|
||||
stage={1}
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Фотограф»"
|
||||
company="Мавис, Санкт-Петербург"
|
||||
image="/images/projects/photo10.jpg"
|
||||
stage={2}
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Life Резиденция»"
|
||||
company="Паритет Девелопмент, Тюмень"
|
||||
image="/images/projects/photo11.jpg"
|
||||
stage={3}
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="МФК «Re:volution towers»"
|
||||
company="НКС Девелопмент, Екатеринбург"
|
||||
image="/images/projects/photo12.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Айвазовский»"
|
||||
company="ЭНКО, Тюмень"
|
||||
image="/images/projects/photo13.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Авторский квартал Машаров»"
|
||||
company="Сибинтел Девелопмент, Тюмень"
|
||||
image="/images/projects/photo14.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Уральский»"
|
||||
company="Эфес, Екатеринбург"
|
||||
image="/images/projects/photo15.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="Iskan Abu Dhabi"
|
||||
company="ОАЭ, Абу-Даби"
|
||||
image="/images/projects/photo16.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Графика»"
|
||||
company="Мавис, Санкт-Петербург"
|
||||
image="/images/projects/photo17.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «4you»"
|
||||
company="Атлас Девелопмент, Екатеринбург"
|
||||
image="/images/projects/photo18.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Новая Атмосфера»"
|
||||
company="Застройщик Атмосфера, Брянск"
|
||||
image="/images/projects/photo19.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Тринити»"
|
||||
company="НКС-девелопмент, Екатеринбург"
|
||||
image="/images/projects/photo20.jpg"
|
||||
/>
|
||||
|
||||
<ProjectCard
|
||||
title="ЖК «Дом на Опалихинской»"
|
||||
company="Корпорация «Маяк», Екатеринбург"
|
||||
image="/images/projects/photo21.jpg"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{projects.map((project, index) => (
|
||||
<ProjectCard key={index} {...project} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import useModalStore from "../stores/useModalStore";
|
||||
|
||||
function ModalContainer() {
|
||||
const [modal, setModal] = useModalStore((state) => [
|
||||
state.modal,
|
||||
state.setModal,
|
||||
]);
|
||||
|
||||
if (modal) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => setModal(null)}
|
||||
className={`min-h-screen p-8 absolute top-0 left-0 w-full flex justify-center items-center bg-black bg-opacity-30 overflow-auto cursor-pointer transition-opacity`}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} className="cursor-default">
|
||||
{modal}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ModalContainer;
|
||||
@@ -15,8 +15,8 @@ function MoreProjectButton({ handleClick }: MoreProjectButtonProps) {
|
||||
className="sm:aspect-[4/3] border border-[#3D425C] rounded-[48px] px-6 py-4 flex sm:flex-col sm:justify-center justify-between items-center gap-2"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PlusIcon />
|
||||
<p className="font-gilroy font-medium leading-none">Показать еще</p>
|
||||
<PlusIcon />
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { motion } from "framer-motion";
|
||||
import ProgressPie from "./ProgressPie";
|
||||
|
||||
interface ProjectCardProps {
|
||||
title: string;
|
||||
company: string;
|
||||
image: string;
|
||||
stream?: boolean;
|
||||
stage?: number;
|
||||
}
|
||||
import TouchScreenIcon from "./icons/TouchScreenIcon";
|
||||
import VRIcon from "./icons/VRIcon";
|
||||
import MobileIcon from "./icons/MobileIcon";
|
||||
import IProject from "../types/IProject";
|
||||
|
||||
function ProjectCard({
|
||||
title,
|
||||
name,
|
||||
company,
|
||||
city,
|
||||
image,
|
||||
stage = 6,
|
||||
stream,
|
||||
}: ProjectCardProps) {
|
||||
releaseYear = 2023,
|
||||
devices = [],
|
||||
}: IProject) {
|
||||
const stagePercentage = Math.round((100 / 6) * stage);
|
||||
|
||||
return (
|
||||
@@ -35,13 +33,13 @@ function ProjectCard({
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-gradient-card"></div>
|
||||
<div className="relative flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="2xl:text-2xl text-xl font-gilroy font-medium">
|
||||
{title}
|
||||
<p className="2xl:text-2xl text-xl font-gilroy font-medium">{name}</p>
|
||||
<p className="2xl:text-sm text-xs">
|
||||
{company}, {city}
|
||||
</p>
|
||||
<p className="2xl:text-sm text-xs">{company}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-2">
|
||||
{stage < 6 ? (
|
||||
<div className="bg-[#14161F] px-3 py-2 rounded-full w-fit flex items-center gap-1">
|
||||
<p className="font-gilroy font-medium leading-none">
|
||||
@@ -51,17 +49,38 @@ function ProjectCard({
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gradient px-3 py-2 rounded-full w-fit">
|
||||
<p className="font-gilroy font-medium leading-none">Сдан</p>
|
||||
<p className="font-gilroy font-medium leading-none">
|
||||
{releaseYear}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stream && (
|
||||
<div className="bg-[#14161F] px-3 py-2 rounded-full w-fit flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-gradient rounded-full"></div>
|
||||
<p className="font-gilroy font-semibold leading-none text-gradient">
|
||||
Stream
|
||||
</p>
|
||||
</div>
|
||||
{devices.length > 0 && (
|
||||
<>
|
||||
{devices.includes("stream") && (
|
||||
<div className="bg-[#14161F] px-3 py-2 rounded-full w-fit flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-gradient rounded-full"></div>
|
||||
<p className="font-gilroy font-semibold leading-none text-gradient">
|
||||
Stream
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{devices.includes("touch") && (
|
||||
<div className="bg-[#14161F] p-2 rounded-full w-fit flex items-center gap-2">
|
||||
<TouchScreenIcon />
|
||||
</div>
|
||||
)}
|
||||
{devices.includes("mobile") && (
|
||||
<div className="bg-[#14161F] p-2 rounded-full w-fit flex items-center gap-2">
|
||||
<MobileIcon />
|
||||
</div>
|
||||
)}
|
||||
{devices.includes("vr") && (
|
||||
<div className="bg-[#14161F] p-2 rounded-full w-fit flex items-center gap-2">
|
||||
<VRIcon />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
function MobileIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="21"
|
||||
height="20"
|
||||
viewBox="0 0 21 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.83338 1.66602C6.17652 1.66602 4.83337 3.00916 4.83337 4.66602V15.3327C4.83337 16.9895 6.17652 18.3327 7.83337 18.3327H13.5C15.1569 18.3327 16.5 16.9895 16.5 15.3327V4.66602C16.5 3.00916 15.1569 1.66602 13.5 1.66602H7.83338ZM7.50004 3.33268C6.94776 3.33268 6.50004 3.7804 6.50004 4.33268L6.50004 15.666C6.50004 16.2183 6.94776 16.666 7.50004 16.666L13.8334 16.666C14.3857 16.666 14.8334 16.2183 14.8334 15.666V4.33268C14.8334 3.7804 14.3857 3.33268 13.8334 3.33268L7.50004 3.33268Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect
|
||||
x="8.16675"
|
||||
y="2.5"
|
||||
width="5"
|
||||
height="1.66667"
|
||||
rx="0.833333"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileIcon;
|
||||
@@ -0,0 +1,32 @@
|
||||
function TouchScreenIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="21"
|
||||
height="20"
|
||||
viewBox="0 0 21 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_1403_1738)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.333374 5.5V14.5C0.333374 16.1568 1.67652 17.5 3.33338 17.5L17.3334 17.5C18.9902 17.5 20.3334 16.1568 20.3334 14.5V5.5C20.3334 3.84315 18.9902 2.5 17.3334 2.5H3.33337C1.67652 2.5 0.333374 3.84315 0.333374 5.5ZM2.00019 5.16666V14.8333C2.00019 15.3856 2.4479 15.8333 3.00018 15.8333L7.04121 15.8333C7.01915 15.5578 7.0099 15.2789 7.01117 15C7.01327 14.5397 7.38427 14.1666 7.8445 14.1666C8.30474 14.1666 8.67784 14.5397 8.67784 15V10C8.67784 9.53976 9.05093 9.16666 9.51117 9.16666C9.97141 9.16666 10.3445 9.53976 10.3445 10V13.5185C10.3445 13.0582 10.7176 12.6851 11.1778 12.6851C11.6381 12.6851 12.0112 13.0582 12.0112 13.5185V14.1667C12.0112 13.7064 12.3843 13.3333 12.8445 13.3333C13.3047 13.3333 13.6778 13.7064 13.6778 14.1667V15.1851C13.6778 14.7249 14.0509 14.3518 14.5112 14.3518C14.9714 14.3518 15.3445 14.7249 15.3445 15.1851V15.8333L17.6668 15.8333C18.2191 15.8333 18.6669 15.3856 18.6669 14.8333L18.6669 5.16666C18.6669 4.61438 18.2191 4.16666 17.6669 4.16666H3.00019C2.4479 4.16666 2.00019 4.61438 2.00019 5.16666ZM10.6995 6.00828C10.1364 5.8403 9.54417 5.79267 8.96185 5.86806C8.37951 5.94345 7.81826 6.14043 7.31571 6.44773C6.81307 6.75509 6.37973 7.16641 6.04676 7.65644C5.71369 8.1466 5.48922 8.70362 5.39068 9.29055C5.29212 9.87755 5.32223 10.4785 5.47862 11.0524C5.59961 11.4965 6.05767 11.7583 6.50172 11.6373C6.94577 11.5164 7.20765 11.0583 7.08666 10.6142C6.99362 10.2728 6.97574 9.91552 7.03434 9.56651C7.09293 9.21754 7.22649 8.88572 7.42529 8.59314C7.62406 8.30062 7.88334 8.0542 8.18518 7.86963C8.48699 7.68508 8.82474 7.56638 9.17583 7.52093C9.52692 7.47548 9.88389 7.50423 10.2231 7.6054C10.5622 7.70658 10.8764 7.87803 11.1442 8.109C11.4121 8.34002 11.6277 8.62542 11.7757 8.94668C11.9237 9.268 12.0006 9.61737 12.0006 9.97128C12.0006 10.4315 12.3737 10.8046 12.8339 10.8046C13.2942 10.8046 13.6673 10.4315 13.6673 9.97128C13.6673 9.37584 13.5379 8.78859 13.2894 8.24921C13.0409 7.70998 12.6798 7.23243 12.2327 6.84685C11.7857 6.46135 11.2626 6.17627 10.6995 6.00828Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1403_1738">
|
||||
<rect
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
transform="translate(0.333374)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default TouchScreenIcon;
|
||||
@@ -0,0 +1,18 @@
|
||||
function VRIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="21"
|
||||
height="12"
|
||||
viewBox="0 0 21 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.63475 11.5L0.746748 0.299999H2.76275L5.73875 9.276L8.73075 0.299999H10.7307L6.84275 11.5H4.63475ZM18.3114 11.5L15.9434 7.42H13.7674V11.5H11.9274V0.299999H16.4074C18.4074 0.299999 20.0074 1.9 20.0074 3.9C20.0074 5.34 19.0954 6.62 17.7834 7.148L20.3274 11.5H18.3114ZM13.7674 2.028V5.772H16.4074C17.3834 5.772 18.1674 4.94 18.1674 3.9C18.1674 2.844 17.3834 2.028 16.4074 2.028H13.7674Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default VRIcon;
|
||||
@@ -0,0 +1,293 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import api from "../../utils/api";
|
||||
import Button from "../Button";
|
||||
import IProject from "../../types/IProject";
|
||||
import useModalStore from "../../stores/useModalStore";
|
||||
|
||||
function CreateProjectModal() {
|
||||
const [project, setProject] = useState<IProject>({
|
||||
name: "",
|
||||
company: "",
|
||||
city: "",
|
||||
image: "",
|
||||
devices: [],
|
||||
});
|
||||
|
||||
const [file, setFile] = useState<File>();
|
||||
const [previewFile, setPreviewFile] = useState<string>();
|
||||
const [setModal] = useModalStore((state) => [state.setModal]);
|
||||
|
||||
function handleChangeFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
if (!e.target.files) return;
|
||||
|
||||
const targetFile = e.target.files[0];
|
||||
|
||||
setFile(targetFile);
|
||||
setPreviewFile(URL.createObjectURL(targetFile));
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const { file }: { file: string } = await api
|
||||
.post("upload", { body: formData })
|
||||
.json();
|
||||
|
||||
setProject((prev) => ({ ...prev, image: file }));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
try {
|
||||
await api.post("projects", { json: { ...project } });
|
||||
|
||||
setModal(null);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: ChangeEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
await createProject();
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
uploadFile();
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-lg text-black p-8 rounded-xl flex flex-col gap-4">
|
||||
<p className="text-xl pb-4 border-b border-[#ccc]">Создание проекта</p>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-2 gap-4 w-[512px]"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Название</label>
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
type="text"
|
||||
placeholder="Название"
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
value={project.name}
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Компания</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Компания"
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
value={project.company}
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, company: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Город</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Город"
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
value={project.city}
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, city: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="relative border border-dashed border-neutral-500 px-3 py-2 hover:bg-opacity-10 hover:bg-black cursor-pointer rounded-lg flex flex-col gap-2">
|
||||
<input
|
||||
required
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="absolute opacity-0"
|
||||
onChange={handleChangeFile}
|
||||
/>
|
||||
<p>{file ? file.name : "Выберите изображение"}</p>
|
||||
|
||||
{previewFile && <img src={previewFile} alt="" />}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Стадия</label>
|
||||
<select
|
||||
required
|
||||
value={project.stage || ""}
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, stage: +e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Выберите стадию
|
||||
</option>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
<option value={5}>5</option>
|
||||
<option value={6}>6</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Год релиза</label>
|
||||
<select
|
||||
required
|
||||
value={project.releaseYear || ""}
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, releaseYear: +e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Выберите год релиза
|
||||
</option>
|
||||
<option value={2024}>2024</option>
|
||||
<option value={2023}>2023</option>
|
||||
<option value={2022}>2022</option>
|
||||
<option value={2021}>2021</option>
|
||||
<option value={2020}>2020</option>
|
||||
<option value={2019}>2019</option>
|
||||
<option value={2018}>2018</option>
|
||||
<option value={2017}>2017</option>
|
||||
<option value={2016}>2016</option>
|
||||
<option value={2015}>2015</option>
|
||||
<option value={2014}>2014</option>
|
||||
<option value={2013}>2013</option>
|
||||
<option value={2012}>2012</option>
|
||||
<option value={2011}>2011</option>
|
||||
<option value={2010}>2010</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Девайсы</p>
|
||||
<div className="">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: [...prev.devices!, "stream"],
|
||||
}));
|
||||
} else {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: prev.devices!.filter(
|
||||
(device) => device !== "stream"
|
||||
),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Stream</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: [...prev.devices!, "touch"],
|
||||
}));
|
||||
} else {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: prev.devices!.filter(
|
||||
(device) => device !== "touch"
|
||||
),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Touch</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: [...prev.devices!, "mobile"],
|
||||
}));
|
||||
} else {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: prev.devices!.filter(
|
||||
(device) => device !== "mobile"
|
||||
),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Mobile</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: [...prev.devices!, "vr"],
|
||||
}));
|
||||
} else {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: prev.devices!.filter(
|
||||
(device) => device !== "vr"
|
||||
),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>VR</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full flex justify-end">
|
||||
<Button className="text-white outline-none">
|
||||
Добавить проект
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateProjectModal;
|
||||
@@ -0,0 +1,40 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import api from "../../utils/api";
|
||||
import Button from "../Button";
|
||||
import useModalStore from "../../stores/useModalStore";
|
||||
|
||||
interface DeleteProjectModalProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function DeleteProjectModal({ projectId }: DeleteProjectModalProps) {
|
||||
const [setModal] = useModalStore((state) => [state.setModal]);
|
||||
|
||||
async function deleteProject() {
|
||||
try {
|
||||
await api.delete(`projects/${projectId}`);
|
||||
setModal(null);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-lg text-black p-8 rounded-xl flex flex-col gap-4">
|
||||
<p className="text-xl pb-4 border-b border-[#ccc]">Удаление проекта</p>
|
||||
|
||||
<Button
|
||||
handleClick={deleteProject}
|
||||
className="text-white self-end outline-none"
|
||||
>
|
||||
Удалить проект
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteProjectModal;
|
||||
@@ -0,0 +1,317 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import api from "../../utils/api";
|
||||
import Button from "../Button";
|
||||
import IProject from "../../types/IProject";
|
||||
import useModalStore from "../../stores/useModalStore";
|
||||
|
||||
interface EditProjectModalProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
function EditProjectModal({ projectId }: EditProjectModalProps) {
|
||||
const [project, setProject] = useState<IProject>({
|
||||
name: "",
|
||||
company: "",
|
||||
city: "",
|
||||
image: "",
|
||||
devices: [],
|
||||
});
|
||||
|
||||
const [file, setFile] = useState<File>();
|
||||
const [previewFile, setPreviewFile] = useState<string>();
|
||||
const [setModal] = useModalStore((state) => [state.setModal]);
|
||||
|
||||
function handleChangeFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
if (!e.target.files) return;
|
||||
|
||||
const targetFile = e.target.files[0];
|
||||
|
||||
setFile(targetFile);
|
||||
setPreviewFile(URL.createObjectURL(targetFile));
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const { file }: { file: string } = await api
|
||||
.post("upload", { body: formData })
|
||||
.json();
|
||||
|
||||
setProject((prev) => ({ ...prev, image: file }));
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProject() {
|
||||
try {
|
||||
await api.put(`projects/${projectId}`, { json: { ...project } });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: ChangeEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
await updateProject();
|
||||
setModal(null);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function getProject() {
|
||||
try {
|
||||
const project: IProject = await api.get(`projects/${projectId}`).json();
|
||||
|
||||
setProject(project);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
uploadFile();
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
getProject();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-lg text-black p-8 rounded-xl flex flex-col gap-4">
|
||||
<p className="text-xl pb-4 border-b border-[#ccc]">
|
||||
Редактирование проекта
|
||||
</p>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-2 gap-4 w-[512px]"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Название</label>
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
type="text"
|
||||
placeholder="Название"
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
value={project.name}
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Компания</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Компания"
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
value={project.company}
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, company: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Город</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="Город"
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
value={project.city}
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, city: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="relative border border-dashed border-neutral-500 px-3 py-2 hover:bg-opacity-10 hover:bg-black cursor-pointer rounded-lg flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="absolute opacity-0"
|
||||
onChange={handleChangeFile}
|
||||
/>
|
||||
<p>{file ? file.name : "Выберите изображение"}</p>
|
||||
|
||||
{project.image && <img src={project.image} alt="" />}
|
||||
{previewFile && <img src={previewFile} alt="" />}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Стадия</label>
|
||||
<select
|
||||
required
|
||||
value={project.stage || ""}
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, stage: +e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Выберите стадию
|
||||
</option>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
<option value={5}>5</option>
|
||||
<option value={6}>6</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm">Год релиза</label>
|
||||
<select
|
||||
required
|
||||
value={project.releaseYear || ""}
|
||||
className="border border-neutral-500 px-3 py-2 rounded-lg outline-none"
|
||||
onChange={(e) =>
|
||||
setProject((prev) => ({ ...prev, releaseYear: +e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Выберите год релиза
|
||||
</option>
|
||||
<option value={2024}>2024</option>
|
||||
<option value={2023}>2023</option>
|
||||
<option value={2022}>2022</option>
|
||||
<option value={2021}>2021</option>
|
||||
<option value={2020}>2020</option>
|
||||
<option value={2019}>2019</option>
|
||||
<option value={2018}>2018</option>
|
||||
<option value={2017}>2017</option>
|
||||
<option value={2016}>2016</option>
|
||||
<option value={2015}>2015</option>
|
||||
<option value={2014}>2014</option>
|
||||
<option value={2013}>2013</option>
|
||||
<option value={2012}>2012</option>
|
||||
<option value={2011}>2011</option>
|
||||
<option value={2010}>2010</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm">Девайсы</p>
|
||||
<div className="">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={project.devices!.includes("stream")}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: [...prev.devices!, "stream"],
|
||||
}));
|
||||
} else {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: prev.devices!.filter(
|
||||
(device) => device !== "stream"
|
||||
),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Stream</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={project.devices!.includes("touch")}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: [...prev.devices!, "touch"],
|
||||
}));
|
||||
} else {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: prev.devices!.filter(
|
||||
(device) => device !== "touch"
|
||||
),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Touch</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={project.devices!.includes("mobile")}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: [...prev.devices!, "mobile"],
|
||||
}));
|
||||
} else {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: prev.devices!.filter(
|
||||
(device) => device !== "mobile"
|
||||
),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>Mobile</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={project.devices!.includes("vr")}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: [...prev.devices!, "vr"],
|
||||
}));
|
||||
} else {
|
||||
setProject((prev) => ({
|
||||
...prev,
|
||||
devices: prev.devices!.filter(
|
||||
(device) => device !== "vr"
|
||||
),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>VR</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-full flex justify-end">
|
||||
<Button className="text-white outline-none">
|
||||
Сохранить изменения
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditProjectModal;
|
||||
@@ -8,6 +8,7 @@
|
||||
body {
|
||||
font-family: "Inter", sans-serif;
|
||||
background-color: #14161f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.font-gilroy {
|
||||
@@ -57,6 +58,8 @@ body {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* Custom Line Through */
|
||||
|
||||
.custom-line-through {
|
||||
position: relative;
|
||||
}
|
||||
@@ -84,3 +87,7 @@ body {
|
||||
rgba(20, 22, 31, 0.6) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply 2xl:text-[40px] xl:text-2xl sm:text-base text-sm;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ import ReactDOM from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import ProjectsPage from "./pages/ProjectsPage.tsx";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
},
|
||||
{
|
||||
path: "/projects",
|
||||
element: <ProjectsPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../utils/api";
|
||||
import IProject from "../types/IProject";
|
||||
import ProjectCard from "../components/ProjectCard";
|
||||
import Button from "../components/Button";
|
||||
import useModalStore from "../stores/useModalStore";
|
||||
import CreateProjectModal from "../components/modals/CreateProjectModal";
|
||||
import ModalContainer from "../components/ModalContainer";
|
||||
import EditProjectModal from "../components/modals/EditProjectModal";
|
||||
import DeleteProjectModal from "../components/modals/DeleteProjectModal";
|
||||
|
||||
function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<IProject[]>([]);
|
||||
const [setModal] = useModalStore((state) => [state.setModal]);
|
||||
|
||||
async function getProjects() {
|
||||
try {
|
||||
const projects: IProject[] = await api.get("projects").json();
|
||||
|
||||
setProjects(projects);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickCreateProject() {
|
||||
setModal(<CreateProjectModal />);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getProjects();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen 2xl:px-10 xl:px-8 sm:px-6 px-4 overflow-x-clip">
|
||||
<div className="relative conatiner mx-auto 2xl:max-w-screen-2xl">
|
||||
<div className="py-8 flex flex-col gap-8">
|
||||
<Button handleClick={handleClickCreateProject}>
|
||||
Добавить проект
|
||||
</Button>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{projects.map((project, index) => (
|
||||
<div key={index} className="relative">
|
||||
<ProjectCard {...project} />
|
||||
<div className="absolute top-0 right-0 p-4 flex gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setModal(<EditProjectModal projectId={project.id!} />)
|
||||
}
|
||||
className="px-3 py-2 border bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity"
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setModal(<DeleteProjectModal projectId={project.id!} />)
|
||||
}
|
||||
className="px-3 py-2 border bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectsPage;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ModalState {
|
||||
modal: ReactNode | null;
|
||||
setModal: (modal: ReactNode) => void;
|
||||
removeModal: () => void;
|
||||
}
|
||||
|
||||
const useModalStore = create<ModalState>((set) => ({
|
||||
modal: null,
|
||||
setModal: (modal) => set({ modal }),
|
||||
removeModal: set({ modal: null }),
|
||||
}));
|
||||
|
||||
export default useModalStore;
|
||||
@@ -0,0 +1,3 @@
|
||||
type Device = "stream" | "touch" | "mobile" | "vr";
|
||||
|
||||
export default Device;
|
||||
@@ -0,0 +1,14 @@
|
||||
import Device from "./Device";
|
||||
|
||||
interface IProject {
|
||||
id?: string;
|
||||
name: string;
|
||||
company: string;
|
||||
city: string;
|
||||
image: string;
|
||||
stage?: number;
|
||||
releaseYear?: number;
|
||||
devices?: Device[];
|
||||
}
|
||||
|
||||
export default IProject;
|
||||
@@ -0,0 +1,7 @@
|
||||
import ky from "ky";
|
||||
|
||||
const api = ky.extend({
|
||||
prefixUrl: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
export default api;
|
||||
Reference in New Issue
Block a user