audio + fix bug
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
hamid zarghami
2026-06-03 14:39:16 +03:30
parent 3bfd5fd1eb
commit 9b72105115
8 changed files with 331 additions and 233 deletions
+35 -20
View File
@@ -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<HTMLDivElement>;
getInputProps: () => React.InputHTMLAttributes<HTMLInputElement>;
isLoading?: boolean;
loadingLabel?: string;
};
const AudioUploadZone: FC<AudioUploadZoneProps> = ({ getRootProps, getInputProps }) => {
const AudioUploadZone: FC<AudioUploadZoneProps> = ({
getRootProps,
getInputProps,
isLoading = false,
loadingLabel = "در حال آپلود...",
}) => {
return (
<div
{...getRootProps()}
className="w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl cursor-pointer hover:bg-gray-50 transition-colors"
className={clx(
"w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl transition-colors",
isLoading
? "pointer-events-none opacity-80"
: "cursor-pointer hover:bg-gray-50"
)}
>
<input {...getInputProps()} />
<Music
size={32}
color="#8C90A3"
variant="Outline"
/>
<div className="text-description text-xs">
فایل صوتی مورد نظر را آپلود کنید
</div>
<div className="text-description text-[10px]">
mp3, wav, ogg, m4a, aac, flac
</div>
<div className="flex items-center gap-2">
<DocumentUpload
size={16}
color="black"
/>
<div className="text-xs">آپلود</div>
</div>
{isLoading ? (
<>
<span className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-black" />
<div className="text-description text-xs">{loadingLabel}</div>
</>
) : (
<>
<Music size={32} color="#8C90A3" variant="Outline" />
<div className="text-description text-xs">
فایل صوتی مورد نظر را آپلود کنید
</div>
<div className="text-description text-[10px]">
mp3, wav, ogg, m4a, aac, flac
</div>
<div className="flex items-center gap-2">
<DocumentUpload size={16} color="black" />
<div className="text-xs">آپلود</div>
</div>
</>
)}
</div>
);
};
+14 -5
View File
@@ -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);
};
@@ -68,7 +68,7 @@ const CatalogueItem: FC<Props> = ({ item, refetch }) => {
</div>
<div className="flex justify-end gap-1 items-center mt-1">
<Link
to={Paths.viewer + `/${item.slug}`}
to={Paths.viewerBySlug + `/${item.slug}`}
onClick={requestViewerFullscreen}
>
<div className="size-6 bg-[#EAECF4] rounded-md flex justify-center items-center">
@@ -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 = () => {
<AudioUploadZone
getRootProps={getRootProps}
getInputProps={getInputProps}
isLoading={isUploading}
/>
{audioObjects.length > 0 && (
+14
View File
@@ -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],
+13 -3
View File
@@ -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<ViewerProps> = ({ 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<PageData[]>([]);
const [documentSettings, setDocumentSettings] = useState<
+112 -93
View File
@@ -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 (
<div className={clx(
'flex min-h-full flex-col overflow-hidden',
isViewerRoute ? 'fixed inset-0 z-50 bg-neutral-100' : 'p-4',
)}>
{!isViewerRoute && shouldRenderSideBar ? <SideBar /> : null}
{!isViewerRoute ? (
<Header hasMainSidebar={hasSidebarSpace} sidebarSize={headerSidebarSize} />
) : null}
<div className={clx(
'flex flex-1 flex-col min-h-0',
!isViewerRoute && 'mt-[68px] xl:mt-[81px]',
!isViewerRoute && shouldRenderSideBar && 'xl:ms-[269px]',
!isViewerRoute && shouldRenderSideBar && hasSubMenu && 'xl:ms-[305px]',
!isViewerRoute && routeHasLocalSidebar && 'xl:mr-[374px]',
)}>
<div className={clx(
'flex-1 flex flex-col w-full min-h-0',
isViewerRoute ? 'h-full overflow-hidden' : 'overflow-auto max-h-[calc(100vh-113px)]',
)}>
<div className='flex-1 h-full flex min-h-0'>
<Routes>
<Route
path={Paths.home}
element={
<PrivateRoute requireAuth={false}>
<Home />
</PrivateRoute>
}
/>
<Route
path={Paths.editor + '/:id'}
element={
<PrivateRoute>
<Editor />
</PrivateRoute>
}
/>
<Route
path={Paths.viewer + '/:id'}
element={
<PrivateRoute requireAuth={false}>
<Viewer />
</PrivateRoute>
}
/>
<Route
path={Paths.catalog.list}
element={
<PrivateRoute>
<CatalogueList />
</PrivateRoute>
}
/>
<Route
path={Paths.designer.request}
element={
<PrivateRoute>
<DesignerRequest />
</PrivateRoute>
}
/>
</Routes>
</div>
</div>
</div>
return (
<div
className={clx(
"flex min-h-full flex-col overflow-hidden",
isViewerRoute ? "fixed inset-0 z-50 bg-neutral-100" : "p-4",
)}
>
{!isViewerRoute && shouldRenderSideBar ? <SideBar /> : null}
{!isViewerRoute ? (
<Header
hasMainSidebar={hasSidebarSpace}
sidebarSize={headerSidebarSize}
/>
) : null}
<div
className={clx(
"flex flex-1 flex-col min-h-0",
!isViewerRoute && "mt-[68px] xl:mt-[81px]",
!isViewerRoute && shouldRenderSideBar && "xl:ms-[269px]",
!isViewerRoute &&
shouldRenderSideBar &&
hasSubMenu &&
"xl:ms-[305px]",
!isViewerRoute && routeHasLocalSidebar && "xl:mr-[374px]",
)}
>
<div
className={clx(
"flex-1 flex flex-col w-full min-h-0",
isViewerRoute
? "h-full overflow-hidden"
: "overflow-auto max-h-[calc(100vh-113px)]",
)}
>
<div className="flex-1 h-full flex min-h-0">
<Routes>
<Route
path={Paths.home}
element={
<PrivateRoute requireAuth={false}>
<Home />
</PrivateRoute>
}
/>
<Route
path={Paths.editor + "/:id"}
element={
<PrivateRoute>
<Editor />
</PrivateRoute>
}
/>
<Route
path={Paths.viewer + "/:id"}
element={
<PrivateRoute requireAuth={false}>
<Viewer />
</PrivateRoute>
}
/>
<Route
path={Paths.viewerBySlug + "/:id"}
element={
<PrivateRoute requireAuth={false}>
<Viewer bySlug={true} />
</PrivateRoute>
}
/>
<Route
path={Paths.catalog.list}
element={
<PrivateRoute>
<CatalogueList />
</PrivateRoute>
}
/>
<Route
path={Paths.designer.request}
element={
<PrivateRoute>
<DesignerRequest />
</PrivateRoute>
}
/>
</Routes>
</div>
</div>
)
}
</div>
</div>
);
};
export default MainRouter
export default MainRouter;
+111 -90
View File
@@ -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<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 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<HeaderProps> = ({ hasMainSidebar = true, sidebarSize = 'default' }) => {
const Header: FC<HeaderProps> = ({
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 (
<div
className={clx(
"fixed z-10 right-4 left-4 top-4 xl:h-16 h-12 flex items-center px-6 bg-white justify-between rounded-[32px]",
hasMainSidebar &&
(hasSubMenu
? sidebarSizeClasses[sidebarSize].withSub
: sidebarSizeClasses[sidebarSize].base),
)}
>
<div className="min-w-[270px] hidden xl:block">
<Input
variant="search"
placeholder={t("header.search")}
onChangeSearchFinal={(value) => setSearch(value)}
/>
</div>
<div
onClick={() => setOpenSidebar(!openSidebar)}
className="xl:hidden block"
>
<HambergerMenu size={24} color="black" />
</div>
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
<div className="flex xl:gap-6 gap-4 items-center">
{isEditorRoute && viewerSlug ? (
<Link
target="_blank"
to={`${Paths.viewer}/${viewerSlug}`}
className="flex items-center"
aria-label={t("header.open_viewer")}
title={t("header.open_viewer")}
// onClick={requestViewerFullscreen}
>
<Eye size={20} color="black" />
</Link>
) : null}
return (
<div className={clx(
'fixed z-10 right-4 left-4 top-4 xl:h-16 h-12 flex items-center px-6 bg-white justify-between rounded-[32px]',
hasMainSidebar && (
hasSubMenu
? sidebarSizeClasses[sidebarSize].withSub
: sidebarSizeClasses[sidebarSize].base
)
)}>
<div className='min-w-[270px] hidden xl:block'>
<Input
variant='search'
placeholder={t('header.search')}
onChangeSearchFinal={(value) => setSearch(value)}
/>
</div>
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
<HambergerMenu size={24} color='black' />
</div>
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
<div className='flex xl:gap-6 gap-4 items-center'>
{isEditorRoute && editorDocumentId ? (
<Link
to={`${Paths.viewer}/${editorDocumentId}`}
className='flex items-center'
aria-label={t('header.open_viewer')}
title={t('header.open_viewer')}
onClick={requestViewerFullscreen}
>
<Eye size={20} color='black' />
</Link>
) : null}
{/* <Link className='xl:hidden' to={Paths.wallet}>
{/* <Link className='xl:hidden' to={Paths.wallet}>
<Wallet className='xl:size-[18px] size-[17px]' color='#da2129' />
</Link> */}
{/* <Notifications /> */}
{/* {
{/* <Notifications /> */}
{/* {
data && (
<Popover className="relative" key={popoverKey}>
<PopoverButton >
@@ -144,9 +165,9 @@ const Header: FC<HeaderProps> = ({ hasMainSidebar = true, sidebarSize = 'default
</Popover>
)
} */}
</div>
</div>
)
}
</div>
</div>
);
};
export default Header
export default Header;