todo video uploading using editor

This commit is contained in:
2024-10-16 19:27:31 +05:00
parent 7cd340d422
commit f1d7cf969a
66 changed files with 1018 additions and 1234 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
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_S3_BUCKET=https://storage.yandexcloud.net/dult-faib-knac-fint/
NEXT_PUBLIC_TINYMCE_API_KEY=2vf68779upg45y46o6g5gaxldy9gzr399eyaaqa0ki3mj2h2
+1
View File
@@ -20,6 +20,7 @@
"framer-motion": "^11.3.9",
"graphql": "^16.9.0",
"graphql-scalars": "^1.23.0",
"html-react-parser": "^5.1.18",
"jose": "^5.9.3",
"ky": "^1.4.0",
"libphonenumber-js": "^1.11.7",
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

+1 -1
View File
@@ -5,6 +5,6 @@ export const oldApi = ky.extend({
});
export const api = ky.extend({
prefixUrl: process.env.NEXT_PUBLIC_OLD_API,
prefixUrl: process.env.NEXT_PUBLIC_API,
credentials: 'include',
});
@@ -0,0 +1,153 @@
'use client';
import { ArticleContentInput } from '@/components/articleInputs/ArticleContentInput';
import { ArticleSliderInput } from '@/components/articleInputs/ArticleSliderInput';
import { CloseIcon } from '@/components/icons/CloseIcon';
import { IArticleFormInput } from '@/components/modals/ArticleFormModal';
import { ArticleContent } from '@/components/pages/BlogPage/ArticleContent';
import { useEditArticleMutation } from '@/queries/articles/editArticle';
import {
GetArticleByIdDocument,
useGetArticleByIdQuery,
} from '@/queries/articles/getArticleById';
import { Block, IArticle } from '@/types/IPost';
import { Button } from '@/ui/Button';
import { reorderFields } from '@/utils/reorderFields';
import { useApolloClient } from '@apollo/client';
import { Reorder } from 'framer-motion';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
export default function DashboardArticlePage({
params: { articleId },
}: {
params: { articleId: string };
}) {
const { push } = useRouter();
const [showPreview, setShowPreview] = useState(false);
const [article, setArticle] = useState<IArticle>();
const client = useApolloClient();
useGetArticleByIdQuery({
variables: { id: +articleId },
onCompleted: ({ article }) => {
if (article.__typename === 'Article') {
const { __typename, id, ...other } = article;
setArticle(other as IArticle);
}
},
});
const [editArticle] = useEditArticleMutation({
onCompleted() {
client.refetchQueries({
include: ['GetArticles', GetArticleByIdDocument],
});
},
});
const { handleSubmit, control, setValue, register, getValues, watch } =
useForm<Pick<IArticleFormInput, 'blocks'>>();
useEffect(() => {
setValue('blocks', article?.blocks as Block[]);
}, [article, setValue]);
const { fields, append, swap, remove } = useFieldArray({
control,
name: 'blocks',
});
if (!article) return <div>not found</div>;
return (
<>
<div>
<form
className="space-y-5"
onSubmit={handleSubmit(formData => {
editArticle({
variables: {
id: +articleId,
input: {
...article,
blocks: JSON.stringify(formData.blocks),
},
},
});
})}
>
<div className="flex gap-4">
<button
onClick={e => {
e.preventDefault();
append({ type: 'Slider', images: [] });
}}
>
Добавить слайдер
</button>
<button
onClick={e => {
e.preventDefault();
append({
type: 'Content',
content: '',
});
}}
>
Добавить разметку
</button>
</div>
<Reorder.Group
axis="y"
values={fields}
onReorder={reorderFields(fields, swap)}
className="space-y-5"
>
{fields.map((item, index) =>
item.type === 'Slider' ? (
<ArticleSliderInput
{...{ control, index, register, item, setValue }}
removeSlider={remove}
key={item.id}
/>
) : (
<ArticleContentInput
{...{ control, index, item, setValue, getValues, watch }}
removeSlider={remove}
key={item.id}
/>
),
)}
</Reorder.Group>
<div className="flex gap-x-4">
<Button type="submit" onClick={() => push(`/blog/${articleId}`)}>
Сохранить
</Button>
<Button
onClick={() => {
setShowPreview(true);
}}
>
Предварительный просмотр
</Button>
</div>
</form>
</div>
{showPreview && (
<div className="w-full bg-[#14161F] absolute -top-4 left-0 z-[10] p-4">
<ArticleContent {...article} blocks={getValues('blocks')} />
<button
onClick={() => setShowPreview(false)}
className="absolute top-4 right-4"
>
<CloseIcon />
</button>
</div>
)}
</>
);
}
@@ -1,3 +0,0 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <section>{children}</section>;
}
@@ -1,153 +0,0 @@
'use client';
import { ArticleContentInput } from '@/components/articleInputs/ArticleContentInput';
import { ArticleQuoteInput } from '@/components/articleInputs/ArticleQuoteInput';
import { ArticleSliderInput } from '@/components/articleInputs/ArticleSliderInput';
import { ArticleVideoInput } from '@/components/articleInputs/ArticleVideoInput';
import { IArticleFormInput } from '@/components/modals/ArticleFormModal';
import { useEditArticleMutation } from '@/queries/articles/editArticle';
import {
GetArticleByIdDocument,
useGetArticleByIdQuery,
} from '@/queries/articles/getArticleById';
import { Block } from '@/types/IPost';
import { Button } from '@/ui/Button';
import { reorderFields } from '@/utils/reorderFields';
import { useApolloClient } from '@apollo/client';
import { Reorder } from 'framer-motion';
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
export default function DashboardArticlePage({
params: { articleId },
}: {
params: { articleId: string };
}) {
const client = useApolloClient();
const { data } = useGetArticleByIdQuery({
variables: { id: +articleId },
});
const [editArticle] = useEditArticleMutation({
onCompleted() {
client.refetchQueries({
include: ['GetArticles', GetArticleByIdDocument],
});
},
});
const { handleSubmit, control, setValue, register } =
useForm<Pick<IArticleFormInput, 'blocks'>>();
useEffect(() => {
if (data?.article.__typename === 'Article') {
const { blocks } = data.article;
setValue('blocks', blocks as Block[]);
}
}, [data, setValue]);
const { fields, append, remove, swap } = useFieldArray({
control,
name: 'blocks',
});
if (!data) return <div>not found</div>;
return (
<div>
<form
className="space-y-5"
onSubmit={handleSubmit(formData => {
if (data.article.__typename !== 'Article') return;
const { id, __typename, ...article } = data.article;
editArticle({
variables: {
id: +articleId,
input: {
...article,
blocks: JSON.stringify(formData.blocks),
},
},
});
})}
>
<div className="flex gap-4">
<button
onClick={e => {
e.preventDefault();
append({ type: 'Video', video: '', caption: '' });
}}
>
Добавить видео
</button>
<button
onClick={e => {
e.preventDefault();
append({ type: 'Slider', images: [] });
}}
>
Добавить слайдер
</button>
<button
onClick={e => {
e.preventDefault();
append({
type: 'Quote',
authorAvatar: '',
authorName: '',
authorRole: '',
text: '',
});
}}
>
Добавить цитату
</button>
<button
onClick={e => {
e.preventDefault();
append({
type: 'Content',
content: '',
});
}}
>
Добавить разметку
</button>
</div>
<Reorder.Group
axis="y"
values={fields}
onReorder={reorderFields(fields, swap)}
className="space-y-5"
>
{fields.map((item, index) => {
return item.type === 'Video' ? (
<ArticleVideoInput
key={item.id}
{...{ register, setValue, remove, index, item }}
/>
) : item.type === 'Slider' ? (
<ArticleSliderInput
{...{ control, index, register, item, setValue }}
key={item.id}
/>
) : item.type === 'Quote' ? (
<ArticleQuoteInput
{...{ register, index, item, setValue }}
key={item.id}
/>
) : (
<ArticleContentInput
key={item.id}
{...{ control, index, item }}
/>
);
})}
</Reorder.Group>
<Button>Сохранить</Button>
</form>
</div>
);
}
-138
View File
@@ -1,138 +0,0 @@
'use client';
import { ArticleFormModal } from '@/components/modals/ArticleFormModal';
import { DeleteArticleModal } from '@/components/modals/DeleteArticleModal';
import { ArticleCard } from '@/components/pages/BlogPage/ArticleCard';
import { useAddArticleMutation } from '@/queries/articles/addArticle';
import { useEditArticleMutation } from '@/queries/articles/editArticle';
import { useGetArticlesQuery } from '@/queries/articles/getArticles';
import { useModalStore } from '@/stores/useModalStore';
import { Block } from '@/types/IPost';
import { Button } from '@/ui/Button';
import { useApolloClient } from '@apollo/client';
export default function DashboardBlogPage() {
const { setModal } = useModalStore();
const client = useApolloClient();
const { data } = useGetArticlesQuery({
variables: { tags: [] },
});
const [addArticle] = useAddArticleMutation({
onCompleted() {
client.refetchQueries({ include: ['GetArticles'] });
setModal(null, '');
},
});
const [editArticle] = useEditArticleMutation({
onCompleted() {
client.refetchQueries({ include: ['GetArticles'] });
setModal(null, '');
},
});
return (
<div>
<Button
onClick={() =>
setModal(
<ArticleFormModal
action="create"
onSubmit={({ blocks, ...data }) => {
addArticle({
variables: {
input: { blocks: JSON.stringify(blocks), ...data },
},
});
}}
/>,
'articleFormModal',
)
}
>
Создать статью
</Button>
<div>
{data?.articles.__typename === 'Articles' &&
data?.articles.articles.map(({ __typename, id, ...article }) => (
<div className="relative" key={id}>
<ArticleCard id={id} {...article} />
<div className="absolute top-0 right-0 p-4 flex gap-2">
<button
onClick={() =>
setModal(
<ArticleFormModal
action="edit"
onSubmit={({ blocks, ...data }) => {
editArticle({
variables: {
id,
input: {
...data,
blocks: JSON.stringify(blocks),
},
},
});
}}
defaultValues={{
...article,
blocks: article.blocks.map(
({ __typename, ...block }) => block,
) as Block[],
}}
/>,
'editProject',
)
}
className="group relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
<span className="pointer-events-none group-hover:opacity-100 opacity-0 transition-opacity absolute -bottom-[90%] left-[50%] -translate-x-[50%] bg-neutral-900 px-2 py-1 text-sm rounded-lg">
Редактировать
</span>
</button>
<button
onClick={() => setModal(<DeleteArticleModal id={id} />, '')}
className="group relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
<span className="pointer-events-none group-hover:opacity-100 opacity-0 transition-opacity absolute -bottom-[90%] left-[50%] -translate-x-[50%] bg-neutral-900 px-2 py-1 text-sm rounded-lg">
Удалить
</span>
</button>
</div>{' '}
</div>
))}
</div>
</div>
);
}
+6 -11
View File
@@ -3,7 +3,6 @@
import { useCheckAuthQuery } from '@/queries/auth/checkAuth';
import { useLogoutLazyQuery } from '@/queries/auth/logout';
import { useApolloClient } from '@apollo/client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
@@ -37,16 +36,12 @@ export default function DashboardLayout({
return (
<section className="space-y-5 p-4">
<div className="flex gap-4 items-center">
<Link href={'/dashboard/projects'}>Проекты</Link>
<Link href={'/dashboard/blog'}>Блог</Link>
<button
className="bg-[red] p-2 rounded-lg font-bold"
onClick={() => logout()}
>
Выйти
</button>
</div>
<button
className="bg-[red] p-2 rounded-lg font-bold"
onClick={() => logout()}
>
Выйти
</button>
{children}
</section>
);
@@ -1,7 +0,0 @@
export default function ProjectsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <section>{children}</section>;
}
-126
View File
@@ -1,126 +0,0 @@
'use client';
import { DeleteProjectModal } from '@/components/modals/DeleteProjectModal';
import {
IAddProjectFormInput,
ProjectFormModal,
} from '@/components/modals/ProjectFormModal';
import { ProjectCard } from '@/components/pages/ProjectsPage/ProjectCard';
import { useAddProjectMutation } from '@/queries/projects/addProject';
import { useEditProjectMutation } from '@/queries/projects/editProject';
import { useGetProjectsQuery } from '@/queries/projects/getProjects';
import { useModalStore } from '@/stores/useModalStore';
import { Device, IProject } from '@/types/IProject';
import { Button } from '@/ui/Button';
import { useApolloClient } from '@apollo/client';
export default function DashboardProjectsPage() {
const { data } = useGetProjectsQuery({ variables: { devices: [] } });
const { setModal } = useModalStore();
const client = useApolloClient();
const [addProject] = useAddProjectMutation({
onCompleted() {
client.refetchQueries({ include: ['GetProjects'] });
setModal(null, '');
},
});
const [editProject] = useEditProjectMutation({
onCompleted() {
setModal(null, '');
},
});
return (
<div className="space-y-5">
<Button
onClick={() =>
setModal(
<ProjectFormModal
action={'create'}
onSubmit={(data: IAddProjectFormInput) => {
addProject({ variables: { input: data } });
}}
/>,
'addProject',
)
}
>
Добавить проект
</Button>
<div className="grid grid-cols-3 gap-4">
{data?.projects.__typename === 'Projects' &&
data?.projects.projects.map(({ __typename, id, ...project }) => (
<div key={id} className="relative">
<ProjectCard {...(project as IProject)} />
<div className="absolute top-0 right-0 p-4 flex gap-2">
<button
onClick={() =>
setModal(
<ProjectFormModal
action="edit"
onSubmit={(data: IAddProjectFormInput) => {
editProject({
variables: { id, input: data },
});
}}
defaultValues={{
...project,
devices: project.devices as Device[],
}}
/>,
'editProject',
)
}
className="group relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
<span className="pointer-events-none group-hover:opacity-100 opacity-0 transition-opacity absolute -bottom-[90%] left-[50%] -translate-x-[50%] bg-neutral-900 px-2 py-1 text-sm rounded-lg">
Редактировать
</span>
</button>
<button
onClick={() => setModal(<DeleteProjectModal id={id} />, '')}
className="group relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
<span className="pointer-events-none group-hover:opacity-100 opacity-0 transition-opacity absolute -bottom-[90%] left-[50%] -translate-x-[50%] bg-neutral-900 px-2 py-1 text-sm rounded-lg">
Удалить
</span>
</button>
</div>
</div>
))}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import { ArticleContent } from '@/components/pages/BlogPage/ArticleContent';
import { useGetArticleByIdQuery } from '@/queries/articles/getArticleById';
import { IArticle } from '@/types/IPost';
import { useState } from 'react';
export default function ArticlePage({
params: { articleId },
}: {
params: { articleId: string };
}) {
const [article, setArticle] = useState<IArticle>();
useGetArticleByIdQuery({
variables: { id: +articleId },
onCompleted: ({ article }) => {
if (article.__typename === 'Article') {
setArticle(article as IArticle);
}
},
});
return <div>{article && <ArticleContent {...article} />}</div>;
}
-117
View File
@@ -1,117 +0,0 @@
import { Posts } from '@/consts/Posts';
import dynamic from 'next/dynamic';
export default function PostPage({
params: { postId },
}: {
params: { postId: string };
}) {
const post = Posts.find(post => post.id === +postId);
const [date = '', month = '', year = ''] = post!.createdAt.split(' ');
const DynamicPostSlider = dynamic(
() =>
import('@/components/pages/BlogPage/PostSlider').then(
mod => mod.PostSlider,
),
{
ssr: false,
},
);
return (
post && (
<>
{/* <div className="sm:pb-7 pb-4 flex max-lg:flex-col justify-between sm:gap-4 gap-y-8 border-b border-[#3D425C]">
<div className="min-w-[calc(25vw-56px)] flex lg:flex-col justify-between max-sm:flex-col-reverse gap-y-2">
<div className="flex flex-wrap gap-3">
{post.tags.map(tag => (
<PostTag key={tag} text={tag} />
))}
</div>
<PostDate
date={date}
month={month}
year={year}
inPostCard={false}
/>
</div>
<div className="flex flex-col relative">
<h2 className="h2 font-medium lg:mb-5 mb-8">{post.title}</h2>
{post.mainImage && (
<Image
src={post.mainImage}
alt={post.title}
fill
className="object-cover !static min-h-[400px] self-end max-lg:rounded-2xl"
sizes="(min-height: 400px)"
priority
/>
)}
</div>
</div>
<div className="lg:max-w-[624px] lg:mt-[70px] mt-14 lg:ml-[25vw] lg:mb-[60px] sm:mx-6 mx-4 sm:mt-14 sm:mb-12 mb-8">
<h3 className="accent font-medium lg:mb-8 mb-6">{post.title}</h3>
<p className="mb-3 m-text">{post.description}</p>
<p className="m-text">{post.extraDesc}</p>
{post.video && (
<div
className="flex items-center justify-center bg-no-repeat bg-cover sm:min-h-[375px] min-h-[220px] lg:mt-[60px] sm:mt-12 mt-8 mb-5 max-lg:rounded-2xl"
style={{ backgroundImage: `url(${post.video})` }}
>
<button className="absolute mx-auto">
<PlayIcon />
</button>
</div>
)}
<p className="mb-3 m-text">{post.description}</p>
<p className="m-text">{post.extraDesc}</p>
</div>
<DynamicPostSlider {...post} />
{post.review && (
<div className="flex gap-4 max-lg:flex-col lg:mx-10 lg:py-14 sm:mx-6 py-10 mx-4 border-y border-[#3D425C]">
<div className="flex gap-x-6 lg:max-h-16 lg:min-w-[calc(23vw)] max-w-[311px] relative">
<Image
src={post.review.author.avatar}
alt={post.review.author.name}
fill
className="rounded-3xl object-cover !static lg:max-w-16 lg:max-h-16 max-w-[72px] max-h-[72px]"
sizes="100%, 100%"
/>
<div className="flex flex-col justify-between gap-1">
<h3 className="h3 font-semibold">{post.review.author.name}</h3>
<p className="m-caption font-medium opacity-80">
{post.review.author.position}
</p>
</div>
</div>
<p className="accent lg:max-w-[55vw]">{post.review.text}</p>
</div>
)}
{post.extraVideo && (
<div className="border-y border-[#3D425C] lg:py-10 py-6 lg:mx-10 sm:mx-6 mx-4">
<div
className="flex justify-center items-center bg-no-repeat bg-cover aspect-[1520/787] max-lg:rounded-2xl"
style={{ backgroundImage: `url(${post.extraVideo})` }}
>
<button className="absolute mx-auto">
<PlayIcon />
</button>
</div>
<div className="flex justify-between mt-4">
<p className="md:text-[clamp(14px,14px+(100vw-768px)/832*2,16px)]">
Пояснялка
</p>
<Link
href={'/'}
className="flex gap-2 md:text-[clamp(14px,14px+(100vw-768px)/832*2,16px)]"
>
YouTube <ArrowMoreIcon />
</Link>
</div>
</div>
)} */}
</>
)
);
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { PostsFilters } from '@/components/pages/BlogPage/PostsFilters';
import { PostsList } from '@/components/pages/BlogPage/PostsList';
import { ArticlesFilters } from '@/components/pages/BlogPage/ArticlesFilters';
import { ArticlesList } from '@/components/pages/BlogPage/ArticlesList';
import { Title } from '@/ui/Title';
export default function BlogPage() {
@@ -7,9 +7,9 @@ export default function BlogPage() {
<div>
<div className="sm:pb-5 pb-4 flex flex-col justify-between border-b border-[#3D425C]">
<Title className="lg:mb-14 sm:mb-8 mb-4">Блог</Title>
<PostsFilters />
<ArticlesFilters />
</div>
<PostsList />
<ArticlesList />
</div>
);
}
+44 -2
View File
@@ -1,13 +1,55 @@
'use client';
import {
IAddProjectFormInput,
ProjectFormModal,
} from '@/components/modals/ProjectFormModal';
import { ProjectsFilters } from '@/components/pages/ProjectsPage/ProjectsFilters';
import { ProjectsList } from '@/components/pages/ProjectsPage/ProjectsList';
import { useCheckAuthQuery } from '@/queries/auth/checkAuth';
import { useAddProjectMutation } from '@/queries/projects/addProject';
import { useModalStore } from '@/stores/useModalStore';
import { Button } from '@/ui/Button';
import { Title } from '@/ui/Title';
import { useApolloClient } from '@apollo/client';
export default function ProjectsPage() {
const { setModal } = useModalStore();
const client = useApolloClient();
const { data } = useCheckAuthQuery();
const [addProject] = useAddProjectMutation({
onCompleted() {
client.refetchQueries({ include: ['GetProjects'] });
},
});
return (
<div className="lg:pt-20 pt-14">
<div className="sm:pb-5 pb-4 flex flex-col justify-between border-b border-[#3D425C]">
<Title className="lg:mb-14 sm:mb-8 mb-4">Проекты</Title>
<Title className="lg:mb-14 sm:mb-8 mb-4">Проекты</Title>
<div className="sm:pb-5 pb-4 flex gap-4 flex-wrap justify-between border-b border-[#3D425C]">
<ProjectsFilters />
{data?.checkAuth.__typename === 'CheckAuthResponse' &&
data.checkAuth.isAuth && (
<Button
onClick={() =>
setModal(
<ProjectFormModal
action={'create'}
onSubmit={(data: IAddProjectFormInput) => {
addProject({ variables: { input: data } });
setModal(null, '');
}}
/>,
'addProject',
)
}
>
Добавить проект
</Button>
)}
</div>
<ProjectsList />
</div>
+11 -1
View File
@@ -1,9 +1,19 @@
'use client';
import { useModalStore } from '@/stores/useModalStore';
import { useEffect } from 'react';
export function ModalContainer() {
const { modal, name } = useModalStore();
const { modal, name, setModal } = useModalStore();
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.key === 'Escape') setModal(null, '');
};
document.addEventListener('keydown', listener);
return () => document.removeEventListener('keydown', listener);
}, [setModal]);
return (
modal && (
+3 -6
View File
@@ -77,20 +77,17 @@ export function MediaUploader<
{isImage(item)
? (previewFile || item.img) && (
<Image
src={
previewFile ||
process.env.NEXT_PUBLIC_S3_BUCKET + '/' + item.img
}
src={previewFile || process.env.NEXT_PUBLIC_S3_BUCKET + item.img}
width={300}
height={300}
alt={''}
className="pointer-events-none"
/>
)
: (previewFile || item.video) && (
<video
src={
previewFile ||
process.env.NEXT_PUBLIC_S3_BUCKET + '/' + item.video
previewFile || process.env.NEXT_PUBLIC_S3_BUCKET + item.video
}
width={300}
height={300}
-42
View File
@@ -1,42 +0,0 @@
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { YearFilterItem } from './YearFilterItem';
import { ArrowDownIcon } from './icons/ArrowDownIcon';
export function YearFilterDropdown({ years }: { years: string[] }) {
const [isOpen, setIsOpen] = useState(false);
const params = new URLSearchParams(useSearchParams());
return (
<div className="sm:hidden w-full relative">
<div
className="flex justify-between border border-[#3D425C] px-6 py-4"
onClick={() => setIsOpen(prev => !prev)}
>
{!params.has('year') ? 'Все время' : params.get('year')}
<ArrowDownIcon />
</div>
{isOpen && (
<div
className="absolute z-10 flex flex-col w-full"
onClick={() => setIsOpen(false)}
>
<YearFilterItem
text={'Все время'}
isAll
inSelect
chosen={!params.has('year')}
/>
{years.map(year => (
<YearFilterItem
key={year}
text={year}
inSelect
chosen={params.get('year') === year}
/>
))}
</div>
)}
</div>
);
}
-38
View File
@@ -1,38 +0,0 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
export function YearFilterItem({
text,
chosen = false,
isAll = false,
inSelect = false,
}: {
text: string;
chosen?: boolean;
isAll?: boolean;
inSelect?: boolean;
}) {
const { push } = useRouter();
const pathname = usePathname();
const params = new URLSearchParams(useSearchParams());
function clickHandler() {
if (isAll || (params.get('year') === text && !inSelect))
params.delete('year');
else params.set('year', text);
push(pathname + '?' + params.toString());
}
return (
<button
className={
'max-sm:w-full max-sm:border-b max-sm:border-x max-sm:border-[#3D425C] max-sm:px-6 max-sm:py-4 max-sm:bg-[#14161f] btn-text font-semibold ' +
(chosen ? 'text-white' : 'text-[#737AA1]')
}
onClick={clickHandler}
>
{text}
</button>
);
}
@@ -1,91 +1,104 @@
import { useModalStore } from '@/stores/useModalStore';
import { IContent } from '@/types/IPost';
import { Editor } from '@tinymce/tinymce-react';
import { Reorder } from 'framer-motion';
import { useRef } from 'react';
import { Control, Controller } from 'react-hook-form';
import { Editor as TinyMCEEditor } from 'tinymce';
import parse from 'html-react-parser';
import { useEffect, useState } from 'react';
import {
Control,
UseFieldArrayRemove,
UseFormGetValues,
UseFormSetValue,
UseFormWatch,
} from 'react-hook-form';
import { ArticleContentEditorModal } from '../modals/ArticleContentEditorModal';
import { IArticleFormInput } from '../modals/ArticleFormModal';
interface IArticleContentInputProps {
export interface IArticleContentInputProps {
item: IContent & Record<'id', string>;
index: number;
control: Control<Pick<IArticleFormInput, 'blocks'>, any>;
setValue: UseFormSetValue<Pick<IArticleFormInput, 'blocks'>>;
getValues: UseFormGetValues<Pick<IArticleFormInput, 'blocks'>>;
watch: UseFormWatch<Pick<IArticleFormInput, 'blocks'>>;
removeSlider: UseFieldArrayRemove;
}
export function ArticleContentInput({
control,
index,
item,
setValue,
getValues,
watch,
removeSlider,
}: IArticleContentInputProps) {
const editorRef = useRef<TinyMCEEditor | null>(null);
const { setModal } = useModalStore();
const [content, setContent] = useState(item.content);
useEffect(() => {
const { unsubscribe } = watch(value => {
if (value.blocks?.[index]?.type === 'Content')
setContent(value.blocks[index].content!);
});
return () => {
unsubscribe();
};
}, [index, watch]);
return (
<Reorder.Item value={item}>
<Controller
control={control}
name={`blocks.${index}.content`}
render={({ field: { onChange, value } }) => (
<Editor
id={item.id}
inline
onEditorChange={onChange}
value={value}
onInit={(_, editor) => {
editorRef.current = editor;
}}
init={{
draggable_modal: true,
plugins: [
'anchor',
'autolink',
'charmap',
'codesample',
'emoticons',
'image',
'link',
'lists',
'media',
'searchreplace',
'table',
'visualblocks',
'wordcount',
'checklist',
'mediaembed',
'casechange',
'export',
'formatpainter',
'pageembed',
'a11ychecker',
'tinymcespellchecker',
'permanentpen',
'powerpaste',
'advtable',
'advcode',
'editimage',
'advtemplate',
'mentions',
'tinycomments',
'tableofcontents',
'footnotes',
'mergetags',
'autocorrect',
'typography',
'inlinecss',
'markdown',
],
toolbar:
'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | link image media table mergetags | addcomment showcomments | spellcheckdialog a11ycheck typography | align lineheight | checklist numlist bullist indent outdent | emoticons charmap | removeformat',
tinycomments_mode: 'embedded',
tinycomments_author: 'Author name',
mergetags_list: [
{ value: 'First.Name', title: 'First Name' },
{ value: 'Email', title: 'Email' },
],
}}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
/>
)}
/>
<div>{parse(content)}</div>
<div className="flex gap-x-4">
<button
onClick={e => {
e.preventDefault();
setModal(
<ArticleContentEditorModal
item={item}
index={index}
control={control}
setValue={setValue}
getValues={getValues}
watch={watch}
/>,
'content',
);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
</button>
<button onClick={() => removeSlider(index)}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</div>
</Reorder.Item>
);
}
@@ -1,59 +0,0 @@
import { IQuote } from '@/types/IPost';
import { Reorder } from 'framer-motion';
import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { MediaUploader } from '../MediaUploader';
import { IArticleFormInput } from '../modals/ArticleFormModal';
interface IArticleQuoteInputProps {
register: UseFormRegister<Pick<IArticleFormInput, 'blocks'>>;
item: IQuote & Record<'id', string>;
index: number;
setValue: UseFormSetValue<Pick<IArticleFormInput, 'blocks'>>;
}
export function ArticleQuoteInput({
index,
item,
register,
setValue,
}: IArticleQuoteInputProps) {
return (
<Reorder.Item value={item}>
<div className="flex flex-col w-fit">
<label htmlFor={`authorName-${item.id}`}>Имя автора</label>
<input
type="text"
id={`authorName-${item.id}`}
className="text-black"
{...register(`blocks.${index}.authorName`)}
/>
</div>
<div className="flex flex-col">
<label htmlFor={`authorRole-${item.id}`}>Должность автора</label>
<input
type="text"
className="text-black"
id={`authorRole-${item.id}`}
{...register(`blocks.${index}.authorRole`)}
/>
</div>
<MediaUploader
dest="blog"
fieldName={`blocks.${index}.authorAvatar`}
setValue={setValue}
item={{ id: item.id, img: item.authorAvatar }}
media="img"
label="Выберите аватар"
/>
<div className="flex flex-col">
<label htmlFor={`quote-text-${item.id}`}>Текст</label>
<input
type="text"
id={`quote-text-${item.id}`}
className="text-black"
{...register(`blocks.${index}.text`)}
/>
</div>
</Reorder.Item>
);
}
@@ -37,17 +37,14 @@ export function ArticleSliderImageInput({
label="Выберите изображение"
media="img"
/>
<div className="flex flex-col">
<label htmlFor={`slider-image-${imgIndex}`}>Подпись</label>
<input
type="text"
className="text-black outline-none pl-1"
id={`slider-image-${imgIndex}`}
{...register(`blocks.${blockIndex}.images.${imgIndex}.caption`)}
/>
</div>
<input
type="text"
className="text-black outline-none pl-1 w-full"
{...register(`blocks.${blockIndex}.images.${imgIndex}.caption`)}
/>
</div>
<button
className="self-start z-[1]"
onClick={e => {
e.preventDefault();
remove(imgIndex);
@@ -4,6 +4,7 @@ import { Reorder } from 'framer-motion';
import {
Control,
useFieldArray,
UseFieldArrayRemove,
UseFormRegister,
UseFormSetValue,
} from 'react-hook-form';
@@ -16,6 +17,7 @@ interface IArticleSliderInputProps {
item: ISlider & Record<'id', string>;
index: number;
control: Control<Pick<IArticleFormInput, 'blocks'>>;
removeSlider: UseFieldArrayRemove;
}
export function ArticleSliderInput({
@@ -24,6 +26,7 @@ export function ArticleSliderInput({
register,
setValue,
control,
removeSlider,
}: IArticleSliderInputProps) {
const { swap, append, remove, fields } = useFieldArray({
control,
@@ -32,14 +35,17 @@ export function ArticleSliderInput({
return (
<Reorder.Item value={item}>
<button
onClick={e => {
e.preventDefault();
append({ img: '', caption: '' });
}}
>
Добавить слайд
</button>
<div className="flex gap-x-4">
<button
onClick={e => {
e.preventDefault();
append({ img: '', caption: '' });
}}
>
Добавить слайд
</button>
<button onClick={() => removeSlider(index)}>Удалить слайдер</button>
</div>
<Reorder.Group
axis="x"
values={fields}
@@ -1,56 +0,0 @@
import { IVideo } from '@/types/IPost';
import { Reorder } from 'framer-motion';
import {
UseFieldArrayRemove,
UseFormRegister,
UseFormSetValue,
} from 'react-hook-form';
import { CloseIcon } from '../icons/CloseIcon';
import { MediaUploader } from '../MediaUploader';
import { IArticleFormInput } from '../modals/ArticleFormModal';
interface IArticleVideoInputProps {
item: IVideo & Record<'id', string>;
index: number;
remove: UseFieldArrayRemove;
register: UseFormRegister<Pick<IArticleFormInput, 'blocks'>>;
setValue: UseFormSetValue<Pick<IArticleFormInput, 'blocks'>>;
}
export function ArticleVideoInput({
register,
setValue,
remove,
index,
item,
}: IArticleVideoInputProps) {
return (
<Reorder.Item value={item} className="w-fit">
<MediaUploader
dest="blog"
setValue={setValue}
fieldName={`blocks.${index}.video`}
media="video"
item={item}
label="Выберите видео"
/>
<input
type="text"
placeholder="Описание видео (необязательно)"
className="text-black"
{...register(`blocks.${index}.caption`, {
required: false,
})}
/>
<button
onClick={e => {
e.preventDefault();
remove(index);
}}
>
<CloseIcon />
</button>
</Reorder.Item>
);
}
+18
View File
@@ -0,0 +1,18 @@
export function DeleteIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
);
}
+22
View File
@@ -0,0 +1,22 @@
export function EditContentIcon() {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17 8H7V6H15L17 8Z" fill="white" />
<path d="M7 11H17V13H7V11Z" fill="white" />
<path d="M7 16H17V18H9L7 16Z" fill="white" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 22V2H19L21 4V22H3ZM19 4H18H5V20H19V5V4Z"
fill="white"
/>
<path d="M19 4H18L19 5V4Z" fill="white" />
</svg>
);
}
+18
View File
@@ -0,0 +1,18 @@
export function EditIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
);
}
@@ -0,0 +1,127 @@
import { api } from '@/api';
import { ClassNameWrapper } from '@/hocs/ClassNameWrapper';
import { useModalStore } from '@/stores/useModalStore';
import { Button } from '@/ui/Button';
import { Editor } from '@tinymce/tinymce-react';
import { useRef } from 'react';
import { Controller } from 'react-hook-form';
import { Editor as Tinymce } from 'tinymce';
import { IArticleContentInputProps } from '../articleInputs/ArticleContentInput';
import { CloseIcon } from '../icons/CloseIcon';
export function ArticleContentEditorModal({
control,
index,
item,
setValue,
getValues,
}: Omit<IArticleContentInputProps, 'removeSlider'>) {
const { setModal } = useModalStore();
const editorRef = useRef<Tinymce | null>(null);
return (
<div className="relative w-full">
<Controller
control={control}
name={`blocks.${index}.content`}
render={({ field: { onChange, value } }) => (
<Editor
id={item.id}
onEditorChange={onChange}
value={value}
onInit={(_, editor) => (editorRef.current = editor)}
init={{
images_upload_credentials: true,
images_upload_handler: async blobInfo => {
const formData = new FormData();
formData.append('files', blobInfo.blob(), blobInfo.filename());
formData.append('dest', 'blog');
const res = await api
.post('upload', { body: formData })
.json<{ files: string[] }>();
console.log(res);
return (
'https://storage.yandexcloud.net/dult-faib-knac-fint/' +
res.files[0]
);
},
automatic_uploads: true,
plugins: [
'anchor',
'autolink',
'charmap',
'codesample',
'emoticons',
'image',
'link',
'lists',
'media',
'searchreplace',
'table',
'visualblocks',
'wordcount',
'checklist',
'mediaembed',
'casechange',
'export',
'formatpainter',
'pageembed',
'a11ychecker',
'tinymcespellchecker',
'permanentpen',
'powerpaste',
'advtable',
'advcode',
'editimage',
'advtemplate',
'mentions',
'tinycomments',
'tableofcontents',
'footnotes',
'mergetags',
'autocorrect',
'typography',
'inlinecss',
'markdown',
],
toolbar:
'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | link image media table mergetags | addcomment showcomments | spellcheckdialog a11ycheck typography | align lineheight | checklist numlist bullist indent outdent | emoticons charmap | removeformat',
tinycomments_mode: 'embedded',
tinycomments_author: 'Author name',
mergetags_list: [
{ value: 'First.Name', title: 'First Name' },
{ value: 'Email', title: 'Email' },
],
}}
apiKey={process.env.NEXT_PUBLIC_TINYMCE_API_KEY}
/>
)}
/>
<Button
className="absolute -bottom-4 left-4 z-[2]"
onClick={() => {
setValue(
`blocks.${index}.content`,
editorRef.current?.getContent() || '',
);
console.log(getValues(`blocks.${index}.content`));
setModal(null, '');
}}
>
Сохранить
</Button>
<button
onClick={e => {
e.preventDefault();
setModal(null, '');
}}
className="absolute top-4 right-4 z-[2]"
>
<ClassNameWrapper element={<CloseIcon />} className="text-black" />
</button>
</div>
);
}
+3 -7
View File
@@ -100,7 +100,7 @@ export function ArticleFormModal({
<div className="text-black bg-white p-4 rounded-lg relative top-[100px] space-y-4">
<div className="flex justify-between items-center border-b border-[#ccc] pb-4 gap-4">
<p className="text-xl">
{action === 'create' ? 'Создание проекта' : 'Изменение проекта'}
{action === 'create' ? 'Создание статьи' : 'Изменение статьи'}
</p>
<button
onClick={() => setModal(null, '')}
@@ -137,9 +137,7 @@ export function ArticleFormModal({
className="relative"
src={
previewCardImage ??
process.env.NEXT_PUBLIC_S3_BUCKET! +
'/' +
defaultValues?.cardImage
process.env.NEXT_PUBLIC_S3_BUCKET! + defaultValues?.cardImage
}
alt={defaultValues?.title!}
/>
@@ -164,9 +162,7 @@ export function ArticleFormModal({
className="relative"
src={
previewPosterImage ??
process.env.NEXT_PUBLIC_S3_BUCKET! +
'/' +
defaultValues?.posterImage
process.env.NEXT_PUBLIC_S3_BUCKET! + defaultValues?.posterImage
}
alt={defaultValues?.title!}
/>
+14 -15
View File
@@ -32,17 +32,18 @@ export function ProjectFormModal({
}: IProjectFormModalProps) {
const { setModal } = useModalStore();
const { register, handleSubmit, setValue } = useForm<IAddProjectFormInput>({
defaultValues:
action === 'create'
? { devices: [], stage: 1 }
: {
...defaultValues,
releaseDate: new Date(defaultValues?.releaseDate!)
.toISOString()
.split('T')[0],
},
});
const { register, handleSubmit, setValue, control } =
useForm<IAddProjectFormInput>({
defaultValues:
action === 'create'
? { devices: [], stage: 1 }
: {
...defaultValues,
releaseDate: new Date(defaultValues?.releaseDate!)
.toISOString()
.split('T')[0],
},
});
const [file, setFile] = useState<File>();
const [previewFile, setPreviewFile] = useState<string>();
@@ -145,9 +146,7 @@ export function ProjectFormModal({
className="relative"
src={
previewFile ??
process.env.NEXT_PUBLIC_S3_BUCKET! +
'/' +
defaultValues?.image
process.env.NEXT_PUBLIC_S3_BUCKET! + defaultValues?.image
}
alt={defaultValues?.name!}
/>
@@ -209,7 +208,7 @@ export function ProjectFormModal({
>
Отмена
</Button>
<Button className="text-white">
<Button className="text-white" type="submit">
{action === 'create' ? 'Добавить проект' : 'Сохранить изменения'}
</Button>
</div>
+138 -62
View File
@@ -1,11 +1,22 @@
'use client';
import { ArrowMoreIcon } from '@/components/icons/ArrowMoreIcon';
import { DeleteIcon } from '@/components/icons/DeleteIcon';
import { EditContentIcon } from '@/components/icons/EditContentIcon';
import { EditIcon } from '@/components/icons/EditIcon';
import {
ArticleFormModal,
IArticleFormInput,
} from '@/components/modals/ArticleFormModal';
import { DeleteArticleModal } from '@/components/modals/DeleteArticleModal';
import { ClassNameWrapper } from '@/hocs/ClassNameWrapper';
import { useEditArticleMutation } from '@/queries/articles/editArticle';
import { useCheckAuthQuery } from '@/queries/auth/checkAuth';
import { useModalStore } from '@/stores/useModalStore';
import type { IArticle } from '@/types/IPost';
import { PostDate } from '@/ui/PostDate';
import { PostTag } from '@/ui/PostTag';
import { useApolloClient } from '@apollo/client';
import Image from 'next/image';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
@@ -17,75 +28,140 @@ export function ArticleCard({
cardImage,
tags,
id,
}: Omit<IArticle, 'blocks' | 'posterImage'>) {
const [date, month, year] = createdAt.split('-');
blocks,
posterImage,
}: IArticle) {
const { setModal } = useModalStore();
const [year, month, date] = createdAt.split('-');
const params = useSearchParams();
const client = useApolloClient();
const { data } = useCheckAuthQuery();
const [editArticle] = useEditArticleMutation({
onCompleted() {
client.refetchQueries({ include: ['GetArticles'] });
},
});
return (
<Link
href={
`${data?.checkAuth.__typename === 'CheckAuthResponse' && data?.checkAuth.isAuth ? '/dashboard' : ''}/blog/` +
id
}
className="border-[#3D425C] [&:not(:last-of-type)]:border-b py-6 flex items-center gap-x-4 gap-y-3 max-sm:flex-col w-full relative"
>
<Image
src={`${process.env.NEXT_PUBLIC_S3_BUCKET}/${cardImage}`}
alt={title}
fill
className="sm:hidden !static object-cover"
sizes="100%"
priority
/>
<PostDate
className="max-lg:hidden"
date={date}
month={month}
year={year}
/>
<Image
src={`${process.env.NEXT_PUBLIC_S3_BUCKET}/${cardImage}`}
alt={title}
fill
className="max-lg:hidden !static max-w-[31vw] object-cover"
sizes="(max-width: 31vw) 100%"
priority
/>
<Image
src={`${process.env.NEXT_PUBLIC_S3_BUCKET}/${cardImage}`}
alt={title}
fill
className="hidden sm:max-lg:block !static max-w-[34.4vw] min-h-60 object-cover"
sizes="(max-width: 34.4vw) 100%, (min-height: 240px) 100%"
priority
/>
<div className="flex justify-between flex-col flex-1 sm-max-lg:flex-1 self-stretch max-sm:gap-y-3">
<div>
<div className="flex gap-2 mb-4">
{tags.map(tag => (
<PostTag
text={tag}
key={tag}
active={params.getAll('tags').includes(tag)}
/>
))}
<div className="border-[#3D425C] border-b w-full relative">
<Link
href={'/blog/' + id}
className="py-6 grid lg:grid-cols-4 sm:grid-cols-8 gap-x-4 gap-y-3 max-sm:flex-col"
>
<Image
src={process.env.NEXT_PUBLIC_S3_BUCKET + cardImage}
alt={title}
fill
className="sm:hidden !static object-cover"
sizes="100%"
priority
/>
<PostDate
className="max-lg:hidden self-end"
date={date}
month={month}
year={year}
/>
<Image
src={process.env.NEXT_PUBLIC_S3_BUCKET + cardImage}
alt={title}
fill
className="max-lg:hidden !relative object-cover"
priority
/>
<Image
src={process.env.NEXT_PUBLIC_S3_BUCKET + cardImage}
alt={title}
fill
className="hidden sm:max-lg:block !relative object-cover sm:max-lg:col-span-3"
priority
/>
<div className="flex justify-between flex-col flex-1 sm-max-lg:flex-1 self-stretch max-sm:gap-y-3 lg:col-span-2 sm:col-span-5">
<div>
<div className="flex gap-2 mb-4">
{tags.map(tag => (
<PostTag
text={tag}
key={tag}
active={params.getAll('tags').includes(tag)}
/>
))}
</div>
<p className="accent font-medium mb-5">{title}</p>
<p className="m-text opacity-80">{description}</p>
</div>
<div className="flex max-lg:justify-between lg:justify-end items-end max-sm:mt-2">
<PostDate
className="lg:hidden"
date={date}
month={month}
year={year}
/>
<ClassNameWrapper
className="self-end"
element={<ArrowMoreIcon />}
/>
</div>
<p className="accent font-medium mb-5">{title}</p>
<p className="m-text opacity-80">{description}</p>
</div>
<div className="flex max-lg:justify-between lg:justify-end items-end max-sm:mt-2">
<PostDate
className="lg:hidden"
date={date}
month={month}
year={year}
/>
<ClassNameWrapper className="self-end" element={<ArrowMoreIcon />} />
</div>
</div>
</Link>
</Link>
{data?.checkAuth.__typename === 'CheckAuthResponse' &&
data?.checkAuth.isAuth && (
<div className="absolute top-0 right-0 p-4 flex gap-2 z-[9]">
<button
onClick={() =>
setModal(
<ArticleFormModal
action="edit"
onSubmit={({ blocks, ...data }: IArticleFormInput) => {
editArticle({
variables: {
id,
input: {
blocks: JSON.stringify(blocks),
...data,
},
},
});
}}
defaultValues={{
cardImage,
createdAt,
description,
tags,
title,
posterImage,
blocks,
}}
/>,
'editArticle',
)
}
className="relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<EditIcon />
</button>
<Link
href={`/dashboard/${id}`}
className="relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<EditContentIcon />
</Link>
<button
onClick={e => {
e.stopPropagation();
setModal(<DeleteArticleModal id={id} />, 'deleteArticle');
}}
className="relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<DeleteIcon />
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,59 @@
import { IArticle } from '@/types/IPost';
import { PostDate } from '@/ui/PostDate';
import parse from 'html-react-parser';
import Image from 'next/image';
import { ArticleSlider } from './ArticleSlider';
export function ArticleContent({
blocks,
createdAt,
description,
posterImage,
tags,
title,
}: IArticle) {
const [year, month, date] = createdAt.split('T')[0].split('-');
return (
<>
<div className="grid grid-cols-4 gap-x-4 border-b border-[#3D425C] pb-6">
<div className="flex flex-col justify-between items-start col-start-1 col-span-1">
<div className="flex gap-x-2">
{tags.map(tag => (
<p
key={tag}
className="px-3 py-[5px] bg-[#3D425C] m-caption rounded-3xl"
>
{tag}
</p>
))}
</div>
<PostDate date={date} month={month} year={year} />
</div>
<div className="space-y-12 col-span-2">
<div className="space-y-6">
<h1 className="h2 font-medium">{title}</h1>
<p className="h4 font-medium">{description}</p>
</div>
<div className="aspect-[768/400] relative">
<Image
fill
src={process.env.NEXT_PUBLIC_S3_BUCKET + posterImage}
alt={title}
className="!relative object-cover object-center"
/>
</div>
</div>
</div>
<div className="space-y-4">
{blocks.map((block, index) =>
block.type === 'Content' ? (
<div key={index}>{parse(block.content)}</div>
) : (
<ArticleSlider key={index} slides={block.images} />
),
)}
</div>
</>
);
}
@@ -1,18 +1,18 @@
'use client';
import { useWindowWidth } from '@/hooks/useWindowWidth';
import { IArticle } from '@/types/IPost';
import { InfinitySlider } from '@/ui/InfinitySlider';
import { PostSliderImage } from '@/ui/PostSlideImage';
export function PostSlider({ title, slides }: IArticle) {
export function ArticleSlider({
slides,
}: {
slides: { img: string; capture?: string }[];
}) {
const width = useWindowWidth();
return (
<div className="lg:mb-[58px] sm:mb-[68px] mb-6">
<h3 className="accent lg:mb-8 lg:ml-[25vw] lg:max-w-[624px] sm:max-w-[84vw] mb-6 sm:ml-6 ml-4">
{title}
</h3>
{slides &&
width &&
(width >= 1024 ? (
@@ -21,7 +21,7 @@ export function PostSlider({ title, slides }: IArticle) {
slideWidth={width * 0.47}
offset={'25vw'}
slides={slides.map(slide => (
<PostSliderImage key={slide} slide={slide} title={title} />
<PostSliderImage key={slide.img} slide={slide.img} />
))}
/>
) : width >= 640 ? (
@@ -31,7 +31,7 @@ export function PostSlider({ title, slides }: IArticle) {
className="px-6"
offset={'25vw'}
slides={slides.map(slide => (
<PostSliderImage key={slide} slide={slide} title={title} />
<PostSliderImage key={slide.img} slide={slide.img} />
))}
/>
) : (
@@ -41,7 +41,7 @@ export function PostSlider({ title, slides }: IArticle) {
className="px-4"
offset={'25vw'}
slides={slides.map(slide => (
<PostSliderImage key={slide} slide={slide} title={title} />
<PostSliderImage key={slide.img} slide={slide.img} />
))}
/>
))}
@@ -1,17 +1,13 @@
'use client';
import { TagFilterItem } from '@/components/TagFilterItem';
import { YearFilterDropdown } from '@/components/YearFilterDropdown';
import { YearFilterItem } from '@/components/YearFilterItem';
import { PostTags } from '@/consts/PostTags';
import { PostYears } from '@/consts/PostYears';
import { Vertical } from '@/ui/Vertical';
import { useSearchParams } from 'next/navigation';
export function PostsFilters() {
export function ArticlesFilters() {
const params = useSearchParams();
const chosedTags = params.getAll('tags');
const chosenYear = params.get('year');
return (
<div className="flex xl:items-center max-xl:flex-col gap-y-4 justify-between">
@@ -33,14 +29,6 @@ export function PostsFilters() {
/>
))}
</div>
<div className="flex flex-wrap gap-4 h-fit max-sm:hidden">
{PostYears.map(year => (
<YearFilterItem key={year} text={year} chosen={year === chosenYear} />
))}
<Vertical />
<YearFilterItem text="Все время" isAll chosen={!chosenYear} />
</div>
<YearFilterDropdown years={PostYears} />
</div>
);
}
@@ -0,0 +1,48 @@
'use client';
import { useGetArticlesQuery } from '@/queries/articles/getArticles';
import { IArticle } from '@/types/IPost';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { ArticleCard } from './ArticleCard';
export function ArticlesList() {
const params = useSearchParams();
const [articles, setArticles] = useState<IArticle[]>();
const [limit, setLimit] = useState(5);
const [tags, setTags] = useState<string[]>([]);
useEffect(() => {
setTags(params.getAll('tags'));
}, [params]);
useGetArticlesQuery({
variables: { tags, limit, offset: 0 },
onCompleted(data) {
if (data.articles.__typename === 'Articles') {
setArticles(data.articles.articles as IArticle[]);
}
},
});
return (
<div className="lg:pb-6">
{articles?.length === 0 ? (
<p>No posts found</p>
) : (
<div className="lg:mb-6 sm:mb-5 mb-4 lg:space-y-6 sm:space-y-5 space-y-4">
{articles?.map(post => <ArticleCard key={post.id} {...post} />)}
<div className="lg:pl-[calc(25vw-40px)]">
<button
onClick={() => setLimit(limit + 5)}
className="lg:opacity-80 lg:hover:opacity-100 btn-text font-semibold border border-[#3D425C] rounded-[32px] lg:py-[14.5px] sm:py-6 py-4 w-full"
>
Показать еще
</button>
</div>
</div>
)}
</div>
);
}
@@ -1,47 +0,0 @@
'use client';
import { Posts } from '@/consts/Posts';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { ArticleCard } from './ArticleCard';
export function PostsList() {
const params = useSearchParams();
const [posts, setPosts] = useState(Posts);
useEffect(() => {
setPosts(
Posts.filter(
post =>
(!params.has('tags') ||
params.getAll('tags').every(tag => post.tags.includes(tag))) &&
(!params.has('year') ||
params.get('year') === post.createdAt.split(' ')[2]),
),
);
}, [params]);
// const { data } = useGetArticlesQuery({
// variables: { tags: params.getAll('tags'), limit: 10, offset: 0 },
// });
return (
<div className="lg:pb-6">
{posts.length === 0 ? (
<p>No posts found</p>
) : (
<div className="lg:mb-6 sm:mb-5 mb-4">
{posts.map(post => (
<ArticleCard key={post.id} {...post} />
))}
<div className="lg:pl-[calc(25vw-40px)]">
<button className="lg:opacity-80 lg:hover:opacity-100 btn-text font-semibold border border-[#3D425C] rounded-[32px] lg:py-[14.5px] sm:py-6 py-4 w-full">
Показать еще
</button>
</div>
</div>
)}
</div>
);
}
@@ -6,7 +6,7 @@ import { AppearanceHr } from '@/ui/AppearanceHr';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
export function RelevantPost({ image, title, id }: IArticle) {
export function RelevantArticles({ image, title, id }: IArticle) {
const { push } = useRouter();
return (
@@ -6,7 +6,7 @@ import { Posts } from '@/consts/Posts';
import { useWindowWidth } from '@/hooks/useWindowWidth';
import { InfinitySlider } from '@/ui/InfinitySlider';
import { useRef } from 'react';
import { RelevantPost } from './RelevantPost';
import { RelevantArticles } from './RelevantArticles';
export function RelevantSlider() {
const left = useRef<HTMLButtonElement>(null);
@@ -37,28 +37,31 @@ export function RelevantSlider() {
<InfinitySlider
slideType="link"
slidesGap={16}
slides={Posts.map(RelevantPost)}
slides={Posts.map(RelevantArticles)}
slideWidth={width * 0.31}
className="px-10"
controlsRefs={{ left, right }}
className="px-10"
withControls={false}
/>
) : width >= 640 ? (
<InfinitySlider
slideType="link"
slidesGap={12}
slides={Posts.map(RelevantPost)}
slides={Posts.map(RelevantArticles)}
slideWidth={width * 0.57}
className="px-8"
controlsRefs={{ left, right }}
withControls={false}
/>
) : (
<InfinitySlider
slideType="link"
slidesGap={8}
slides={Posts.map(RelevantPost)}
slides={Posts.map(RelevantArticles)}
slideWidth={328}
className="px-4"
controlsRefs={{ left, right }}
withControls={false}
/>
))}
</div>
@@ -1,23 +1,38 @@
'use client';
import { ArrowMoreIcon } from '@/components/icons/ArrowMoreIcon';
import {
IAddProjectFormInput,
ProjectFormModal,
} from '@/components/modals/ProjectFormModal';
import { ClassNameWrapper } from '@/hocs/ClassNameWrapper';
import { useCheckAuthQuery } from '@/queries/auth/checkAuth';
import { useAddProjectMutation } from '@/queries/projects/addProject';
import { useGetProjectsQuery } from '@/queries/projects/getProjects';
import { useModalStore } from '@/stores/useModalStore';
import { IProject } from '@/types/IProject';
import { Button } from '@/ui/Button';
import { Descriptor } from '@/ui/Descriptor';
import { getProjectsCount } from '@/utils/getProjectsCount';
import { getSortedProjects } from '@/utils/getSortedProjects';
import { useApolloClient } from '@apollo/client';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { ProjectsSection } from '../ProjectsPage/ProjectsSection';
export function Projects() {
const { setModal } = useModalStore();
const [all, setAll] = useState(false);
const [sortedProjects, setSortedProjects] =
useState<Map<string | number, IProject[]>>();
const client = useApolloClient();
const { data } = useGetProjectsQuery({ variables: { devices: [] } });
const { data: authData } = useCheckAuthQuery();
const getProjects = useCallback(async () => {
try {
setSortedProjects(
@@ -34,6 +49,14 @@ export function Projects() {
}
}, [data]);
const [addProject] = useAddProjectMutation({
onCompleted() {
client.refetchQueries({
include: ['GetProjects'],
});
},
});
const projectsInProccess = sortedProjects?.get('В работе') ?? [];
useEffect(() => {
@@ -56,6 +79,27 @@ export function Projects() {
в&nbsp;разных городах России и&nbsp;мира
</p>
</div>
{authData?.checkAuth.__typename === 'CheckAuthResponse' &&
authData?.checkAuth.isAuth && (
<div className="m-auto">
<Button
onClick={() =>
setModal(
<ProjectFormModal
action={'create'}
onSubmit={(data: IAddProjectFormInput) => {
addProject({ variables: { input: data } });
setModal(null, '');
}}
/>,
'addProject',
)
}
>
Добавить проект
</Button>
</div>
)}
<Link
href={'/projects'}
className="lg:col-start-4 self-end w-full bg-[#14161F] btn-text flex justify-between items-center rounded-full border border-[#3D425C] py-5 px-6"
@@ -7,7 +7,7 @@ import Image from 'next/image';
export function ProjectsMap() {
return (
<div className="relative w-fit h-fit origin-top-left">
<div className="relative w-fit h-fit origin-top-left max-md:hidden z-[9]">
<Image
src={'/img/pages/home/stats/map.jpg'}
alt={'Карта проектов'}
+1 -1
View File
@@ -22,7 +22,7 @@ export function Showreel() {
className="w-full lg:aspect-[1552/616] object-cover self-stretch"
/>
<button
className="absolute z-10 p-8 rounded-full border group-hover:block hidden bg-[#14161F33]"
className="absolute z-[9] p-8 rounded-full border group-hover:block hidden bg-[#14161F33]"
onClick={() => {
setModal(
<VideoModal
+7 -1
View File
@@ -43,8 +43,14 @@ export function Statistics() {
Мы собрали статистику за&nbsp;13&nbsp;лет работы c&nbsp;застройщиками,
реализовав {getProjectsCount(projects.length)}
</div>
<div className="lg:col-start-4 lg:col-span-full sm:col-span-2 lg:py-10 lg:pl-4 border-b border-[#3D425C] aspect-[1176/570] max-md:hidden">
<div className="lg:col-start-4 lg:col-span-full col-span-2 lg:py-10 lg:pl-4 border-b border-[#3D425C] aspect-[1176/570]">
<ProjectsMap />
<Image
src={'/img/pages/home/stats/map_with_points.png'}
alt="map"
fill
className="!relative md:hidden"
/>
</div>
<div className="lg:col-span-3 md:col-span-1 col-span-full lg:py-10 sm:max-lg:pt-8 pt-6 lg:flex flex-col justify-between sm:max-lg:space-y-5 max-sm:space-y-3 lg:border-b lg:border-r border-[#3D425C]">
<Descriptor title="экономическая эффективность" />
+5 -2
View File
@@ -46,11 +46,13 @@ export function Streaming() {
city="Екатеринбург"
name="Re:volution Towers"
image="/img/pages/home/streaming/revolution.png"
href="https://stream.graff.tech"
/>
<StreamingProject
city="Тюмень"
image="/img/pages/home/streaming/aivazovsky.png"
name="Айвазовский City"
href="https://stream.graff.tech/?build=Ivazowsky&location=a1"
/>
</div>
</div>
@@ -62,10 +64,11 @@ function StreamingProject({
city,
name,
image,
}: Pick<IProject, 'city' | 'name' | 'image'>) {
href,
}: Pick<IProject, 'city' | 'name' | 'image'> & { href: string }) {
return (
<Link
href={'https://stream.graff.tech'}
href={href}
className="flex flex-col justify-between gap-y-12 p-6 [background:center/cover_url(/img/pages/home/stats/highlight.svg)_no-repeat,#14161F] col-span-1"
>
<Image
@@ -52,7 +52,7 @@ export function ProjectCard({
>
{data?.checkAuth.__typename === 'CheckAuthResponse' &&
data.checkAuth.isAuth && (
<div className="absolute top-0 right-0 p-4 flex gap-2 z-10">
<div className="absolute top-0 right-0 p-4 flex gap-2 z-[9]">
<button
onClick={() =>
setModal(
@@ -76,7 +76,7 @@ export function ProjectCard({
'editProject',
)
}
className="group relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
className="relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -92,13 +92,10 @@ export function ProjectCard({
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
<span className="pointer-events-none group-hover:opacity-100 opacity-0 transition-opacity absolute -bottom-[90%] left-[50%] -translate-x-[50%] bg-neutral-900 px-2 py-1 text-sm rounded-lg">
Редактировать
</span>
</button>
<button
onClick={() => setModal(<DeleteProjectModal id={id} />, '')}
className="group relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
className="relative p-2 bg-black bg-opacity-60 hover:bg-opacity-70 transition-opacity rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -114,16 +111,13 @@ export function ProjectCard({
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
<span className="pointer-events-none group-hover:opacity-100 opacity-0 transition-opacity absolute -bottom-[90%] left-[50%] -translate-x-[50%] bg-neutral-900 px-2 py-1 text-sm rounded-lg">
Удалить
</span>
</button>
</div>
)}
<div
className="group-hover:scale-110 transition-transform duration-500 absolute top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: `url(${process.env.NEXT_PUBLIC_S3_BUCKET}/${image})`,
backgroundImage: `url(${process.env.NEXT_PUBLIC_S3_BUCKET}${image})`,
}}
/>
<div className="absolute top-0 left-0 w-full h-full bg-gradient-card" />
@@ -24,14 +24,6 @@ export function ProjectsFilters() {
/>
))}
</div>
{/* <div className="flex flex-wrap gap-4 h-fit max-sm:hidden">
{ProjectYears.map(year => (
<YearFilterItem key={year} text={year} chosen={year === chosenYear} />
))}
<Vertical />
<YearFilterItem text="Все время" isAll chosen={!params.has('year')} />
</div>
<YearFilterDropdown years={ProjectYears} /> */}
</div>
);
}
+1 -1
View File
@@ -1 +1 @@
export const PostTags = ['недвижимость', 'награды', 'выставки'];
export const PostTags = ['Недвижимость', 'Награды', 'Выставки'];
+1 -17
View File
@@ -52,7 +52,7 @@ export type AuthResponse = {
export type AuthResult = AuthResponse | Error;
export type Block = Content | Quote | Slider | Video;
export type Block = Content | Slider;
export type CheckAuthResponse = {
__typename?: 'CheckAuthResponse';
@@ -211,15 +211,6 @@ export type QueryProjectsArgs = {
input?: InputMaybe<ProjectInput>;
};
export type Quote = {
__typename?: 'Quote';
authorAvatar: Scalars['String']['output'];
authorName: Scalars['String']['output'];
authorRole: Scalars['String']['output'];
text: Scalars['String']['output'];
type: Scalars['String']['output'];
};
export type Region = {
__typename?: 'Region';
regionName: Scalars['String']['output'];
@@ -232,10 +223,3 @@ export type Slider = {
images: Array<Image>;
type: Scalars['String']['output'];
};
export type Video = {
__typename?: 'Video';
caption?: Maybe<Scalars['String']['output']>;
type: Scalars['String']['output'];
video: Scalars['String']['output'];
};
@@ -8,7 +8,7 @@ export type AddArticleMutationVariables = Types.Exact<{
}>;
export type AddArticleMutation = { __typename?: 'Mutation', createArticle: { __typename?: 'Article', id: number, title: string, description: string, posterImage: string, cardImage: string, createdAt: any, tags: Array<string>, blocks: Array<{ __typename?: 'Content', type: string, content: string } | { __typename?: 'Quote', type: string, text: string, authorName: string, authorAvatar: string, authorRole: string } | { __typename?: 'Slider', type: string, images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> } | { __typename?: 'Video', type: string, video: string, caption?: string | null }> } | { __typename?: 'Error' } };
export type AddArticleMutation = { __typename?: 'Mutation', createArticle: { __typename?: 'Article', id: number, title: string, description: string, posterImage: string, cardImage: string, createdAt: any, tags: Array<string>, blocks: Array<{ __typename?: 'Content', type: string, content: string } | { __typename?: 'Slider', type: string, images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> }> } | { __typename?: 'Error' } };
export const AddArticleDocument = gql`
@@ -27,13 +27,6 @@ export const AddArticleDocument = gql`
type
content
}
... on Quote {
type
text
authorName
authorAvatar
authorRole
}
... on Slider {
type
images {
@@ -41,11 +34,6 @@ export const AddArticleDocument = gql`
caption
}
}
... on Video {
type
video
caption
}
}
}
}
@@ -13,13 +13,6 @@ mutation AddArticle($input: CreateArticleInput!) {
type
content
}
... on Quote {
type
text
authorName
authorAvatar
authorRole
}
... on Slider {
type
images {
@@ -27,11 +20,6 @@ mutation AddArticle($input: CreateArticleInput!) {
caption
}
}
... on Video {
type
video
caption
}
}
}
}
@@ -8,7 +8,7 @@ export type DeleteArticleMutationVariables = Types.Exact<{
}>;
export type DeleteArticleMutation = { __typename?: 'Mutation', deleteArticle: { __typename?: 'Article', id: number, title: string, description: string, createdAt: any, cardImage: string, posterImage: string, tags: Array<string>, blocks: Array<{ __typename?: 'Content', type: string, content: string } | { __typename?: 'Quote', type: string, text: string, authorName: string, authorAvatar: string, authorRole: string } | { __typename?: 'Slider', type: string, images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> } | { __typename?: 'Video', type: string, video: string, caption?: string | null }> } | { __typename?: 'Error', message: string } };
export type DeleteArticleMutation = { __typename?: 'Mutation', deleteArticle: { __typename?: 'Article', id: number, title: string, description: string, createdAt: any, cardImage: string, posterImage: string, tags: Array<string>, blocks: Array<{ __typename?: 'Content', type: string, content: string } | { __typename?: 'Slider', type: string, images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> }> } | { __typename?: 'Error', message: string } };
export const DeleteArticleDocument = gql`
@@ -30,13 +30,6 @@ export const DeleteArticleDocument = gql`
type
content
}
... on Quote {
type
text
authorName
authorAvatar
authorRole
}
... on Slider {
type
images {
@@ -44,11 +37,6 @@ export const DeleteArticleDocument = gql`
caption
}
}
... on Video {
type
video
caption
}
}
}
}
@@ -16,13 +16,6 @@ mutation DeleteArticle($id: Int!) {
type
content
}
... on Quote {
type
text
authorName
authorAvatar
authorRole
}
... on Slider {
type
images {
@@ -30,11 +23,6 @@ mutation DeleteArticle($id: Int!) {
caption
}
}
... on Video {
type
video
caption
}
}
}
}
@@ -9,7 +9,7 @@ export type EditArticleMutationVariables = Types.Exact<{
}>;
export type EditArticleMutation = { __typename?: 'Mutation', updateArticle: { __typename?: 'Article', id: number, title: string, description: string, posterImage: string, cardImage: string, createdAt: any, tags: Array<string>, blocks: Array<{ __typename?: 'Content', type: string, content: string } | { __typename?: 'Quote', type: string, text: string, authorName: string, authorAvatar: string, authorRole: string } | { __typename?: 'Slider', type: string, images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> } | { __typename?: 'Video', type: string, video: string, caption?: string | null }> } | { __typename?: 'Error', message: string } };
export type EditArticleMutation = { __typename?: 'Mutation', updateArticle: { __typename?: 'Article', id: number, title: string, description: string, posterImage: string, cardImage: string, createdAt: any, tags: Array<string>, blocks: Array<{ __typename?: 'Content', type: string, content: string } | { __typename?: 'Slider', type: string, images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> }> } | { __typename?: 'Error', message: string } };
export const EditArticleDocument = gql`
@@ -31,13 +31,6 @@ export const EditArticleDocument = gql`
type
content
}
... on Quote {
type
text
authorName
authorAvatar
authorRole
}
... on Slider {
type
images {
@@ -45,11 +38,6 @@ export const EditArticleDocument = gql`
caption
}
}
... on Video {
type
video
caption
}
}
}
}
@@ -16,13 +16,6 @@ mutation EditArticle($id: Int!, $input: CreateArticleInput!) {
type
content
}
... on Quote {
type
text
authorName
authorAvatar
authorRole
}
... on Slider {
type
images {
@@ -30,11 +23,6 @@ mutation EditArticle($id: Int!, $input: CreateArticleInput!) {
caption
}
}
... on Video {
type
video
caption
}
}
}
}
@@ -8,7 +8,7 @@ export type GetArticleByIdQueryVariables = Types.Exact<{
}>;
export type GetArticleByIdQuery = { __typename?: 'Query', article: { __typename?: 'Article', id: number, title: string, description: string, createdAt: any, cardImage: string, posterImage: string, tags: Array<string>, blocks: Array<{ __typename?: 'Content', type: string, content: string } | { __typename?: 'Quote', type: string, text: string, authorName: string, authorAvatar: string, authorRole: string } | { __typename?: 'Slider', type: string, images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> } | { __typename?: 'Video', type: string, video: string, caption?: string | null }> } | { __typename?: 'Error', message: string } };
export type GetArticleByIdQuery = { __typename?: 'Query', article: { __typename?: 'Article', id: number, title: string, description: string, createdAt: any, cardImage: string, posterImage: string, tags: Array<string>, blocks: Array<{ __typename?: 'Content', type: string, content: string } | { __typename?: 'Slider', type: string, images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> }> } | { __typename?: 'Error', message: string } };
export const GetArticleByIdDocument = gql`
@@ -30,13 +30,6 @@ export const GetArticleByIdDocument = gql`
type
content
}
... on Quote {
type
text
authorName
authorAvatar
authorRole
}
... on Slider {
type
images {
@@ -44,11 +37,6 @@ export const GetArticleByIdDocument = gql`
caption
}
}
... on Video {
type
video
caption
}
}
}
}
@@ -16,13 +16,6 @@ query GetArticleById($id: Int!) {
type
content
}
... on Quote {
type
text
authorName
authorAvatar
authorRole
}
... on Slider {
type
images {
@@ -30,11 +23,6 @@ query GetArticleById($id: Int!) {
caption
}
}
... on Video {
type
video
caption
}
}
}
}
@@ -10,7 +10,7 @@ export type GetArticlesQueryVariables = Types.Exact<{
}>;
export type GetArticlesQuery = { __typename?: 'Query', articles: { __typename?: 'Articles', articles: Array<{ __typename?: 'Article', id: number, title: string, description: string, tags: Array<string>, createdAt: any, posterImage: string, cardImage: string, blocks: Array<{ __typename?: 'Content', content: string } | { __typename?: 'Quote', text: string, authorName: string, authorAvatar: string, authorRole: string } | { __typename?: 'Slider', images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> } | { __typename?: 'Video', video: string, caption?: string | null }> }> } | { __typename?: 'Error', message: string } };
export type GetArticlesQuery = { __typename?: 'Query', articles: { __typename?: 'Articles', articles: Array<{ __typename?: 'Article', id: number, title: string, description: string, tags: Array<string>, createdAt: any, posterImage: string, cardImage: string, blocks: Array<{ __typename?: 'Content', content: string } | { __typename?: 'Slider', images: Array<{ __typename?: 'Image', img: string, caption?: string | null }> }> }> } | { __typename?: 'Error', message: string } };
export const GetArticlesDocument = gql`
@@ -32,22 +32,12 @@ export const GetArticlesDocument = gql`
... on Content {
content
}
... on Quote {
text
authorName
authorAvatar
authorRole
}
... on Slider {
images {
img
caption
}
}
... on Video {
video
caption
}
}
}
}
@@ -16,22 +16,12 @@ query GetArticles($tags: [String!]!, $limit: Int, $offset: Int) {
... on Content {
content
}
... on Quote {
text
authorName
authorAvatar
authorRole
}
... on Slider {
images {
img
caption
}
}
... on Video {
video
caption
}
}
}
}
+1 -15
View File
@@ -13,21 +13,7 @@ export interface ISlider {
images: IImage[];
}
export interface IVideo {
type: 'Video';
video: string;
caption?: string | null;
}
export interface IQuote {
type: 'Quote';
text: string;
authorName: string;
authorAvatar: string;
authorRole: string;
}
export type Block = IContent | ISlider | IVideo | IQuote;
export type Block = IContent | ISlider;
export interface IArticle {
id: number;
+7 -1
View File
@@ -8,6 +8,7 @@ interface ButtonProps {
disabled?: boolean;
className?: string;
onClick?: () => void;
type?: 'submit' | 'reset' | 'button';
}
export function Button({
@@ -18,11 +19,16 @@ export function Button({
disabled = false,
className,
onClick,
type,
}: ButtonProps) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
onClick={e => {
if (type !== 'submit') e.preventDefault();
onClick?.();
}}
className={`group relative px-6 py-2 rounded-full min-w-fit ${
(color === 'primary'
? 'bg-gradient-to-r from-[#798FFF] to-[#D375FF]'
+12 -25
View File
@@ -1,5 +1,3 @@
import { ArrowLeftIcon } from '@/components/icons/ArrowLeftIcon';
import { ArrowRightIcon } from '@/components/icons/ArrowRightIcon';
import { useWindowWidth } from '@/hooks/useWindowWidth';
import {
ReactNode,
@@ -11,6 +9,7 @@ import {
useState,
} from 'react';
import { useSwipeable } from 'react-swipeable';
import { SliderControls } from './SliderControls';
export function InfinitySlider({
slides,
@@ -18,23 +17,23 @@ export function InfinitySlider({
offset = 0,
className = '',
slidesGap = 16,
progressMT = 16,
slideType = 'image',
controlsRefs = undefined,
withDarkness = false,
withControls = true,
}: {
slides: ReactNode[];
slideWidth: number;
offset?: string | number;
className?: string;
slidesGap?: number;
progressMT?: number;
slideType?: 'image' | 'link';
controlsRefs?: {
left: RefObject<HTMLButtonElement>;
right: RefObject<HTMLButtonElement>;
};
withDarkness?: boolean;
withControls?: boolean;
}) {
const width = useWindowWidth();
const baseOffset = slideWidth + slidesGap;
@@ -143,27 +142,15 @@ export function InfinitySlider({
))}
</div>
</div>
{!controlsRefs && (
<div
style={{
marginLeft: width >= 1024 ? offset : 'auto',
marginTop: progressMT,
}}
className="flex items-center gap-4 lg:w-[clamp(720px,100vw-465px,1136px)] desktop-figma:w-[71vw]"
>
<button onClick={prevSlide} className="max-sm:hidden">
<ArrowLeftIcon />
</button>
<div className="h-1 bg-[#3D425C] w-full">
<div
className="bg-[#ffffff] h-1 duration-500"
style={{ width: `${((slide + 1) / 3) * 100}%` }}
/>
</div>
<button onClick={nextSlide} className="max-sm:hidden">
<ArrowRightIcon />
</button>
</div>
{withControls && (
<SliderControls
slidesCount={slides.length}
width={width >= 640 ? 132 : (width / 360) * 196}
height={width >= 640 ? 66 : 58}
slide={slide}
onLeftClick={prevSlide}
onRightClick={nextSlide}
/>
)}
</div>
);
+5 -11
View File
@@ -1,28 +1,22 @@
import { getMonthTitle } from '@/utils/getMonthTitle';
export function PostDate({
date,
month,
year,
inPostCard = true,
className = '',
}: {
date: string;
month: string;
year: string;
inPostCard?: boolean;
className?: string;
}) {
return (
<div
className={
'lg:min-w-[105px] lg:max-w-[calc(25vw-56px)] lg:w-[23vw]' +
(inPostCard ? ' flex-1 self-end ' : ' ') +
className
}
>
<div className={className}>
<h2 className="h2 font-medium mb-2 max-lg:hidden">{date}</h2>
<p className="descriptor font-medium max-lg:opacity-70">
<p className="descriptor font-medium max-lg:opacity-70 uppercase">
<span className="lg:hidden">{date} </span>
{month} {year}
{getMonthTitle(month)} {year}
</p>
</div>
);
+3 -9
View File
@@ -1,16 +1,10 @@
import Image from 'next/image';
export function PostSliderImage({
slide,
title,
}: {
slide: string;
title: string;
}) {
export function PostSliderImage({ slide }: { slide: string }) {
return (
<Image
src={slide}
alt={title}
src={process.env.NEXT_PUBLIC_S3_BUCKET + slide}
alt={slide}
fill
className="!static object-cover max-lg:rounded-2xl"
sizes="100%"
+16
View File
@@ -0,0 +1,16 @@
export function getMonthTitle(month: string) {
return new Map([
['1', 'Января'],
['2', 'Февраля'],
['3', 'Марта'],
['4', 'Апреля'],
['5', 'Мая'],
['6', 'Июня'],
['7', 'Июля'],
['8', 'Августа'],
['9', 'Сентября'],
['10', 'Октября'],
['11', 'Ноября'],
['12', 'Декабря'],
]).get(month);
}
+87
View File
@@ -2400,6 +2400,36 @@ dom-helpers@^5.0.1:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
dom-serializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.2"
entities "^4.2.0"
domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@5.0.3, domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domutils@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
dependencies:
dom-serializer "^2.0.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
dot-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
@@ -2451,6 +2481,11 @@ enhanced-resolve@^5.12.0:
graceful-fs "^4.2.4"
tapable "^2.2.0"
entities@^4.2.0, entities@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
env-paths@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
@@ -3266,6 +3301,34 @@ hoist-non-react-statics@^3.3.2:
dependencies:
react-is "^16.7.0"
html-dom-parser@5.0.10:
version "5.0.10"
resolved "https://registry.yarnpkg.com/html-dom-parser/-/html-dom-parser-5.0.10.tgz#bf46b05c50f35c2fcadfc8e91566c54d3caf9bd7"
integrity sha512-GwArYL3V3V8yU/mLKoFF7HlLBv80BZ2Ey1BzfVNRpAci0cEKhFHI/Qh8o8oyt3qlAMLlK250wsxLdYX4viedvg==
dependencies:
domhandler "5.0.3"
htmlparser2 "9.1.0"
html-react-parser@^5.1.18:
version "5.1.18"
resolved "https://registry.yarnpkg.com/html-react-parser/-/html-react-parser-5.1.18.tgz#a07ff6d95fcaa6de45244386a12dddb981434915"
integrity sha512-65BwC0zzrdeW96jB2FRr5f1ovBhRMpLPJNvwkY5kA8Ay5xdL9t/RH2/uUTM7p+cl5iM88i6dDk4LXtfMnRmaJQ==
dependencies:
domhandler "5.0.3"
html-dom-parser "5.0.10"
react-property "2.0.2"
style-to-js "1.1.16"
htmlparser2@9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23"
integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.1.0"
entities "^4.5.0"
http-proxy-agent@^7.0.0:
version "7.0.2"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e"
@@ -3340,6 +3403,11 @@ inherits@2, inherits@^2.0.3, inherits@^2.0.4:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
inline-style-parser@0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz#f4af5fe72e612839fcd453d989a586566d695f22"
integrity sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==
input-format@^0.3.10:
version "0.3.10"
resolved "https://registry.yarnpkg.com/input-format/-/input-format-0.3.10.tgz#e8a8855e2e89e3b1cd995333f6277c14865f0e35"
@@ -4527,6 +4595,11 @@ react-phone-number-input@*, react-phone-number-input@^3.4.5:
libphonenumber-js "^1.11.5"
prop-types "^15.8.1"
react-property@2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/react-property/-/react-property-2.0.2.tgz#d5ac9e244cef564880a610bc8d868bd6f60fdda6"
integrity sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==
react-rangeslider@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/react-rangeslider/-/react-rangeslider-2.2.0.tgz#4362b01f4f5a455f0815d371d496f69ca4c6b5aa"
@@ -5052,6 +5125,20 @@ strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
style-to-js@1.1.16:
version "1.1.16"
resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.16.tgz#e6bd6cd29e250bcf8fa5e6591d07ced7575dbe7a"
integrity sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==
dependencies:
style-to-object "1.0.8"
style-to-object@1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.8.tgz#67a29bca47eaa587db18118d68f9d95955e81292"
integrity sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==
dependencies:
inline-style-parser "0.2.4"
styled-jsx@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f"