upd
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_API_URL=http://192.168.1.170:3000
|
||||
+4
-1
@@ -12,14 +12,17 @@
|
||||
"dependencies": {
|
||||
"date-fns": "^2.30.0",
|
||||
"framer-motion": "^10.16.5",
|
||||
"ky": "^1.1.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-rangeslider": "^2.2.0",
|
||||
"react-router-dom": "^6.18.0",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"usehooks-ts": "^2.9.1"
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"zustand": "^4.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
|
||||
+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;
|
||||
@@ -1129,6 +1129,11 @@ keyv@^4.5.3:
|
||||
dependencies:
|
||||
json-buffer "3.0.1"
|
||||
|
||||
ky@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ky/-/ky-1.1.3.tgz#0d75906dfae00af0b4ea1a6fe29e505c4a0ee234"
|
||||
integrity sha512-t7q8sJfazzHbfYxiCtuLIH4P+pWoCgunDll17O/GBZBqMt2vHjGSx5HzSxhOc2BDEg3YN/EmeA7VKrHnwuWDag==
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
|
||||
@@ -1409,6 +1414,13 @@ react-circular-progressbar@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.1.0.tgz#99e5ae499c21de82223b498289e96f66adb8fa3a"
|
||||
integrity sha512-xp4THTrod4aLpGy68FX/k1Q3nzrfHUjUe5v6FsdwXBl3YVMwgeXYQKDrku7n/D6qsJA9CuunarAboC2xCiKs1g==
|
||||
|
||||
react-device-detect@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/react-device-detect/-/react-device-detect-2.2.3.tgz#97a7ae767cdd004e7c3578260f48cf70c036e7ca"
|
||||
integrity sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==
|
||||
dependencies:
|
||||
ua-parser-js "^1.0.33"
|
||||
|
||||
react-dom@^18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||
@@ -1680,6 +1692,11 @@ typescript@^5.0.2:
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
|
||||
integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
|
||||
|
||||
ua-parser-js@^1.0.33:
|
||||
version "1.0.37"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f"
|
||||
integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==
|
||||
|
||||
update-browserslist-db@^1.0.13:
|
||||
version "1.0.13"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
|
||||
@@ -1695,6 +1712,11 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
use-sync-external-store@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||
|
||||
usehooks-ts@^2.9.1:
|
||||
version "2.9.1"
|
||||
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.9.1.tgz#953d3284851ffd097432379e271ce046a8180b37"
|
||||
@@ -1742,3 +1764,10 @@ yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
zustand@^4.4.6:
|
||||
version "4.4.6"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.6.tgz#03c78e3e2686c47095c93714c0c600b72a6512bd"
|
||||
integrity sha512-Rb16eW55gqL4W2XZpJh0fnrATxYEG3Apl2gfHTyDSE965x/zxslTikpNch0JgNjJA9zK6gEFW8Fl6d1rTZaqgg==
|
||||
dependencies:
|
||||
use-sync-external-store "1.2.0"
|
||||
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
PORT=3000
|
||||
PORT=3000
|
||||
MONGO_URI=mongodb://root:p62Z!ZatgY25@194.26.138.94:27017/
|
||||
JWT_SECRET=yDcdWJgvlj2bJAuovYfQHTvtc3U9xQPw
|
||||
+7
-1
@@ -11,12 +11,18 @@
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2"
|
||||
"express": "^4.18.2",
|
||||
"mongoose": "^8.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sharp": "^0.32.6",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.15",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.8.10",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"nodemon": "^3.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { connect } from "mongoose";
|
||||
|
||||
async function connectDB() {
|
||||
try {
|
||||
await connect(process.env.MONGO_URI!, { dbName: "estate" });
|
||||
console.log("MongoDB connected...");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export default connectDB;
|
||||
@@ -1,62 +0,0 @@
|
||||
const db = {
|
||||
products: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Смартфон POCO C51 64 ГБ",
|
||||
price: 6499,
|
||||
image:
|
||||
"https://c.dns-shop.ru/thumb/st1/fit/500/500/256e8ece17c46e683dbe0facd94c5e69/5c108f7da6e5886d4d32d723d0e24ad0151bd790d7941374c56d9d8e9718d9f6.jpg.webp",
|
||||
colors: ["black", "blue", "green"],
|
||||
popularity: 3,
|
||||
desc: `Смартфон POCO C51 64 ГБ предлагает функции, необходимые для разговоров по мобильной связи, общения в социальных сетях и мультимедийных развлечений. Он выполнен в компактном пластиковом корпусе с обтекаемыми гранями и тыловой панелью голубого цвета. Экран IPS диагональю 6.52 дюйма обеспечивает интуитивное управление и реалистичное изображение.
|
||||
Высокая производительность системы достигается благодаря 8-ядерному процессору MediaTek Helio G36 и 2 ГБ оперативной памяти. На тыловой панели установлена сдвоенная камера 8+0.3 Мп с автофокусом и светодиодной вспышкой для реалистичной съемки фотографий и видео. Фронтальная камера 5 Мп позволяет делать селфи и общаться по видеосвязи. Встроенный сканер отпечатков пальцев гарантирует простую и безопасную разблокировку устройства. За автономность смартфона POCO C51 отвечает аккумуляторная батарея емкостью 5000 мА*ч.`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Смартфон realme C30s 64 ГБ",
|
||||
price: 6999,
|
||||
image:
|
||||
"https://c.dns-shop.ru/thumb/st1/fit/500/500/774bc3d792cbcab34769a178c61686f2/cb157601cb15ceac4756053ed94ff8a9b2598f461f86084ca57090724cd9214e.jpg.webp",
|
||||
colors: ["black", "blue"],
|
||||
popularity: 3,
|
||||
desc: `Смартфон realme C30S имеет тонкий корпус черного цвета и вес 186 г, что облегчает эксплуатацию. Для разграничения звонков можно установить две SIM-карты. Модель оснащена мощным производительным 8-ядерным процессором, который обеспечивает бесперебойную и быструю работу. Широкий экран диагональю 6.5 дюймов обеспечивает комфортную игру и просмотр фильмов. IPS-дисплей поддерживает насыщенную и яркую цветопередачу.
|
||||
Смартфон realme C30S имеет 64 ГБ памяти, чего достаточно для хранения необходимой информации. Батарея емкостью 5000 мА*ч позволяет долгое время играть или смотреть видео без подзарядки. Даже при остатке 5% можно включить режим энергосбережения, чтобы оставаться на связи. Для быстрой разблокировки экрана на боковой стороне корпуса есть сканер отпечатка пальцев.`,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Смартфон Xiaomi Redmi A2+ 64 ГБ",
|
||||
price: 7499,
|
||||
image:
|
||||
"https://c.dns-shop.ru/thumb/st1/fit/500/500/43aa8e3640bdc253df179db3eecd9cd3/b5926e67b994494fd67cdf8b75d9db0bad7bc5f4bc9839937ffc3daf7053abdc.jpg.webp",
|
||||
colors: ["black", "blue", "green"],
|
||||
popularity: 5,
|
||||
desc: `Смартфон Xiaomi Redmi A2+ в голубом цвете корпуса поддерживает установку двух SIM-карт, чтобы вы могли разграничить личные и деловые звонки. Экран обладает диагональю 6.52” и разрешением 1600x720 для комфортного просмотра любого контента. Закаленное стекло способствует защите дисплея от мелких повреждений: потертостей и царапин. 8-ядерный процессор MediaTek Helio G36 вместе с 3 ГБ оперативной памяти обеспечивает достаточный уровень производительности для запуска мобильных приложений и работы в режиме многозадачности.
|
||||
Смартфон Xiaomi Redmi A2+ имеет 64 ГБ встроенной памяти и предусматривает отдельный слот, в который можно установить карту памяти емкостью до 1 ТБ. Тыловая камера представлена двумя модулями 8+0.3 Мп для съемки детализированных и красочных снимков. Двойная светодиодная вспышка позволит проводить фотосъемку в условиях плохой освещенности. Для создания селфи предусмотрена 5-мегапиксельная фронтальная камера. Литий-полимерный аккумулятор емкостью 5000 мАч позволит устройству проработать в режиме разговора до 28 ч.`,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Смартфон Infinix SMART 7 HD 64 ГБ",
|
||||
price: 6999,
|
||||
image:
|
||||
"https://c.dns-shop.ru/thumb/st1/fit/500/500/746f2653ecd116f0b3f5bc3e1ce6040f/8a0c3b832d57c5ea8faae411b3afd708403516a91e448da83efb13ef5d6696bd.jpg.webp",
|
||||
colors: ["white", "black", "blue"],
|
||||
popularity: 2,
|
||||
desc: `Смартфон Infinix Smart 7 HD 64 ГБ выполнен в корпусе белого цвета и оснащен дисплеем 6.6 дюйма. Панель IPS (1612x720 пикселей) воспроизводит реалистичное изображение с насыщенными и яркими цветами. Плавная и бесперебойная работа аппаратной платформы при выполнении различных задач обеспечивается благодаря процессору Unisoc SC9863A и 2 ГБ оперативной памяти.
|
||||
Infinix Smart 7 HD оснащен слотами для установки двух карт SIM и карты памяти microSD. Основная камера 8+0.3 Мп предназначена для создания детализированных фотографий и видео. На передней стороне в каплевидном вырезе расположена камера 5 Мп, которая позволяет общаться по видеосвязи и делать селфи. В устройстве реализованы беспроводные интерфейсы Wi-Fi и Bluetooth. Аккумулятор 5000 мА*ч гарантирует до 32 часов работы смартфона без подзарядки в режиме просмотра видео.`,
|
||||
},
|
||||
|
||||
{
|
||||
id: 5,
|
||||
name: "Смартфон Tecno POP 7 64 ГБ",
|
||||
price: 8499,
|
||||
image:
|
||||
"https://c.dns-shop.ru/thumb/st4/fit/500/500/8278fc2e0c767175a1bc3fe505284ca7/88c242ac48d30b2977c1802c8ad41f63484c9e2bb3a0976befc7d0bb7caa6c6e.jpg.webp",
|
||||
colors: ["black", "blue", "purple"],
|
||||
popularity: 4,
|
||||
desc: `Смартфон Tecno POP 7 в пластиковом корпусе голубого цвета обладает многоуровневой биометрической защитой. Она запускает идентификацию личности по отпечатку и чертам лица. Процессор с 8 ядрами и 2 ГБ оперативной памяти выступают гарантом быстрого запуска приложений и отклика на пожелания пользователя. Энергия аккумулятора емкостью 5000 мАч рассчитана на длительное использование функционала.
|
||||
Смартфон Tecno POP 7 оснащен основной камерой с разрешением матрицы 8 Мп и светодиодной вспышкой. Кадры получатся насыщенными даже в условиях слабого освещения. В 6.6-дюймовый экран интегрирован датчик, который анализирует уровень внешнего освещения. Он автоматически уменьшает или повышает яркость для комфортного просмотра контента.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default db;
|
||||
+7
-3
@@ -1,6 +1,11 @@
|
||||
import "dotenv/config";
|
||||
import connectDB from "./config/db";
|
||||
import express, { json } from "express";
|
||||
import cors from "cors";
|
||||
import uploadRouter from "./routes/upload";
|
||||
import projectsRouter from "./routes/projects";
|
||||
|
||||
await connectDB();
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
@@ -8,9 +13,8 @@ const port = process.env.PORT || 3000;
|
||||
app.use(json());
|
||||
app.use(cors());
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.json({ ok: 1 });
|
||||
});
|
||||
app.use("/upload", uploadRouter);
|
||||
app.use("/projects", projectsRouter);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is listening on port ${port}`);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Schema, model } from "mongoose";
|
||||
|
||||
const projectSchema = new Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
company: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
city: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
stage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
releaseYear: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
devices: {
|
||||
type: Array,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true },
|
||||
}
|
||||
);
|
||||
|
||||
const Project = model("Project", projectSchema);
|
||||
|
||||
export default Project;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Router } from "express";
|
||||
import Project from "../models/Project";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
try {
|
||||
const projects = await Project.find().sort({ createdAt: -1 });
|
||||
|
||||
return res.json(projects);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const project = await Project.findById(req.params.id);
|
||||
|
||||
return res.json(project);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
try {
|
||||
const project = await Project.create(req.body);
|
||||
|
||||
return res.json(project);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:id", async (req, res) => {
|
||||
console.log(req.body);
|
||||
|
||||
try {
|
||||
const project = await Project.findByIdAndUpdate(req.params.id, req.body, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
});
|
||||
|
||||
console.log("updated", project);
|
||||
|
||||
return res.json(project);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req, res) => {
|
||||
console.log(req.body);
|
||||
|
||||
try {
|
||||
const project = await Project.findByIdAndDelete(req.params.id);
|
||||
|
||||
return res.json(project);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const projectsRouter = router;
|
||||
export default projectsRouter;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Router } from "express";
|
||||
import multer, { memoryStorage } from "multer";
|
||||
import sharp from "sharp";
|
||||
import fs from "fs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const upload = multer({
|
||||
dest: "uploads/",
|
||||
storage: memoryStorage(),
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.split("/")[0] === "image") {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Only images are allowed!"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.get("/:file", (req, res) => {
|
||||
const fileName = req.params.file;
|
||||
|
||||
try {
|
||||
const readStream = fs.createReadStream(`uploads/${fileName}`);
|
||||
readStream.pipe(res);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", upload.single("file"), async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.json({ error: "req.file" });
|
||||
}
|
||||
|
||||
try {
|
||||
const filename = `${uuidv4()}.jpg`;
|
||||
|
||||
await sharp(req.file.buffer)
|
||||
.resize({
|
||||
width: 512,
|
||||
height: 512,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toFile(`uploads/${filename}`);
|
||||
|
||||
return res.json({ file: `http://192.168.1.170:3000/upload/${filename}` });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const uploadRouter = router;
|
||||
export default uploadRouter;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
+1473
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user