This commit is contained in:
2025-02-20 12:21:40 +05:00
parent c82e5116c4
commit d3adac5f0d
43 changed files with 1000 additions and 816 deletions
-1
View File
@@ -1,4 +1,3 @@
NEXT_PUBLIC_API=http://192.168.1.250:3001/
NEXT_PUBLIC_OLD_API=https://graff.estate/api
NEXT_PUBLIC_S3_BUCKET=https://storage.yandexcloud.net/dult-faib-knac-fint/
NEXT_PUBLIC_TINYMCE_API_KEY=2vf68779upg45y46o6g5gaxldy9gzr399eyaaqa0ki3mj2h2
Vendored
-1
View File
@@ -1,7 +1,6 @@
declare namespace NodeJS {
interface ProcessEnv {
readonly NEXT_PUBLIC_API: string;
readonly NEXT_PUBLIC_OLD_API: string;
readonly NEXT_PUBLIC_S3_BUCKET: string;
readonly NEXT_PUBLIC_TINYMCE_API_KEY: string;
}
-17
View File
@@ -1,23 +1,6 @@
import ky from 'ky';
export const oldApi = ky.extend({
prefixUrl: process.env.NEXT_PUBLIC_OLD_API,
});
export const api = ky.extend({
prefixUrl: process.env.NEXT_PUBLIC_API,
credentials: 'include',
// hooks: {
// beforeRequest: [
// async (req) => {
// if (new URL(req.url).pathname === '/auth/check') {
// await ky
// .get(process.env.NEXT_PUBLIC_API + 'auth/refresh', {
// credentials: 'include',
// })
// .json();
// }
// },
// ],
// },
});
+6
View File
@@ -3,6 +3,7 @@ import { ArticlesList } from '@/components/pages/BlogPage/ArticlesList';
import { ArticlesPageActions } from '@/components/pages/BlogPage/ArticlesPageActions';
import { getQueryClient } from '@/lib/queryClient';
import { IArticle } from '@/types/IArticle';
import { IStory } from '@/types/IStory';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
export default async function BlogPage({
@@ -33,6 +34,11 @@ export default async function BlogPage({
.json<IArticle[]>(),
});
await queryClient.prefetchQuery({
queryKey: ['stories'],
queryFn: async () => await api.get('stories').json<IStory[]>(),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ArticlesPageActions tags={tags} />
-1
View File
@@ -58,7 +58,6 @@ export default async function HomePage() {
<Awards />
<Projects />
<Clients />
{/* <ReorderGrid /> */}
</HydrationBoundary>
);
}
+1 -1
View File
@@ -111,7 +111,7 @@ html {
}
@utility line1 {
@apply 2xl:text-[128px] lg:max-2xl:text-[clamp(96px,4.444vw,128px)] md:max-lg:text-[clamp(56px,56px+(100vw-768px)/672*40,96px)] xs:max-md:text-[clamp(40px,40px+(100vw-360px)/408*16,56px)] text-[40px] leading-[85%];
@apply 2xl:text-[128px] lg:max-2xl:text-[clamp(96px,96px+(100vw-1440px)/96*32,128px)] md:max-lg:text-[clamp(56px,56px+(100vw-768px)/672*40,96px)] xs:max-md:text-[clamp(40px,40px+(100vw-360px)/408*16,56px)] text-[40px] leading-[85%];
}
@utility line2 {
+1 -1
View File
@@ -39,7 +39,7 @@ export default function RootLayout({
}>) {
return (
<html>
<body className="min-h-screen flex flex-col justify-between snap-ysnap-mandatory">
<body className="min-h-screen flex flex-col justify-between">
<Providers>
{children}
<ModalContainer />
+5 -3
View File
@@ -9,7 +9,7 @@ export function DeleteItemModal({
id,
}: {
title: string;
entity: 'projects' | 'companies' | 'articles';
entity: 'projects' | 'companies' | 'articles' | 'stories';
id: string;
}) {
const { setModal } = useModalStore();
@@ -21,7 +21,7 @@ export function DeleteItemModal({
<div className="flex justify-between items-center border-b border-[#ccc] pb-4 gap-4">
<p className="text-2xl">{title}</p>
<button
onClick={() => setModal(null, '')}
onClick={() => setModal(null)}
className="hover:bg-white
hover:bg-opacity-10 p-2 transition-colors rounded-full cursor-pointer group"
>
@@ -37,7 +37,9 @@ export function DeleteItemModal({
? 'проект'
: entity === 'companies'
? 'компанию'
: 'статью'}
: entity === 'articles'
? 'статью'
: 'историю'}
</Button>
</div>
);
+9 -17
View File
@@ -16,20 +16,17 @@ import TrashIcon from '../../public/icons/trash.svg';
export function ImageUploader<T extends FieldValues>({
dest,
fieldName,
// item,
name,
label,
className = '',
required = false,
}: {
dest: string;
fieldName: Path<T>;
item: Record<'img', string> & Record<'id', string>;
name: Path<T>;
label: string;
required?: boolean;
className?: string;
}) {
// const [currentImg, setCurrentImg] = useState(item.img);
const [file, setFile] = useState<File>();
const [previewFile, setPreviewFile] = useState('');
@@ -55,24 +52,19 @@ export function ImageUploader<T extends FieldValues>({
const filePaths = await api
.post('upload', { body: formData })
.json<PathValue<T, Path<T>>[]>();
setValue(fieldName, filePaths[0]!);
setValue(name, filePaths[0]!);
} catch (error) {
if (error instanceof Error) {
alert(`Error: ${error.message}`);
}
}
}, [dest, fieldName, file, setValue]);
}, [dest, name, file, setValue]);
useEffect(() => {
uploadFile();
}, [file, uploadFile]);
const currentImg = useWatch({ control, name: fieldName });
// useEffect(() => {
// const { unsubscribe } = watch((input) => setCurrentImg(input[fieldName]));
// return unsubscribe;
// }, [watch, fieldName]);
const currentImg = useWatch({ control, name: name });
return (
<Dropzone
@@ -93,7 +85,7 @@ export function ImageUploader<T extends FieldValues>({
type="file"
accept={'image/*'}
required={required}
{...{ ...register(fieldName, { required }), ref: inputRef }}
{...{ ...register(name, { required }), ref: inputRef }}
onChange={handleChangeFile}
{...getInputProps()}
/>
@@ -101,7 +93,7 @@ export function ImageUploader<T extends FieldValues>({
<>
<div
className={`relative${
fieldName === 'image' ? ' max-w-[calc(292/824*100%)]' : ''
name === 'image' ? ' max-w-[calc(292/824*100%)]' : ''
}`}
>
<Image
@@ -111,7 +103,7 @@ export function ImageUploader<T extends FieldValues>({
}
alt={''}
className={`pointer-events-none aspect-square object-${
fieldName === 'image' ? 'cover rounded-2xl' : 'contain'
name === 'image' ? 'cover rounded-2xl' : 'contain'
} !relative`}
fill
sizes="100%"
@@ -133,7 +125,7 @@ export function ImageUploader<T extends FieldValues>({
onClick={(e) => {
e.preventDefault();
setFile(undefined!);
setValue(fieldName, undefined!);
setValue(name, undefined!);
setPreviewFile('');
}}
className="bg-[#37393B99] px-3 py-2 rounded-xl cursor-pointer"
+15 -20
View File
@@ -5,6 +5,8 @@ import { useModalStore } from '@/stores/useModalStore';
import { IArticle } from '@/types/IArticle';
import { ICompany } from '@/types/ICompany';
import { IProject } from '@/types/IProject';
import { IStory } from '@/types/IStory';
import { isArticle } from '@/utils/isArticle';
import { isCompany } from '@/utils/isCompany';
import { isProject } from '@/utils/isProject';
import { SyntheticEvent } from 'react';
@@ -14,11 +16,12 @@ import { DeleteItemModal } from './DeleteItemModal';
import { ArticleContentFormModal } from './modals/ArticleContentFormModal';
import { CompanyFormModal } from './modals/CompanyFormModal';
import { ProjectFormModal } from './modals/ProjectFormModal';
import { StoryFormModal } from './modals/StoryFormModal';
export function ItemActions({
item,
}: {
item: IProject | ICompany | IArticle;
item: IProject | ICompany | IArticle | IStory;
}) {
const { data: auth } = useCheckAuthQuery();
@@ -28,21 +31,12 @@ export function ItemActions({
e.stopPropagation();
if (isProject(item)) {
const { company, ...project } = item;
setModal(
<ProjectFormModal action="edit" defaultValues={project} />,
'editProjectForm'
);
setModal(<ProjectFormModal action="edit" defaultValues={project} />);
} else if (isCompany(item)) {
const { projects, ...company } = item;
setModal(
<CompanyFormModal action="edit" defaultValues={company} />,
'editCompanyForm'
);
} else
setModal(
<ArticleContentFormModal {...item} />,
'articleContentFormModal'
);
setModal(<CompanyFormModal action="edit" defaultValues={company} />);
} else if (isArticle(item)) setModal(<ArticleContentFormModal {...item} />);
else setModal(<StoryFormModal action={'edit'} defaultValues={item} />);
}
function handleDelete(e: SyntheticEvent) {
@@ -56,19 +50,20 @@ export function ItemActions({
? 'проекта'
: isCompany(item)
? 'компании'
: 'статьи')
: isArticle(item)
? 'статьи'
: 'истории')
}
entity={
isProject(item)
? 'projects'
: isCompany(item)
? 'companies'
: 'articles'
: isArticle(item)
? 'articles'
: 'stories'
}
/>,
`delete${
isProject(item) ? 'Project' : isCompany(item) ? 'Company' : 'Article'
}`
/>
);
}
-3
View File
@@ -4,7 +4,6 @@ import { api } from '@/api';
import { useMediaQueries } from '@/hooks/useMediaQueries';
import { useScroll } from '@/hooks/useScroll';
import { useCheckAuthQuery } from '@/queries/checkAuth';
import { useModalStore } from '@/stores/useModalStore';
import { HeaderLink } from '@/ui/HeaderLink';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
@@ -21,8 +20,6 @@ import SkolkovoIcon from '../../../public/icons/skolkovo.svg';
import { Products } from './Products';
export function Header() {
const { setModal } = useModalStore();
const queryClient = useQueryClient();
const { data: auth } = useCheckAuthQuery();
+3 -12
View File
@@ -4,11 +4,11 @@ import { useModalStore } from '@/stores/useModalStore';
import { useEffect } from 'react';
export function ModalContainer() {
const { modal, name, setModal } = useModalStore();
const { modal, setModal } = useModalStore();
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.key === 'Escape') setModal(null, '');
if (e.key === 'Escape') setModal(null);
};
document.addEventListener('keydown', listener);
@@ -17,16 +17,7 @@ export function ModalContainer() {
return (
modal && (
<div
className={
'fixed left-0 z-[14] w-full h-full flex justify-center items-start transition-opacity' +
(name === 'video' || name === 'form'
? ' bg-black bg-opacity-90 [backdrop-filter:blur(10px);]'
: '') +
(name === 'menu' ? ' lg:top-16 top-12' : ' top-0') +
(name.endsWith('Form') ? ' overflow-auto' : '')
}
>
<div className="fixed left-0 z-[14] w-full h-full flex justify-center items-start transition-opacity">
<div className="absolute backdrop-blur-lg w-full h-full z-[1]" />
{modal}
</div>
+133
View File
@@ -0,0 +1,133 @@
import { api } from '@/api';
import { Button } from '@/ui/Button';
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import Dropzone from 'react-dropzone';
import { useFormContext, useWatch } from 'react-hook-form';
import AddIcon from '../../public/icons/add.svg';
import RestartIcon from '../../public/icons/restart.svg';
import TrashIcon from '../../public/icons/trash.svg';
export function VideoUploader({ name, dest }: { name: string; dest: string }) {
const { register, control, setValue } = useFormContext();
const [file, setFile] = useState<File>();
const [previewFile, setPreviewFile] = useState('');
const currentVideo = useWatch({ control, name });
function handleChangeFile(e: ChangeEvent<HTMLInputElement>) {
if (!e.target.files) return;
const targetFile = e.target.files[0];
setFile(targetFile);
setPreviewFile(URL.createObjectURL(targetFile));
}
const uploadFile = useCallback(async () => {
if (!file) return;
const formData = new FormData();
formData.append('dest', dest);
formData.append('files', file);
try {
const filePaths = await api
.post('upload', { body: formData })
.json<string[]>();
setValue(name, filePaths[0]!);
} catch (error) {
if (error instanceof Error) {
alert(`Error: ${error.message}`);
}
}
}, [file, name, setValue]);
useEffect(() => {
uploadFile();
}, [file, uploadFile]);
return (
<Dropzone
onDrop={([file]) => {
setFile(file);
setPreviewFile(URL.createObjectURL(file));
}}
accept={{
'video/*': ['*'],
}}
noClick
>
{({ getRootProps, getInputProps, inputRef }) => (
<div
{...getRootProps()}
className="relative border border-[#37393B] px-3 py-2 rounded-lg flex flex-col justify-center items-center gap-2 flex-1"
>
<input
type="file"
accept="video/mp4, video/mov"
required
{...{ ...register(name), ref: inputRef }}
onChange={handleChangeFile}
{...getInputProps()}
/>
{previewFile || currentVideo ? (
<>
<div className="relative">
<video
src={
previewFile ||
process.env.NEXT_PUBLIC_S3_BUCKET + currentVideo
}
playsInline
muted
loop
autoPlay
className="pointer-events-none !relative rounded-xl"
/>
</div>
<div className="left-6 top-6 absolute flex gap-2">
<Button
onClick={() => inputRef.current?.click()}
color="secondary"
className="px-3 py-2 bg-[#37393B99] btns"
rounded="xl"
icon={
<RestartIcon className="lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4 text-white" />
}
>
Заменить
</Button>
<button
onClick={(e) => {
e.preventDefault();
setFile(undefined!);
setValue(name, undefined!);
setPreviewFile('');
}}
className="bg-[#37393B99] px-3 py-2 rounded-xl cursor-pointer"
>
<TrashIcon className="lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4 text-white" />
</button>
</div>
</>
) : (
<div className="flex flex-col items-center gap-4">
<p className="text-[#7A7A7A] font-medium text1 text-center max-w-[calc(346/824*100%)]">
Выберите или перетащите видео
</p>
<Button
onClick={() => inputRef.current?.click()}
color="secondary"
className="bg-[#37393B99] rounded-xl px-3 py-[9px] btnm"
icon={<AddIcon className="w-4 h-4 text-white" />}
>
Выбрать
</Button>
</div>
)}
</div>
)}
</Dropzone>
);
}
@@ -27,9 +27,8 @@ export function ArticleQuoteInput({
<div className="flex gap-4">
<ImageUploader
dest="articles"
fieldName={`blocks.${index}.avatar`}
name={`blocks.${index}.avatar`}
required
item={{ id: item.id, img: item.avatar }}
label="Автор"
/>
<div className="space-y-1 w-full">
@@ -26,8 +26,7 @@ export function ArticleSliderImageInput({
<Reorder.Item value={item} className="flex items-center" drag as="div">
<ImageUploader
dest="blog"
fieldName={`blocks.${index}.images.${imgIndex}.img`}
item={item}
name={`blocks.${index}.images.${imgIndex}.img`}
label="Выберите изображение"
/>
<button
@@ -1,15 +1,8 @@
import { api } from '@/api';
import { useFieldArrayFormContext } from '@/lib/FieldArrayFormProvider';
import { IVideo } from '@/types/IArticle';
import { Button } from '@/ui/Button';
import { Reorder } from 'framer-motion';
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import Dropzone from 'react-dropzone';
import { useWatch } from 'react-hook-form';
import AddIcon from '../../../public/icons/add.svg';
import RestartIcon from '../../../public/icons/restart.svg';
import TrashIcon from '../../../public/icons/trash.svg';
import { IArticleInput } from '../modals/ArticleFormModal';
import { VideoUploader } from '../VideoUploader';
export function ArticleVideoUploader({
item,
@@ -18,45 +11,44 @@ export function ArticleVideoUploader({
item: IVideo & { id: string };
index: number;
}) {
const { remove, register, control, setValue } =
useFieldArrayFormContext<IArticleInput>();
const { remove } = useFieldArrayFormContext();
const [file, setFile] = useState<File>();
const [previewFile, setPreviewFile] = useState('');
// const [file, setFile] = useState<File>();
// const [previewFile, setPreviewFile] = useState('');
const currentVideo = useWatch({ control, name: `blocks.${index}.src` });
// const currentVideo = useWatch({ control, name: `blocks.${index}.src` });
function handleChangeFile(e: ChangeEvent<HTMLInputElement>) {
if (!e.target.files) return;
// function handleChangeFile(e: ChangeEvent<HTMLInputElement>) {
// if (!e.target.files) return;
const targetFile = e.target.files[0];
// const targetFile = e.target.files[0];
setFile(targetFile);
setPreviewFile(URL.createObjectURL(targetFile));
}
// setFile(targetFile);
// setPreviewFile(URL.createObjectURL(targetFile));
// }
const uploadFile = useCallback(async () => {
if (!file) return;
// const uploadFile = useCallback(async () => {
// if (!file) return;
const formData = new FormData();
formData.append('dest', 'articles');
formData.append('files', file);
// const formData = new FormData();
// formData.append('dest', 'articles');
// formData.append('files', file);
try {
const filePaths = await api
.post('upload', { body: formData })
.json<string[]>();
setValue(`blocks.${index}.src`, filePaths[0]!);
} catch (error) {
if (error instanceof Error) {
alert(`Error: ${error.message}`);
}
}
}, [file, index, setValue]);
// try {
// const filePaths = await api
// .post('upload', { body: formData })
// .json<string[]>();
// setValue(`blocks.${index}.src`, filePaths[0]!);
// } catch (error) {
// if (error instanceof Error) {
// alert(`Error: ${error.message}`);
// }
// }
// }, [file, index, setValue]);
useEffect(() => {
uploadFile();
}, [file, uploadFile]);
// useEffect(() => {
// uploadFile();
// }, [file, uploadFile]);
return (
<Reorder.Item
@@ -64,7 +56,7 @@ export function ArticleVideoUploader({
as="div"
className="w-full flex items-start gap-5"
>
<Dropzone
{/* <Dropzone
onDrop={([file]) => {
setFile(file);
setPreviewFile(URL.createObjectURL(file));
@@ -141,7 +133,8 @@ export function ArticleVideoUploader({
)}
</div>
)}
</Dropzone>
</Dropzone> */}
<VideoUploader name={`blocks.${index}.src`} dest="articles" />
<button onClick={() => remove(index)} className="cursor-pointer">
<TrashIcon className="text-white lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4" />
</button>
@@ -57,7 +57,7 @@ export function ArticleContentFormModal({
blocks: JSON.stringify(getValues('blocks')),
drafted,
});
setModal(null, '');
setModal(null);
}
return (
@@ -83,8 +83,7 @@ export function ArticleContentFormModal({
posterImage,
id,
}}
/>,
'editArticleModal'
/>
)
}
icon={
+1 -1
View File
@@ -36,7 +36,7 @@ export function ArticleFormActions({
</div>
<button
className="bg-[#37393B99] rounded-2xl p-4 absolute top-5 right-5 z-[2] cursor-pointer"
onClick={() => setModal(null, '')}
onClick={() => setModal(null)}
>
<CloseIcon className="lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4 text-white" />
</button>
+6 -11
View File
@@ -39,10 +39,7 @@ export function ArticleFormModal<TAction extends 'create' | 'edit'>({
...data,
blocks: JSON.stringify(defaultValues ? defaultValues.blocks : []),
});
setModal(
<ArticleContentFormModal {...article} />,
'articleContentFormModal'
);
setModal(<ArticleContentFormModal {...article} />);
}
const { handleSubmit, getValues, control } = form;
@@ -57,7 +54,7 @@ export function ArticleFormModal<TAction extends 'create' | 'edit'>({
),
drafted,
});
setModal(null, '');
setModal(null);
}
const { mutateAsync } = useArticleMutation(
@@ -77,8 +74,8 @@ export function ArticleFormModal<TAction extends 'create' | 'edit'>({
submitHandler={handleSubmit(onSubmit)}
disabled={!title || !tags || !cardImage || !posterImage}
/>
<FormProvider {...form}>
<form className="space-y-10">
<FormProvider {...form}>
<TextInput
name="title"
label="Название статьи"
@@ -87,8 +84,7 @@ export function ArticleFormModal<TAction extends 'create' | 'edit'>({
<ImageUploader
dest="blog"
required
fieldName="cardImage"
item={{ id: '', img: defaultValues?.cardImage ?? '' }}
name="cardImage"
label="Загрузите или перетащите изображение для превью (рекомендованнный размер 1080/1080 px)"
/>
<div className="gap-y-4 flex flex-col">
@@ -103,12 +99,11 @@ export function ArticleFormModal<TAction extends 'create' | 'edit'>({
<ImageUploader
dest="blog"
required
fieldName="posterImage"
item={{ id: '', img: defaultValues?.posterImage ?? '' }}
name="posterImage"
label="Загрузите или перетащите изображение для превью (рекомендованнный размер 1080/1080 px)"
/>
</form>
</FormProvider>
</form>
</div>
</>
);
+4 -6
View File
@@ -43,7 +43,7 @@ export function CompanyFormModal<TAction extends 'create' | 'edit'>({
: await api.put(`companies/${defaultValues?.id}`, { json }).json(),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['companies'] });
setModal(null, '');
setModal(null);
},
});
@@ -51,7 +51,7 @@ export function CompanyFormModal<TAction extends 'create' | 'edit'>({
<>
<button
className="bg-[#37393B99] rounded-2xl p-4 absolute top-5 right-5 z-[2] cursor-pointer"
onClick={() => setModal(null, '')}
onClick={() => setModal(null)}
>
<CloseIcon className="lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4 text-white" />
</button>
@@ -69,15 +69,13 @@ export function CompanyFormModal<TAction extends 'create' | 'edit'>({
<ImageUploader
className="aspect-square w-full"
dest="projects"
fieldName="logo"
item={{ id: '', img: defaultValues?.logo ?? '' }}
name="logo"
label="Загрузите или перетащите логотип ( в формате svg )"
/>
<ImageUploader
className="aspect-square w-full"
dest="projects"
fieldName="mapIcon"
item={{ id: '', img: defaultValues?.mapIcon ?? '' }}
name="mapIcon"
label="Загрузите или перетащите иконку ( в формате svg )"
/>
</div>
@@ -1,30 +0,0 @@
import { useModalStore } from '@/stores/useModalStore';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
export function DeleteArticleModal({ id }: { id: string }) {
const { setModal } = useModalStore();
async function handleDeleteArticle() {}
return (
<div className="bg-white shadow-lg text-black p-8 rounded-xl flex flex-col gap-4">
<div className="flex justify-between items-center border-b border-[#ccc] pb-4 gap-4">
<p className="text-xl">Удаление статьи</p>
<button
onClick={() => setModal(null, '')}
className="p-2 hover:bg-white hover:bg-opacity-10 transition-colors rounded-full"
>
<Icon name='close' color='white' />
</button>
</div>
<Button
onClick={handleDeleteArticle}
className="text-white self-end outline-none"
>
Удалить статью
</Button>
</div>
);
}
+2 -4
View File
@@ -96,8 +96,7 @@ export function ProjectFormModal<TAction extends 'create' | 'edit'>({
</div>
<ImageUploader
dest="projects"
fieldName="image"
item={{ img: defaultValues?.image ?? '', id: '' }}
name="image"
label="Загрузите или перетащите изображение для превью (рекомендованнный размер 1080/1080 px)"
/>
<div className="flex flex-col gap-4">
@@ -126,7 +125,6 @@ export function ProjectFormModal<TAction extends 'create' | 'edit'>({
</select>
<OpenFormModalWrapper
modal={<CompanyFormModal action={'create'} />}
modalName="companyForm"
>
<Button
rounded="2xl"
@@ -184,7 +182,7 @@ export function ProjectFormModal<TAction extends 'create' | 'edit'>({
</div>
<button
className="bg-[#37393B99] rounded-2xl p-4 absolute top-5 right-5 z-[2] cursor-pointer"
onClick={() => setModal(null, '')}
onClick={() => setModal(null)}
>
<CloseIcon className="lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4 text-white" />
</button>
+24 -13
View File
@@ -1,9 +1,10 @@
import { stories } from '@/consts/stories';
import { useMediaQueries } from '@/hooks/useMediaQueries';
import { useGetStories } from '@/queries/getStories';
import { useModalStore } from '@/stores/useModalStore';
import { createRef, RefObject, useEffect, useRef, useState } from 'react';
import { useSwipeable } from 'react-swipeable';
import CloseIcon from '../../../public/icons/close.svg';
import { ItemActions } from '../ItemActions';
export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
const { setModal } = useModalStore();
@@ -16,9 +17,12 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
const ref = useRef<HTMLDivElement>(null);
const { data: stories } = useGetStories();
useEffect(() => {
if (!stories) return;
setVideoRefs(stories.map(createRef<HTMLVideoElement>));
}, []);
}, [stories]);
useEffect(() => {
if (
@@ -40,7 +44,7 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
useEffect(() => {
if (currentProgress === 1 && videoRefs.length) {
if (currentIndex === videoRefs.length - 1) setModal(null, '');
if (currentIndex === videoRefs.length - 1) setModal(null);
else setCurrentIndex((prev) => prev + 1);
}
}, [currentProgress, setModal, videoRefs]);
@@ -68,7 +72,7 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
preventScrollOnSwipe: true,
touchEventOptions: { passive: false },
onSwipedLeft: () =>
setCurrentIndex((prev) => Math.min(stories.length - 1, prev + 1)),
setCurrentIndex((prev) => Math.min((stories?.length ?? 0) - 1, prev + 1)),
onSwipedRight: () => setCurrentIndex((prev) => Math.max(0, prev - 1)),
onTouchStartOrOnMouseDown: (e) => {
const slider = ref.current;
@@ -80,26 +84,30 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
)
return;
if ((e.event as TouchEvent).touches[0].clientX > slider!.clientWidth / 2)
setCurrentIndex((prev) => Math.min(stories.length - 1, prev + 1));
setCurrentIndex((prev) =>
Math.min((stories?.length ?? 0) - 1, prev + 1)
);
else setCurrentIndex((prev) => Math.max(0, prev - 1));
},
});
if (!stories) return null;
return (
<>
<button
className="rounded-2xl p-4 bg-[#37393B99] absolute top-5 right-5 z-10 cursor-pointer"
onClick={() => setModal(null, '')}
onClick={() => setModal(null)}
>
<CloseIcon className="lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4 text-white" />
</button>
<div
{...handlers}
className="overflow-hidden z-1 md:m-auto lg:max-w-[84.028vw] lg:max-h-[76.433vh] md:max-lg:max-w-[157.422vw] md:max-lg:max-h-[67.669vh] max-md:max-w-screen max-h-dvh max-md:top-0"
className="overflow-hidden z-1 md:m-auto lg:w-[84.028vw] lg:h-[76.433vh] md:max-lg:w-[157.422vw] md:max-lg:h-[67.669vh] max-md:w-screen h-dvh max-md:top-0"
>
<div ref={ref} className="flex md:gap-6 items-center h-full relative">
{!!videoRefs.length &&
stories.map(({ id, video, title, poster }, index) => (
stories?.map(({ id, video, preview, text, createdAt }, index) => (
<div
style={{
transform: isLg
@@ -110,7 +118,7 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
left: isLg || isMd ? 24 * (1 - currentIndex) : 0,
}}
key={id}
className={`select-none relative flex items-end overflow-hidden md:rounded-xl cursor-pointer transition-transform p-4 ${
className={`select-none relative flex items-end group overflow-hidden md:rounded-xl cursor-pointer transition-transform p-4 ${
index === currentIndex
? 'lg:min-w-[28.125vw] lg:h-[76.433vh] md:max-lg:min-w-[52.734vw] md:max-lg:h-[67.669vh] max-md:min-w-screen max-md:h-dvh'
: 'lg:min-w-[26.25vw] lg:h-[71.338vh] md:max-lg:min-w-[49.219vw] md:max-lg:h-[63.158vh] max-md:min-w-screen max-md:h-dvh'
@@ -119,8 +127,8 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
>
<video
ref={videoRefs[index]}
src={video}
poster={poster}
src={process.env.NEXT_PUBLIC_S3_BUCKET + video}
poster={process.env.NEXT_PUBLIC_S3_BUCKET + preview}
className="absolute inset-0 object-cover object-center md:rounded-xl"
playsInline
/>
@@ -133,7 +141,7 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
/>
{currentIndex === index && (
<div className="space-y-5 z-1 max-md:hidden">
<p className="heading1 font-medium">{title}</p>
<p className="heading1 font-medium">{text}</p>
<div className="bg-white/30 w-full h-1 rounded-[34px]">
<div
className="h-1 bg-white transition-[width] rounded-[34px]"
@@ -142,11 +150,13 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
</div>
</div>
)}
<ItemActions item={{ id, text, video, preview, createdAt }} />
</div>
))}
</div>
{stories && (
<div className="md:hidden absolute space-y-6 left-2.5 right-2.5 bottom-4 z-1 w-full">
<p className="heading1 font-medium">{stories[currentIndex].title}</p>
<p className="heading1 font-medium">{stories[currentIndex].text}</p>
<div className="flex gap-1">
{stories.map(({ id }, index) => (
<div key={id} className="bg-white/40 flex-1 rounded-[34px] h-1">
@@ -162,6 +172,7 @@ export function StoriesModal({ startIndex = 0 }: { startIndex?: number }) {
))}
</div>
</div>
)}
</div>
</>
);
+79
View File
@@ -0,0 +1,79 @@
import { useStoryMutation } from '@/hooks/useStoryMutation';
import { useModalStore } from '@/stores/useModalStore';
import { TextInput } from '@/ui/TextInput';
import {
FormProvider,
SubmitHandler,
useForm,
useWatch,
} from 'react-hook-form';
import CloseIcon from '../../../public/icons/close.svg';
import { ImageUploader } from '../ImageUploader';
import { VideoUploader } from '../VideoUploader';
import { FormModalHeader } from './FormModalHeader';
export interface IStoryFormInput {
text: string;
video: string;
preview: string;
createdAt: string;
}
interface IStoryFormModalProps<TAction extends 'create' | 'edit'> {
action: TAction;
defaultValues?: TAction extends 'edit'
? IStoryFormInput & { id: string }
: undefined;
}
export function StoryFormModal<TAction extends 'create' | 'edit'>({
action,
defaultValues,
}: IStoryFormModalProps<TAction>) {
const { mutateAsync } = useStoryMutation(
action === 'create'
? { action, id: undefined }
: { action, id: defaultValues!.id }
);
const form = useForm<IStoryFormInput>({
defaultValues: defaultValues ?? { createdAt: new Date().toISOString() },
});
const { handleSubmit, control } = form;
const { preview, video } = useWatch({ control });
const { setModal } = useModalStore();
return (
<>
<div className="relative py-10 space-y-10 bg-[#232425] rounded-[28px] top-5 w-[66.25vw] pl-[75px] pr-[55px] overflow-y-auto z-[2] max-h-[calc(100vh-40px)]">
<FormModalHeader
disabled={!preview || !video}
submitHandler={handleSubmit(
mutateAsync as SubmitHandler<IStoryFormInput>
)}
/>
<FormProvider {...form}>
<form className="space-y-10">
<VideoUploader name="video" dest="stories" />
<ImageUploader
dest="stories"
name="preview"
label="Загрузите или перетащите изображение для превью"
required
/>
<TextInput name="text" label="Описание сториса" placeholder="" />
</form>
</FormProvider>
</div>
<button
className="bg-[#37393B99] rounded-2xl p-4 absolute top-5 right-5 z-[2] cursor-pointer"
onClick={() => setModal(null)}
>
<CloseIcon className="lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4 text-white" />
</button>
</>
);
}
@@ -31,12 +31,7 @@ export function ArticleSyncPage({ articleId }: { articleId: string }) {
className="bg-[#37393B99] z-3 backdrop-blur-sm p-4 btnm"
color="secondary"
rounded="2xl"
onClick={() =>
setModal(
<ArticleContentFormModal {...article} />,
'articleContentFormModal'
)
}
onClick={() => setModal(<ArticleContentFormModal {...article} />)}
icon={
<EditIcon className="lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4 text-white" />
}
@@ -16,10 +16,7 @@ export function ArticlesPageActions({ tags }: { tags: string[] }) {
return (
<div className="lg:sticky lg:top-12 absolute flex flex-col justify-between gap-y-4">
<div className="space-y-2 max-lg:hidden">
<OpenFormModalWrapper
modalName="addArticle"
modal={<ArticleFormModal action="create" />}
>
<OpenFormModalWrapper modal={<ArticleFormModal action="create" />}>
<Button
className="px-3 py-2 outline-none btns flex items-center text-nowrap"
rounded="xl"
@@ -1,6 +1,6 @@
'use client';
import { oldApi } from '@/api';
import { api } from '@/api';
import regionsData from '@/consts/regionsData.json';
import { GradientButton } from '@/ui/GradientButton';
import { Title } from '@/ui/Title';
@@ -33,18 +33,17 @@ export function Calculator() {
const [calculated, setCalculated] = useState(true);
async function getRegionName() {
const result: any = await oldApi.get('getRegionName').json();
if (result.error) {
setSelectedRegion(regionsData.find((region) => region.id === 11));
return;
}
try {
const result: any = await api
.get('getRegionName')
.json<{ regionName: string }>();
const foundRegion =
regionsData.find((region) => region.name === result.regionName) ||
regionsData.find((region) => region.name === result.regionName) ??
regionsData.find((region) => region.id === 11);
setSelectedRegion(foundRegion);
} catch (error) {
setSelectedRegion(regionsData.find((region) => region.id === 11));
}
}
useEffect(() => {
@@ -66,7 +66,6 @@ export function Clients() {
</Title>
<OpenFormModalWrapper
modal={<CompanyFormModal action="create" />}
modalName="addCompany"
className="aspect-square flex flex-col items-center justify-center gap-3"
>
<GradientButton>
+1 -1
View File
@@ -62,7 +62,7 @@ export function Motivation() {
/>
</div>
<button
onClick={() => setModal(<StoriesModal />, 'stories')}
onClick={() => setModal(<StoriesModal />)}
className="flex flex-col justify-between lg:w-[28.611vw] md:max-lg:w-[47.135vw] lg:h-[15.694vw] md:max-lg:h-[29.427vw] w-[46.111vw] h-[61.111vw] lg:p-[1.667vw] p-4 lg:col-start-2 lg:row-start-1 row-start-2 bg-[radial-gradient(ellipse_at_bottom_right,#7A7A7A50,transparent)] rounded-2xl relative cursor-pointer outline-none"
>
<p className="font-medium heading2 lg:max-w-[60%] md:max-lg:max-w-[19.271vw] self-start text-left">
@@ -23,7 +23,6 @@ export function ProjectsPageHeader() {
</Title>
{auth && (
<OpenFormModalWrapper
modalName="addProjectForm"
modal={<ProjectFormModal action={'create'} />}
className="btns sticky top-0"
>
+45 -46
View File
@@ -8,63 +8,63 @@
},
{
"id": 2,
"name": "Амурская область",
"name": "Амурская Область",
"areaInComplex": 5837,
"areaApartment": 48,
"costPerSquare": 131
},
{
"id": 3,
"name": "Архангельская область",
"name": "Архангельская Область",
"areaInComplex": 6566,
"areaApartment": 44,
"costPerSquare": 101
},
{
"id": 4,
"name": "Астраханская область",
"name": "Астраханская Область",
"areaInComplex": 11851,
"areaApartment": 50,
"costPerSquare": 96
},
{
"id": 5,
"name": "Белгородская область",
"name": "Белгородская Область",
"areaInComplex": 4570,
"areaApartment": 51,
"costPerSquare": 86
},
{
"id": 6,
"name": "Брянская область",
"name": "Брянская Область",
"areaInComplex": 7333,
"areaApartment": 59,
"costPerSquare": 60
},
{
"id": 7,
"name": "Владимирская область",
"name": "Владимирская Область",
"areaInComplex": 7509,
"areaApartment": 56,
"costPerSquare": 54
},
{
"id": 8,
"name": "Волгоградская область",
"name": "Волгоградская Область",
"areaInComplex": 7378,
"areaApartment": 52,
"costPerSquare": 76
},
{
"id": 9,
"name": "Вологодская область",
"name": "Вологодская Область",
"areaInComplex": 5235,
"areaApartment": 51,
"costPerSquare": 54
},
{
"id": 10,
"name": "Воронежская область",
"name": "Воронежская Область",
"areaInComplex": 13002,
"areaApartment": 48,
"costPerSquare": 77
@@ -99,14 +99,14 @@
},
{
"id": 15,
"name": "Ивановская область",
"name": "Ивановская Область",
"areaInComplex": 6594,
"areaApartment": 58,
"costPerSquare": 60
},
{
"id": 16,
"name": "Иркутская область",
"name": "Иркутская Область",
"areaInComplex": 5067,
"areaApartment": 51,
"costPerSquare": 103
@@ -120,14 +120,14 @@
},
{
"id": 18,
"name": "Калининградская область",
"name": "Калининградская Область",
"areaInComplex": 7209,
"areaApartment": 56,
"costPerSquare": 87
},
{
"id": 19,
"name": "Калужская область",
"name": "Калужская Область",
"areaInComplex": 7833,
"areaApartment": 55,
"costPerSquare": 88
@@ -141,21 +141,21 @@
},
{
"id": 21,
"name": "Кемеровская область - Кузбасс",
"name": "Кемеровская Область - Кузбасс",
"areaInComplex": 7256,
"areaApartment": 52,
"costPerSquare": 71
},
{
"id": 22,
"name": "Кировская область",
"name": "Кировская Область",
"areaInComplex": 6249,
"areaApartment": 51,
"costPerSquare": 80
},
{
"id": 23,
"name": "Костромская область",
"name": "Костромская Область",
"areaInComplex": 2821,
"areaApartment": 55,
"costPerSquare": 67
@@ -176,91 +176,91 @@
},
{
"id": 26,
"name": "Курганская область",
"name": "Курганская Область",
"areaInComplex": 5434,
"areaApartment": 53,
"costPerSquare": 63
},
{
"id": 27,
"name": "Курская область",
"name": "Курская Область",
"areaInComplex": 7045,
"areaApartment": 54,
"costPerSquare": 90
},
{
"id": 28,
"name": "Ленинградская область",
"name": "Ленинградская Область",
"areaInComplex": 12385,
"areaApartment": 40,
"costPerSquare": 134
},
{
"id": 29,
"name": "Липецкая область",
"name": "Липецкая Область",
"areaInComplex": 6231,
"areaApartment": 58,
"costPerSquare": 69
},
{
"id": 30,
"name": "Магаданская область",
"name": "Магаданская Область",
"areaInComplex": 3187,
"areaApartment": 55,
"costPerSquare": 85
},
{
"id": 31,
"name": "Московская область",
"name": "Московская Область",
"areaInComplex": 13213,
"areaApartment": 46,
"costPerSquare": 149
},
{
"id": 32,
"name": "Нижегородская область",
"name": "Нижегородская Область",
"areaInComplex": 8760,
"areaApartment": 53,
"costPerSquare": 106
},
{
"id": 33,
"name": "Новгородская область",
"name": "Новгородская Область",
"areaInComplex": 5953,
"areaApartment": 53,
"costPerSquare": 71
},
{
"id": 34,
"name": "Новосибирская область",
"name": "Новосибирская Область",
"areaInComplex": 6748,
"areaApartment": 51,
"costPerSquare": 107
},
{
"id": 35,
"name": "Омская область",
"name": "Омская Область",
"areaInComplex": 8625,
"areaApartment": 55,
"costPerSquare": 85
},
{
"id": 36,
"name": "Оренбургская область",
"name": "Оренбургская Область",
"areaInComplex": 7686,
"areaApartment": 49,
"costPerSquare": 67
},
{
"id": 37,
"name": "Орловская область",
"name": "Орловская Область",
"areaInComplex": 11543,
"areaApartment": 58,
"costPerSquare": 34
},
{
"id": 38,
"name": "Пензенская область",
"name": "Пензенская Область",
"areaInComplex": 12832,
"areaApartment": 54,
"costPerSquare": 74
@@ -281,7 +281,7 @@
},
{
"id": 41,
"name": "Псковская область",
"name": "Псковская Область",
"areaInComplex": 5750,
"areaApartment": 52,
"costPerSquare": 59
@@ -400,49 +400,49 @@
},
{
"id": 58,
"name": "Ростовская область",
"name": "Ростовская Область",
"areaInComplex": 10987,
"areaApartment": 48,
"costPerSquare": 94
},
{
"id": 59,
"name": "Рязанская область",
"name": "Рязанская Область",
"areaInComplex": 14581,
"areaApartment": 49,
"costPerSquare": 76
},
{
"id": 60,
"name": "Самарская область",
"name": "Самарская Область",
"areaInComplex": 10571,
"areaApartment": 55,
"costPerSquare": 82
},
{
"id": 61,
"name": "Саратовская область",
"name": "Саратовская Область",
"areaInComplex": 7789,
"areaApartment": 53,
"costPerSquare": 59
},
{
"id": 62,
"name": "Сахалинская область",
"name": "Сахалинская Область",
"areaInComplex": 5668,
"areaApartment": 51,
"costPerSquare": 153
},
{
"id": 63,
"name": "Свердловская область",
"name": "Свердловская Область",
"areaInComplex": 12467,
"areaApartment": 48,
"costPerSquare": 111
},
{
"id": 64,
"name": "Смоленская область",
"name": "Смоленская Область",
"areaInComplex": 5512,
"areaApartment": 55,
"costPerSquare": 54
@@ -456,35 +456,35 @@
},
{
"id": 66,
"name": "Тамбовская область",
"name": "Тамбовская Область",
"areaInComplex": 6631,
"areaApartment": 54,
"costPerSquare": 61
},
{
"id": 67,
"name": "Тверская область",
"name": "Тверская Область",
"areaInComplex": 5696,
"areaApartment": 52,
"costPerSquare": 79
},
{
"id": 68,
"name": "Томская область",
"name": "Томская Область",
"areaInComplex": 4397,
"areaApartment": 51,
"costPerSquare": 105
},
{
"id": 69,
"name": "Тульская область",
"name": "Тульская Область",
"areaInComplex": 7696,
"areaApartment": 48,
"costPerSquare": 90
},
{
"id": 70,
"name": "Тюменская область",
"name": "Тюменская Область",
"areaInComplex": 11328,
"areaApartment": 52,
"costPerSquare": 100
@@ -498,7 +498,7 @@
},
{
"id": 72,
"name": "Ульяновская область",
"name": "Ульяновская Область",
"areaInComplex": 7177,
"areaApartment": 48,
"costPerSquare": 75
@@ -519,7 +519,7 @@
},
{
"id": 75,
"name": "Челябинская область",
"name": "Челябинская Область",
"areaInComplex": 8631,
"areaApartment": 51,
"costPerSquare": 78
@@ -547,10 +547,9 @@
},
{
"id": 79,
"name": "Ярославская область",
"name": "Ярославская Область",
"areaInComplex": 6502,
"areaApartment": 54,
"costPerSquare": 77
}
]
+1 -3
View File
@@ -4,12 +4,10 @@ import { PropsWithChildren, ReactNode } from 'react';
export function OpenFormModalWrapper({
modal,
modalName,
children,
className,
}: PropsWithChildren<{
modal: ReactNode;
modalName: string;
className?: string;
}>) {
const { data: auth } = useCheckAuthQuery();
@@ -21,7 +19,7 @@ export function OpenFormModalWrapper({
<div
ref={(el) => {
if (!el) return;
const handler = () => setModal(modal, modalName);
const handler = () => setModal(modal);
el!.children.item(0)!.addEventListener('click', handler);
return () =>
el!.children.item(0)!.removeEventListener('click', handler);
+2 -2
View File
@@ -3,7 +3,7 @@ import { useModalStore } from '@/stores/useModalStore';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useDeleteMutation(
entity: 'projects' | 'companies' | 'articles',
entity: 'projects' | 'companies' | 'articles' | 'stories',
id: string
) {
const { setModal } = useModalStore();
@@ -18,7 +18,7 @@ export function useDeleteMutation(
if (entity === 'articles')
queryClient.setQueryData(['articles', 'drafted', []], []);
setModal(null, '');
setModal(null);
},
});
+1 -1
View File
@@ -19,7 +19,7 @@ export function useProjectMutation({
: await api.put(`projects/${id}`, { json }).json<IProject>(),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['projects'] });
setModal(null, '');
setModal(null);
},
});
}
+26
View File
@@ -0,0 +1,26 @@
import { api } from '@/api';
import { IStoryFormInput } from '@/components/modals/StoryFormModal';
import { useModalStore } from '@/stores/useModalStore';
import { IStory } from '@/types/IStory';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useStoryMutation({
action,
id,
}: { action: 'create'; id: undefined } | { action: 'edit'; id: string }) {
const queryClient = useQueryClient();
const { setModal } = useModalStore();
return useMutation({
mutationFn: async (json: IStoryFormInput) => {
action === 'create'
? await api.post('stories', { json }).json<IStory>()
: await api.put(`stories/${id}`, { json }).json<IStory>();
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['stories'] });
setModal(null);
},
});
}
+10
View File
@@ -0,0 +1,10 @@
import { api } from '@/api';
import { IStory } from '@/types/IStory';
import { useQuery } from '@tanstack/react-query';
export function useGetStories() {
return useQuery({
queryKey: ['stories'],
queryFn: async () => await api.get('stories').json<IStory[]>(),
});
}
+3 -5
View File
@@ -3,12 +3,10 @@ import { create } from 'zustand';
interface IModalState {
modal: ReactNode | null;
setModal: (modal: ReactNode, modalName: string) => void;
name: string;
setModal: (modal: ReactNode) => void;
}
export const useModalStore = create<IModalState>(set => ({
export const useModalStore = create<IModalState>((set) => ({
modal: null,
name: '',
setModal: (modal: ReactNode, name: string) => set({ modal, name }),
setModal: (modal: ReactNode) => set({ modal }),
}));
+7
View File
@@ -0,0 +1,7 @@
export interface IStory {
id: string;
text: string;
video: string;
preview: string;
createdAt: string;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function MenuLink({
<Link
href={href}
className="flex sm:flex-col max-sm:items-center justify-between h-full"
onClick={() => setModal(null, '')}
onClick={() => setModal(null)}
>
<h4 className="h4 fontme">{title}</h4>
<div className="self-end">{children}</div>
+20 -12
View File
@@ -1,35 +1,43 @@
'use client';
import { StoriesModal } from '@/components/modals/StoriesModal';
import { stories } from '@/consts/stories';
import { StoryFormModal } from '@/components/modals/StoryFormModal';
import { OpenFormModalWrapper } from '@/hocs/OpenFormModalWrapper';
import { useGetStories } from '@/queries/getStories';
import { useModalStore } from '@/stores/useModalStore';
import Image from 'next/image';
import AddIcon from '../../public/icons/add.svg';
import { GradientButton } from './GradientButton';
export function StoriesButton() {
const { setModal } = useModalStore();
const { data: stories } = useGetStories();
return (
<div className="flex mx-auto">
{stories.slice(-3).map(({ id, title, poster }, index) => (
<div className="flex items-center mx-auto">
{stories?.slice(-3).map(({ id, text, preview }, index) => (
<button
onClick={() =>
setModal(
<StoriesModal startIndex={stories.length - 3 + index} />,
'stories'
)
}
className="lg:p-[0.069vw] p-px cursor-pointer outline-none bg-gradient rounded-full aspect-square relative first:translate-x-2 last:-translate-x-2"
onClick={() => setModal(<StoriesModal startIndex={index} />)}
className="cursor-pointer outline-none rounded-full aspect-square first:translate-x-2 [:nth-last-child(2)]:-translate-x-2 w-full"
key={id}
>
<div className="lg:p-[0.069vw] p-px bg-gradient rounded-full relative aspect-square">
<Image
src={poster}
src={process.env.NEXT_PUBLIC_S3_BUCKET + preview}
fill
alt={title}
alt={text}
className="rounded-full object-cover object-center aspect-square lg:border-[0.139vw] border-[2px] border-[#0F1011] !relative"
sizes="(min-width: 1440px) 3.611vw, 52px"
/>
</div>
</button>
))}
<OpenFormModalWrapper modal={<StoryFormModal action={'create'} />}>
<GradientButton>
<AddIcon className="text-white lg:w-[1.111vw] lg:h-[1.111vw] w-4 h-4" />
</GradientButton>
</OpenFormModalWrapper>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { IArticle } from '@/types/IArticle';
import { ICompany } from '@/types/ICompany';
import { IProject } from '@/types/IProject';
import { IStory } from '@/types/IStory';
export function isArticle(
item: ICompany | IArticle | IProject | IStory
): item is IArticle {
return 'blocks' in item;
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { IArticle } from '@/types/IArticle';
import { ICompany } from '@/types/ICompany';
import { IProject } from '@/types/IProject';
import { IStory } from '@/types/IStory';
export function isCompany(
item: IProject | ICompany | IArticle
item: IProject | ICompany | IArticle | IStory
): item is ICompany {
return 'color' in item;
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { IArticle } from '@/types/IArticle';
import { ICompany } from '@/types/ICompany';
import { IProject } from '@/types/IProject';
import { IStory } from '@/types/IStory';
export function isProject(
item: IProject | ICompany | IArticle
item: IProject | ICompany | IArticle | IStory
): item is IProject {
return 'image' in item;
}