diff --git a/.gitleaks.toml b/.gitleaks.toml index f863a9e1..8fb1fc95 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,5 +1,9 @@ title = "Custom Gitleaks Config" -allowlist = { regexes = ["https://registry\\.npmjs\\.org"] } + +[allowlist] +files = ["package-lock.json"] +regexes = ["https://registry\\.npmjs\\.org"] +paths = ['''package-lock\.json'''] # Rule to detect API keys [[rules]] diff --git a/api/axiosInstance.ts b/api/axiosInstance.ts deleted file mode 100644 index 1d08f3ca..00000000 --- a/api/axiosInstance.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'axios'; - -const axiosInstance = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, -}); - -export default axiosInstance; diff --git a/api/AxiosProvider.tsx b/apis/AxiosProvider.tsx similarity index 100% rename from api/AxiosProvider.tsx rename to apis/AxiosProvider.tsx diff --git a/apis/axiosInstance.ts b/apis/axiosInstance.ts new file mode 100644 index 00000000..0c2376cb --- /dev/null +++ b/apis/axiosInstance.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; + +const axiosInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// TODO: REPLACE BEARER LOGIC AS SOON AS AUTHENTICATION IS IMPLEMENTED. THIS IS FOR TESTING PURPOSES +axiosInstance.interceptors.request.use( + (config) => { + if (process.env.NEXT_PUBLIC_BEARER) { + // eslint-disable-next-line no-param-reassign + config.headers.Authorization = `Bearer ${process.env.NEXT_PUBLIC_BEARER}`; + } else { + throw new Error('No bearer token found'); + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +export default axiosInstance; diff --git a/api/endpoints.ts b/apis/endpoints.ts similarity index 71% rename from api/endpoints.ts rename to apis/endpoints.ts index 1edf1e4d..3aeb73ba 100644 --- a/api/endpoints.ts +++ b/apis/endpoints.ts @@ -18,6 +18,12 @@ const ENDPOINTS = { NEWSLETTER: { SUBSCRIBE: `${API_BASE_URL}/newsletter-subscribers`, }, + TAGS: { + GET_ALL: `${API_BASE_URL}/content/blog-post-tags`, + CREATE: `${API_BASE_URL}/content/blog-post-tags`, + DELETE: `${API_BASE_URL}/content/blog-post-tags`, + EDIT: `${API_BASE_URL}/content/blog-post-tags`, + }, }; export default ENDPOINTS; diff --git a/api/mutations/blogs/useAddNewPost.ts b/apis/mutations/blogs/useAddNewPost.ts similarity index 100% rename from api/mutations/blogs/useAddNewPost.ts rename to apis/mutations/blogs/useAddNewPost.ts diff --git a/api/mutations/contact/useSubmitContactform.ts b/apis/mutations/contact/useSubmitContactform.ts similarity index 100% rename from api/mutations/contact/useSubmitContactform.ts rename to apis/mutations/contact/useSubmitContactform.ts diff --git a/api/mutations/newsletter/useSubmitNewsletterForm.ts b/apis/mutations/newsletter/useSubmitNewsletterForm.ts similarity index 100% rename from api/mutations/newsletter/useSubmitNewsletterForm.ts rename to apis/mutations/newsletter/useSubmitNewsletterForm.ts diff --git a/apis/mutations/tags/useAddNewTag.ts b/apis/mutations/tags/useAddNewTag.ts new file mode 100644 index 00000000..62c44f35 --- /dev/null +++ b/apis/mutations/tags/useAddNewTag.ts @@ -0,0 +1,34 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { AxiosError } from 'axios'; +import { useAxios } from '../../AxiosProvider'; +import ENDPOINTS from '../../endpoints'; +import QUERY_KEYS from '../../queryKeys'; + +const useAddNewTag = () => { + const queryClient = useQueryClient(); + const axios = useAxios(); + + interface ErrorResponse { + message?: string; + } + + return useMutation({ + mutationFn: async ({ tagName }: { tagName: string }) => { + const response = await axios.post(ENDPOINTS.TAGS.CREATE, { name: tagName }); + return response.data; + }, + onError: (error: AxiosError) => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.TAGS.ALL }); + toast.error(error?.response?.data?.message || 'Настана грешка при додавање на тагот'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.TAGS.ALL }); + toast.success('Тагот беше успешно додаден.'); + }, + }); +}; + +export default useAddNewTag; diff --git a/apis/mutations/tags/useDeleteTag.ts b/apis/mutations/tags/useDeleteTag.ts new file mode 100644 index 00000000..21b973be --- /dev/null +++ b/apis/mutations/tags/useDeleteTag.ts @@ -0,0 +1,34 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { AxiosError } from 'axios'; +import { useAxios } from '../../AxiosProvider'; +import ENDPOINTS from '../../endpoints'; +import QUERY_KEYS from '../../queryKeys'; + +const useDeleteTag = () => { + const queryClient = useQueryClient(); + const axios = useAxios(); + + interface ErrorResponse { + message?: string; + } + + return useMutation({ + mutationFn: async (tagId: string) => { + const response = await axios.delete(`${ENDPOINTS.TAGS.DELETE}/${tagId}`); + return response.data; + }, + onError: (error: AxiosError) => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.TAGS.ALL }); + toast.error(error?.response?.data?.message || 'Настана грешка при бришење на тагот'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.TAGS.ALL }); + toast.success('Тагот беше успешно избришан.'); + }, + }); +}; + +export default useDeleteTag; diff --git a/apis/mutations/tags/useEditTag.ts b/apis/mutations/tags/useEditTag.ts new file mode 100644 index 00000000..39b9184a --- /dev/null +++ b/apis/mutations/tags/useEditTag.ts @@ -0,0 +1,34 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { AxiosError } from 'axios'; +import { useAxios } from '../../AxiosProvider'; +import ENDPOINTS from '../../endpoints'; +import QUERY_KEYS from '../../queryKeys'; + +const useEditTag = () => { + const queryClient = useQueryClient(); + const axios = useAxios(); + + interface ErrorResponse { + message?: string; + } + + return useMutation({ + mutationFn: async ({ tagId, newName }: { tagId: string; newName: string }) => { + const response = await axios.patch(`${ENDPOINTS.TAGS.EDIT}/${tagId}`, { name: newName }); + return response.data; + }, + onError: (error: AxiosError) => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.TAGS.ALL }); + toast.error(error?.response?.data?.message || 'Настана грешка при изменување на тагот'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.TAGS.ALL }); + toast.success('Тагот беше успешно изменет'); + }, + }); +}; + +export default useEditTag; diff --git a/api/queries/blogs/getBlogDetails.ts b/apis/queries/blogs/getBlogDetails.ts similarity index 100% rename from api/queries/blogs/getBlogDetails.ts rename to apis/queries/blogs/getBlogDetails.ts diff --git a/api/queries/blogs/getBlogs.ts b/apis/queries/blogs/getBlogs.ts similarity index 100% rename from api/queries/blogs/getBlogs.ts rename to apis/queries/blogs/getBlogs.ts diff --git a/api/queries/blogs/getInfiniteBlogs.ts b/apis/queries/blogs/getInfiniteBlogs.ts similarity index 100% rename from api/queries/blogs/getInfiniteBlogs.ts rename to apis/queries/blogs/getInfiniteBlogs.ts diff --git a/apis/queries/tags/getTags.ts b/apis/queries/tags/getTags.ts new file mode 100644 index 00000000..8ca423cf --- /dev/null +++ b/apis/queries/tags/getTags.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAxios } from '../../AxiosProvider'; +import ENDPOINTS from '../../endpoints'; +import QUERY_KEYS from '../../queryKeys'; + +const useGetTags = (search?: string) => { + const axios = useAxios(); + + return useQuery({ + queryKey: [...QUERY_KEYS.TAGS.ALL, search], + queryFn: async () => { + const url = search + ? `${ENDPOINTS.TAGS.GET_ALL}?search=${encodeURIComponent(search)}` + : ENDPOINTS.TAGS.GET_ALL; + + const { data } = await axios.get(url); + return data; + }, + }); +}; + +export default useGetTags; diff --git a/api/queries/users/getUsers.ts b/apis/queries/users/getUsers.ts similarity index 100% rename from api/queries/users/getUsers.ts rename to apis/queries/users/getUsers.ts diff --git a/api/queryKeys.ts b/apis/queryKeys.ts similarity index 82% rename from api/queryKeys.ts rename to apis/queryKeys.ts index 7622ab1f..1cfe254f 100644 --- a/api/queryKeys.ts +++ b/apis/queryKeys.ts @@ -6,6 +6,9 @@ const QUERY_KEYS = { USERS: { ALL: ['users'], }, + TAGS: { + ALL: ['blogPostTags'], + }, } as const; export default QUERY_KEYS; diff --git a/app/action.tsx b/app/action.tsx index b78626c8..c521eb9f 100644 --- a/app/action.tsx +++ b/app/action.tsx @@ -1,7 +1,7 @@ 'use server'; import axios from 'axios'; -import axiosInstance from '../api/axiosInstance'; +import axiosInstance from '../apis/axiosInstance'; import { BlogCardProps } from '../components/reusable-components/blog-card/BlogCard'; const fetchBlogPosts = async ( diff --git a/app/content-panel/blogs/[id]/page.tsx b/app/content-panel/blogs/[id]/page.tsx index 09aa8d24..37f99f3d 100644 --- a/app/content-panel/blogs/[id]/page.tsx +++ b/app/content-panel/blogs/[id]/page.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import styles from './BlogDetailsPage.module.scss'; import BlogDetailsCard from '../../../../components/reusable-components/blogDetails-card/BlogDetailsCard'; -import useGetBlogDetails from '../../../../api/queries/blogs/getBlogDetails'; +import useGetBlogDetails from '../../../../apis/queries/blogs/getBlogDetails'; import { BlogDetailsData } from '../../../../components/reusable-components/_Types'; const BlogDetailsPage = ({ params }: { params: { id: string } }) => { diff --git a/app/content-panel/tags/page.tsx b/app/content-panel/tags/page.tsx index 2bb31811..f325137c 100644 --- a/app/content-panel/tags/page.tsx +++ b/app/content-panel/tags/page.tsx @@ -1,15 +1,133 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; + import styles from '../../../components/module-components/tags/Tags.module.scss'; -import AddTags from '../../../components/module-components/tags/AddTags'; import TagTable from '../../../components/module-components/tags/TagTable'; +import TextInput from '../../../components/reusable-components/text-input/TextInput'; +import AddTag from '../../../components/module-components/tags/AddTag'; +import TagManagementControls from '../../../components/module-components/tags/TagManagementControls'; +import useDebounce from '../../../utils/hooks/useDebounce'; + +import useGetTags from '../../../apis/queries/tags/getTags'; +import useAddNewTag from '../../../apis/mutations/tags/useAddNewTag'; +import useDeleteTag from '../../../apis/mutations/tags/useDeleteTag'; +import useEditTag from '../../../apis/mutations/tags/useEditTag'; + +interface Tag { + id: string; + name: string; +} const Tags = () => { + // MUTATIONS + const addNewTagMutation = useAddNewTag(); + const deleteTagMutation = useDeleteTag(); + const editTagMutation = useEditTag(); + + // STATE + const [showAddTag, setShowAddTag] = useState(false); + const [tags, setTags] = useState([]); + const [editingTagId, setEditingTagId] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + const debouncedSearchTerm = useDebounce(searchTerm, 300); + const { data, isLoading } = useGetTags(debouncedSearchTerm); + + const validationSchema = Yup.object().shape({ + tagName: Yup.string() + .required('Името за тагот е задолжително') + .test('unique', 'Тагот веќе постои', (value) => { + return !tags.some((tag) => tag.name.toLowerCase() === value?.toLowerCase().trim()); + }), + }); + + const handleDelete = async (id: string) => { + await deleteTagMutation.mutateAsync(id); + }; + + const addTag = async (newTag: string) => { + const trimmedNewTag = newTag.trim(); + + if (tags.some((tag) => tag.name.toLowerCase() === trimmedNewTag.toLowerCase())) { + return { success: false, error: 'Тагот веќе постои.' }; + } + + await addNewTagMutation.mutateAsync({ tagName: trimmedNewTag }); + return { success: true }; // this is for formik validation purposes + }; + + const handleSaveChanges = async (tagId: string, newName: string) => { + await editTagMutation.mutateAsync({ tagId, newName: newName.trim() }); + setEditingTagId(null); + }; + + const formik = useFormik({ + initialValues: { + tagName: '', + }, + validationSchema, + onSubmit: async (values, { resetForm }) => { + await handleSaveChanges(editingTagId!, values.tagName); + resetForm(); + }, + }); + + const triggerEdit = (tagId: string) => { + const tagToEdit = tags.find((tag) => tag.id === tagId); + + if (tagToEdit) { + setEditingTagId(tagId); + formik.setFieldValue('tagName', tagToEdit.name); + } + }; + + const handleCancelEdit = () => { + setEditingTagId(null); + formik.resetForm(); + }; + + useEffect(() => { + if (data?.data) { + setTags(data.data); + } + }, [data]); + return (
- - + setShowAddTag(true)} + searchTerm={searchTerm} + setSearchTerm={setSearchTerm} + /> + + {showAddTag && setShowAddTag(false)} onAdd={addTag} />} + + ( +
+ +
+ )} + />
); }; diff --git a/app/layout.tsx b/app/layout.tsx index 8d66b74e..72535be7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,7 +13,7 @@ import ReactQueryProvider from '../utils/providers/ReactQueryProvider'; import { ThemeProvider } from './context/themeContext'; import styles from './page.module.scss'; import { AuthProvider } from './context/authContext'; -import { AxiosProvider } from '../api/AxiosProvider'; +import { AxiosProvider } from '../apis/AxiosProvider'; import 'bootstrap-icons/font/bootstrap-icons.css'; import { EditorProvider } from './context/EditorContext'; diff --git a/components/module-components/blog/addNewPost.tsx b/components/module-components/blog/addNewPost.tsx index d8effcc1..097e95c3 100644 --- a/components/module-components/blog/addNewPost.tsx +++ b/components/module-components/blog/addNewPost.tsx @@ -1,6 +1,6 @@ 'use client'; -import useAddNewPost from '../../../api/mutations/blogs/useAddNewPost'; +import useAddNewPost from '../../../apis/mutations/blogs/useAddNewPost'; const AddNewPost = () => { const addNewPostMutation = useAddNewPost(); diff --git a/components/module-components/contact/ContactForm.tsx b/components/module-components/contact/ContactForm.tsx index e75088a7..b01b4632 100644 --- a/components/module-components/contact/ContactForm.tsx +++ b/components/module-components/contact/ContactForm.tsx @@ -13,7 +13,7 @@ import { fullNameRegexValidation, emailRegexValidation } from './regexValidation import { useSubmitContactForm, ContactFormData, -} from '../../../api/mutations/contact/useSubmitContactform'; +} from '../../../apis/mutations/contact/useSubmitContactform'; const ContactForm = () => { const [turnstileToken, setTurnstileToken] = useState(null); @@ -54,7 +54,7 @@ const ContactForm = () => { toast.success('Пораката беше успешно испратена!'); resetForm(); setTurnstileToken(null); - } catch (error) { + } catch { toast.error('Настана грешка при испраќањето на пораката. Пробајте повторно.'); } }, diff --git a/components/module-components/tags/AddTag.tsx b/components/module-components/tags/AddTag.tsx new file mode 100644 index 00000000..62ce5e53 --- /dev/null +++ b/components/module-components/tags/AddTag.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Formik, Form, Field } from 'formik'; +import * as Yup from 'yup'; +import Button from '../../reusable-components/button/Button'; +import styles from './addTag.module.scss'; + +interface AddTagResult { + success: boolean; + error?: string; +} + +interface AddTagProps { + onCancel: () => void; + onAdd: (tag: string) => Promise; +} + +const AddTag: React.FC = ({ onCancel, onAdd }) => { + const validationSchema = Yup.object({ + tagName: Yup.string().trim().required('Името на тагот е задолжително.'), + }); + + return ( + { + const result = await onAdd(values.tagName.trim()); + + if (result.success) { + resetForm(); + onCancel(); + } else if (result.error === 'Тагот веќе постои.') { + setFieldError('tagName', 'Тагот веќе постои.'); + } else { + setFieldError('tagName', 'Настана грешка.'); + } + }} + > + {({ errors, touched }) => ( +
+
+
+ + {errors.tagName && touched.tagName && ( +
{errors.tagName}
+ )} +
+
+
+ )} +
+ ); +}; + +export default AddTag; diff --git a/components/module-components/tags/AddTags.tsx b/components/module-components/tags/AddTags.tsx deleted file mode 100644 index b707ef85..00000000 --- a/components/module-components/tags/AddTags.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { useState } from 'react'; -import styles from './addTags.module.scss'; -import Input from '../../reusable-components/input/Input'; -import Button from '../../reusable-components/button/Button'; - -const AddTags = () => { - const [searchTerm, setSearchTerm] = useState(''); - - const handleAdd = () => { - console.log(searchTerm); - }; - - return ( -
- - -
- ); -}; - -export default AddTags; diff --git a/components/module-components/tags/TagManagementControls.tsx b/components/module-components/tags/TagManagementControls.tsx new file mode 100644 index 00000000..316fd1dc --- /dev/null +++ b/components/module-components/tags/TagManagementControls.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import Button from '../../reusable-components/button/Button'; +import Input from '../../reusable-components/input/Input'; +import styles from './tagManagementControls.module.scss'; + +interface TagManagementControlsProps { + onAddClick: () => void; + searchTerm: string; + setSearchTerm: (term: string) => void; +} + +const TagManagementControls: React.FC = ({ + onAddClick, + searchTerm, + setSearchTerm, +}) => { + return ( +
+
+ +
+
+ ); +}; + +export default TagManagementControls; diff --git a/components/module-components/tags/TagTable.tsx b/components/module-components/tags/TagTable.tsx index b2c5d946..aa4cef4c 100644 --- a/components/module-components/tags/TagTable.tsx +++ b/components/module-components/tags/TagTable.tsx @@ -3,57 +3,103 @@ import React, { useState } from 'react'; import Button from '../../reusable-components/button/Button'; import ReusableTable from '../../reusable-components/reusable-table/ReusableTable'; +import ReusableModal from '../../reusable-components/reusable-modal/ReusableModal'; interface Tag { id: string; name: string; } -const TagTable: React.FC = () => { - const [tags] = useState([ - { id: '1', name: 'React' }, - { id: '2', name: 'TypeScript' }, - { id: '3', name: 'NextJS' }, - ]); +interface TagTableProps { + tags: Tag[]; + editingTagId: string | null; + onEdit: (id: string) => void; + onSave: () => void; + onCancel: () => void; + onDelete: (id: string) => void; + renderEditInput: (tag: Tag) => React.ReactNode; + isLoading: boolean; +} +const TagTable: React.FC = ({ + isLoading, + tags, + editingTagId, + onEdit, + onSave, + onCancel, + onDelete, + renderEditInput, +}) => { + const [deleteTagId, setDeleteTagId] = useState(''); const headers: (keyof Tag)[] = ['name']; - const displayNames: { [key in keyof Tag]?: string } = { name: 'Tag Name' }; - - const handleEdit = (id: string) => { - console.log('Edit tag', id); - }; - - const handleDelete = (id: string) => { - console.log('Delete tag', id); - }; + const displayNames: { [key in keyof Tag]?: string } = { name: 'Име' }; const renderActions = (item: Tag) => ( + // eslint-disable-next-line react/jsx-no-useless-fragment <> -