diff --git a/src/components/AudioUploadZone.tsx b/src/components/AudioUploadZone.tsx index b0c408c..a05f9e4 100644 --- a/src/components/AudioUploadZone.tsx +++ b/src/components/AudioUploadZone.tsx @@ -1,36 +1,51 @@ import { type FC } from "react"; import { DocumentUpload, Music } from "iconsax-react"; +import { clx } from "@/helpers/utils"; type AudioUploadZoneProps = { getRootProps: () => React.HTMLAttributes; getInputProps: () => React.InputHTMLAttributes; + isLoading?: boolean; + loadingLabel?: string; }; -const AudioUploadZone: FC = ({ getRootProps, getInputProps }) => { +const AudioUploadZone: FC = ({ + getRootProps, + getInputProps, + isLoading = false, + loadingLabel = "در حال آپلود...", +}) => { return (
- -
- فایل صوتی مورد نظر را آپلود کنید -
-
- mp3, wav, ogg, m4a, aac, flac -
-
- -
آپلود
-
+ {isLoading ? ( + <> + +
{loadingLabel}
+ + ) : ( + <> + +
+ فایل صوتی مورد نظر را آپلود کنید +
+
+ mp3, wav, ogg, m4a, aac, flac +
+
+ +
آپلود
+
+ + )}
); }; diff --git a/src/config/Paths.ts b/src/config/Paths.ts index 92e8207..992b1f4 100644 --- a/src/config/Paths.ts +++ b/src/config/Paths.ts @@ -2,6 +2,7 @@ export const Paths = { home: "/", editor: "/editor", viewer: "/viewer", + viewerBySlug: "/catalogue", catalog: { list: "/catalogue", }, @@ -11,12 +12,20 @@ export const Paths = { }; const viewerPathPrefix = `${Paths.viewer}/`; +const viewerBySlugPathPrefix = `${Paths.viewerBySlug}/`; + +/** Full-screen viewer: /viewer/:id or /catalogue/:slug (not the list at /catalogue). */ +export const isViewerPath = (pathname: string): boolean => { + const isViewerById = + pathname.startsWith(viewerPathPrefix) && + pathname.length > viewerPathPrefix.length; + const isViewerBySlug = + pathname.startsWith(viewerBySlugPathPrefix) && + pathname.length > viewerBySlugPathPrefix.length; + return isViewerById || isViewerBySlug; +}; /** Routes that do not require login (must stay in sync with PrivateRoute requireAuth={false}) */ export const isPublicPath = (pathname: string): boolean => { - return ( - pathname === Paths.home || - (pathname.startsWith(viewerPathPrefix) && - pathname.length > viewerPathPrefix.length) - ); + return pathname === Paths.home || isViewerPath(pathname); }; diff --git a/src/pages/catalogue/components/CatalogueItem.tsx b/src/pages/catalogue/components/CatalogueItem.tsx index 3b0270c..61b0f58 100644 --- a/src/pages/catalogue/components/CatalogueItem.tsx +++ b/src/pages/catalogue/components/CatalogueItem.tsx @@ -68,7 +68,7 @@ const CatalogueItem: FC = ({ item, refetch }) => {
diff --git a/src/pages/editor/components/sidebar/instructions/AudioInput.tsx b/src/pages/editor/components/sidebar/instructions/AudioInput.tsx index f06c061..bc69a4a 100644 --- a/src/pages/editor/components/sidebar/instructions/AudioInput.tsx +++ b/src/pages/editor/components/sidebar/instructions/AudioInput.tsx @@ -1,4 +1,4 @@ -import { type FC, useCallback, useMemo } from "react"; +import { type FC, useCallback, useMemo, useState } from "react"; import { useEditorStore, type ToolType } from "@/pages/editor/store/editorStore"; import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData"; import { CloseCircle, Music } from "iconsax-react"; @@ -13,30 +13,38 @@ const AudioInput: FC = () => { const { objects, addObject, setSelectedObjectId, setTool, deleteObject } = useEditorStore(); const { mutateAsync: uploadFile } = useSingleUpload(); + const [isUploading, setIsUploading] = useState(false); const onDrop = useCallback( async (acceptedFiles: File[]) => { - for (const file of acceptedFiles) { - try { - const result = await uploadFile(file); - const audioUrl = result?.data?.url; - if (!audioUrl) continue; - const audioId = `audio-${Date.now()}-${Math.random()}`; - const newAudio = { - id: audioId, - type: "audio" as ToolType, - x: 100, - y: 100, - width: 320, - height: 56, - audioUrl, - }; - addObject(newAudio); - setSelectedObjectId(newAudio.id); - setTool("select"); - } catch { - // TODO: show error toast + if (acceptedFiles.length === 0) return; + + setIsUploading(true); + try { + for (const file of acceptedFiles) { + try { + const result = await uploadFile(file); + const audioUrl = result?.data?.url; + if (!audioUrl) continue; + const audioId = `audio-${Date.now()}-${Math.random()}`; + const newAudio = { + id: audioId, + type: "audio" as ToolType, + x: 100, + y: 100, + width: 320, + height: 56, + audioUrl, + }; + addObject(newAudio); + setSelectedObjectId(newAudio.id); + setTool("select"); + } catch { + // TODO: show error toast + } } + } finally { + setIsUploading(false); } }, [addObject, setSelectedObjectId, setTool, uploadFile] @@ -46,6 +54,7 @@ const AudioInput: FC = () => { onDrop, accept: AUDIO_ACCEPT, multiple: true, + disabled: isUploading, }); const audioObjects = useMemo(() => { @@ -63,6 +72,7 @@ const AudioInput: FC = () => { {audioObjects.length > 0 && ( diff --git a/src/pages/home/hooks/useHomeData.ts b/src/pages/home/hooks/useHomeData.ts index 8e1cb71..ac9c2d0 100644 --- a/src/pages/home/hooks/useHomeData.ts +++ b/src/pages/home/hooks/useHomeData.ts @@ -21,6 +21,11 @@ export const useGetCatalog = () => { }); }; +const catalogMongoIdPattern = /^[a-f\d]{24}$/i; + +export const isCatalogMongoId = (value: string) => + catalogMongoIdPattern.test(value); + export const useGetCatalogById = (id: string) => { return useQuery({ queryKey: ["catalog", id], @@ -31,6 +36,15 @@ export const useGetCatalogById = (id: string) => { staleTime: 5 * 60 * 1000, }); }; + +/** Route param may be Mongo id (editor/admin) or slug (public viewer-style URLs). */ +export const useGetCatalogByIdOrSlug = (param: string) => { + const isId = isCatalogMongoId(param); + const byId = useGetCatalogById(isId ? param : ""); + const bySlug = useGetCatalogBySlug(isId ? "" : param); + return isId ? byId : bySlug; +}; + export const useGetCatalogBySlug = (slug: string) => { return useQuery({ queryKey: ["catalog", slug], diff --git a/src/pages/viewer/Viewer.tsx b/src/pages/viewer/Viewer.tsx index 8f9ad1f..e5cec42 100644 --- a/src/pages/viewer/Viewer.tsx +++ b/src/pages/viewer/Viewer.tsx @@ -1,6 +1,9 @@ import { usePageTitle } from "@/hooks/usePageTitle"; import type { DocumentSettings } from "@/pages/editor/store/editorStore"; -import { useGetCatalogBySlug } from "@/pages/home/hooks/useHomeData"; +import { + useGetCatalogById, + useGetCatalogBySlug, +} from "@/pages/home/hooks/useHomeData"; import { type FC, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import BookViewer from "./components/BookViewer"; @@ -10,9 +13,16 @@ import { useInitialPagesPreload } from "./hooks/useInitialPagesPreload"; import type { PageData } from "./types"; import { transformViewerDataToPages } from "./utils/dataTransformer"; -const Viewer: FC = () => { +type ViewerProps = { + bySlug?: boolean; +}; +const Viewer: FC = ({ bySlug = false }) => { const { id } = useParams<{ id: string }>(); - const { data, isLoading, error: queryError } = useGetCatalogBySlug(id ?? ""); + const { + data, + isLoading, + error: queryError, + } = bySlug ? useGetCatalogBySlug(id ?? "") : useGetCatalogById(id ?? ""); usePageTitle(`کاتالوگ ${data?.data?.name}`); const [pages, setPages] = useState([]); const [documentSettings, setDocumentSettings] = useState< diff --git a/src/router/MainRouter.tsx b/src/router/MainRouter.tsx index 14e8d4b..9312ba0 100644 --- a/src/router/MainRouter.tsx +++ b/src/router/MainRouter.tsx @@ -1,98 +1,117 @@ -import SideBar from '@/shared/SideBar' -import Header from '@/shared/Header' -import { Route, Routes, useLocation } from 'react-router-dom' -import { Paths } from '@/config/Paths' -import PrivateRoute from '@/router/PrivateRoute' -import { clx } from '@/helpers/utils' -import { useSharedStore } from '@/shared/store/sharedStore' -import Home from '@/pages/home/Home' -import Editor from '@/pages/editor/Editor' -import Viewer from '@/pages/viewer/Viewer' -import CatalogueList from '@/pages/catalogue/List' -import DesignerRequest from '@/pages/designer/Request' +import { isViewerPath, Paths } from "@/config/Paths"; +import { clx } from "@/helpers/utils"; +import CatalogueList from "@/pages/catalogue/List"; +import DesignerRequest from "@/pages/designer/Request"; +import Editor from "@/pages/editor/Editor"; +import Home from "@/pages/home/Home"; +import Viewer from "@/pages/viewer/Viewer"; +import PrivateRoute from "@/router/PrivateRoute"; +import Header from "@/shared/Header"; +import SideBar from "@/shared/SideBar"; +import { useSharedStore } from "@/shared/store/sharedStore"; +import { Route, Routes, useLocation } from "react-router-dom"; const MainRouter = () => { - const { hasSubMenu } = useSharedStore() - const location = useLocation() - const viewerPathPrefix = `${Paths.viewer}/` - const isViewerRoute = - location.pathname.startsWith(viewerPathPrefix) && - location.pathname.length > viewerPathPrefix.length - const hiddenSideBarRoutes = [Paths.editor] - const shouldRenderSideBar = - !hiddenSideBarRoutes.includes(location.pathname) && !isViewerRoute - const routeHasLocalSidebar = location.pathname.includes('editor') - const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar - const headerSidebarSize = routeHasLocalSidebar ? 'wide' : 'default' + const { hasSubMenu } = useSharedStore(); + const location = useLocation(); + const isViewerRoute = isViewerPath(location.pathname); + const hiddenSideBarRoutes = [Paths.editor]; + const shouldRenderSideBar = + !hiddenSideBarRoutes.includes(location.pathname) && !isViewerRoute; + const routeHasLocalSidebar = location.pathname.includes("editor"); + const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar; + const headerSidebarSize = routeHasLocalSidebar ? "wide" : "default"; - return ( -
- {!isViewerRoute && shouldRenderSideBar ? : null} - {!isViewerRoute ? ( -
- ) : null} -
-
-
- - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - -
-
-
+ return ( +
+ {!isViewerRoute && shouldRenderSideBar ? : null} + {!isViewerRoute ? ( +
+ ) : null} +
+
+
+ + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + +
- ) -} +
+
+ ); +}; -export default MainRouter \ No newline at end of file +export default MainRouter; diff --git a/src/shared/Header.tsx b/src/shared/Header.tsx index 93d3c65..4403963 100644 --- a/src/shared/Header.tsx +++ b/src/shared/Header.tsx @@ -1,102 +1,123 @@ -import { type FC, useEffect } from 'react' -import Input from '@/components/Input' +import Input from "@/components/Input"; +import { type FC, useEffect } from "react"; // import { ArrowDown2, CloseCircle, HambergerMenu, Logout, Wallet } from 'iconsax-react' // import AvatarImage from '../assets/images/avatar_image.png' -import { useTranslation } from 'react-i18next' -import { Link, useLocation } from 'react-router-dom' -import { Paths } from '@/config/Paths' -import { useSharedStore } from '@/shared/store/sharedStore' -import { Eye, HambergerMenu } from 'iconsax-react' -import { clx } from '@/helpers/utils' -import { requestViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen' +import { Paths } from "@/config/Paths"; +import { clx } from "@/helpers/utils"; +import { + isCatalogMongoId, + useGetCatalogByIdOrSlug, +} from "@/pages/home/hooks/useHomeData"; +import { useSharedStore } from "@/shared/store/sharedStore"; +import { Eye, HambergerMenu } from "iconsax-react"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router-dom"; -type SidebarSize = 'default' | 'wide' +type SidebarSize = "default" | "wide"; type HeaderProps = { - hasMainSidebar?: boolean - sidebarSize?: SidebarSize -} + hasMainSidebar?: boolean; + sidebarSize?: SidebarSize; +}; -const sidebarSizeClasses: Record = { - default: { - base: 'xl:right-[285px] xl:w-[calc(100%-305px)]', - withSub: 'xl:right-[320px] xl:w-[calc(100%-340px)]', - }, - wide: { - base: 'xl:right-[390px] xl:w-[calc(100%-406px)]', - withSub: 'xl:right-[425px] xl:w-[calc(100%-441px)]', - }, -} +const sidebarSizeClasses: Record< + SidebarSize, + { base: string; withSub: string } +> = { + default: { + base: "xl:right-[285px] xl:w-[calc(100%-305px)]", + withSub: "xl:right-[320px] xl:w-[calc(100%-340px)]", + }, + wide: { + base: "xl:right-[390px] xl:w-[calc(100%-406px)]", + withSub: "xl:right-[425px] xl:w-[calc(100%-441px)]", + }, +}; -const Header: FC = ({ hasMainSidebar = true, sidebarSize = 'default' }) => { +const Header: FC = ({ + hasMainSidebar = true, + sidebarSize = "default", +}) => { + // const location = useLocation(); + // const [popoverKey, setPopoverKey] = useState(0); + const { t } = useTranslation("global"); + const { setOpenSidebar, openSidebar, hasSubMenu, setSearch } = + useSharedStore(); + const location = useLocation(); + const editorPathPrefix = `${Paths.editor}/`; + const isEditorRoute = + location.pathname.startsWith(editorPathPrefix) && + location.pathname.length > editorPathPrefix.length; + const editorRouteParam = isEditorRoute + ? (location.pathname.slice(editorPathPrefix.length).split("/")[0] ?? "") + : ""; + const { data: catalogData } = useGetCatalogByIdOrSlug( + isEditorRoute && editorRouteParam ? editorRouteParam : "", + ); + const viewerSlug = + catalogData?.data?.slug ?? + (editorRouteParam && !isCatalogMongoId(editorRouteParam) + ? editorRouteParam + : ""); - // const location = useLocation(); - // const [popoverKey, setPopoverKey] = useState(0); - const { t } = useTranslation('global') - const { setOpenSidebar, openSidebar, hasSubMenu, setSearch } = useSharedStore() - const location = useLocation() - const editorPathPrefix = `${Paths.editor}/` - const isEditorRoute = - location.pathname.startsWith(editorPathPrefix) && - location.pathname.length > editorPathPrefix.length - const editorDocumentId = isEditorRoute - ? (location.pathname.slice(editorPathPrefix.length).split('/')[0] ?? '') - : '' + // useEffect(() => { + // setPopoverKey((prevKey) => prevKey + 1); + // }, [location.pathname]); - // useEffect(() => { - // setPopoverKey((prevKey) => prevKey + 1); - // }, [location.pathname]); + // const handleLogout = () => { + // removeToken() + // removeRefreshToken() + // window.location.href = Pages.auth.login + // } - // const handleLogout = () => { - // removeToken() - // removeRefreshToken() - // window.location.href = Pages.auth.login - // } + useEffect(() => { + setSearch(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]); - useEffect(() => { - setSearch('') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location.pathname]) + return ( +
+
+ setSearch(value)} + /> +
+
setOpenSidebar(!openSidebar)} + className="xl:hidden block" + > + +
+ {/* */} +
+ {isEditorRoute && viewerSlug ? ( + + + + ) : null} - return ( -
- -
- setSearch(value)} - /> -
-
setOpenSidebar(!openSidebar)} className='xl:hidden block'> - -
- {/* */} -
- {isEditorRoute && editorDocumentId ? ( - - - - ) : null} - - {/* + {/* */} - {/* */} - {/* { + {/* */} + {/* { data && ( @@ -144,9 +165,9 @@ const Header: FC = ({ hasMainSidebar = true, sidebarSize = 'default ) } */} -
-
- ) -} +
+
+ ); +}; -export default Header \ No newline at end of file +export default Header;