Compare commits

...

2 Commits

Author SHA1 Message Date
hamid zarghami 9b72105115 audio + fix bug
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-03 14:39:16 +03:30
hamid zarghami 3bfd5fd1eb base admin 2026-06-03 12:24:32 +03:30
22 changed files with 664 additions and 195 deletions
+53
View File
@@ -0,0 +1,53 @@
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,
isLoading = false,
loadingLabel = "در حال آپلود...",
}) => {
return (
<div
{...getRootProps()}
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()} />
{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>
);
};
export default AudioUploadZone;
+14 -5
View File
@@ -2,6 +2,7 @@ export const Paths = {
home: "/", home: "/",
editor: "/editor", editor: "/editor",
viewer: "/viewer", viewer: "/viewer",
viewerBySlug: "/catalogue",
catalog: { catalog: {
list: "/catalogue", list: "/catalogue",
}, },
@@ -11,12 +12,20 @@ export const Paths = {
}; };
const viewerPathPrefix = `${Paths.viewer}/`; 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}) */ /** Routes that do not require login (must stay in sync with PrivateRoute requireAuth={false}) */
export const isPublicPath = (pathname: string): boolean => { export const isPublicPath = (pathname: string): boolean => {
return ( return pathname === Paths.home || isViewerPath(pathname);
pathname === Paths.home ||
(pathname.startsWith(viewerPathPrefix) &&
pathname.length > viewerPathPrefix.length)
);
}; };
@@ -68,7 +68,7 @@ const CatalogueItem: FC<Props> = ({ item, refetch }) => {
</div> </div>
<div className="flex justify-end gap-1 items-center mt-1"> <div className="flex justify-end gap-1 items-center mt-1">
<Link <Link
to={Paths.viewer + `/${item.slug}`} to={Paths.viewerBySlug + `/${item.slug}`}
onClick={requestViewerFullscreen} onClick={requestViewerFullscreen}
> >
<div className="size-6 bg-[#EAECF4] rounded-md flex justify-center items-center"> <div className="size-6 bg-[#EAECF4] rounded-md flex justify-center items-center">
@@ -8,6 +8,7 @@ import {
Gallery, Gallery,
Link2, Link2,
VideoSquare, VideoSquare,
Music,
DocumentText, DocumentText,
ArrowLeft, ArrowLeft,
Minus, Minus,
@@ -46,6 +47,8 @@ const LayersList = () => {
return <Link2 size={20} color="black" /> return <Link2 size={20} color="black" />
case 'video': case 'video':
return <VideoSquare size={20} color="black" /> return <VideoSquare size={20} color="black" />
case 'audio':
return <Music size={20} color="black" />
case 'document': case 'document':
return <DocumentText size={20} color="black" /> return <DocumentText size={20} color="black" />
case 'arrow': case 'arrow':
@@ -93,6 +96,8 @@ const LayersList = () => {
return 'لینک' return 'لینک'
case 'video': case 'video':
return 'ویدیو' return 'ویدیو'
case 'audio':
return 'صدا'
case 'document': case 'document':
return 'سند' return 'سند'
case 'arrow': case 'arrow':
@@ -13,6 +13,7 @@ import {
ImageObject, ImageObject,
LinkShape, LinkShape,
VideoShape, VideoShape,
AudioShape,
} from "../tools"; } from "../tools";
import Table from "@/components/Table"; import Table from "@/components/Table";
@@ -221,6 +222,10 @@ const ObjectRenderer = ({
case "video": case "video":
shapeElement = <VideoShape key={obj.id} {...commonProps} />; shapeElement = <VideoShape key={obj.id} {...commonProps} />;
break; break;
case "audio":
shapeElement = <AudioShape key={obj.id} {...commonProps} />;
break;
case "document": case "document":
shapeElement = <ImageObject key={obj.id} {...commonProps} />; shapeElement = <ImageObject key={obj.id} {...commonProps} />;
break; break;
@@ -7,6 +7,7 @@ import {
SizeSettings, SizeSettings,
LinkSettings, LinkSettings,
VideoSettings, VideoSettings,
AudioSettings,
AlignmentSettings, AlignmentSettings,
TransformSettings, TransformSettings,
GridSettings, GridSettings,
@@ -103,7 +104,9 @@ const ObjectSettings = ({
<VideoSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} /> <VideoSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
)} )}
{roundedSelectedObject.type === "audio" && (
<AudioSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
)}
{(roundedSelectedObject.type === "document" || roundedSelectedObject.type === "sticker") && ( {(roundedSelectedObject.type === "document" || roundedSelectedObject.type === "sticker") && (
<SizeSettings <SizeSettings
@@ -2,6 +2,7 @@ import { type ToolType } from "@/pages/editor/store/editorStore";
import { import {
ImageGallery, ImageGallery,
VideoInput, VideoInput,
AudioInput,
LinkInput, LinkInput,
SelectInstruction, SelectInstruction,
RectangleInstruction, RectangleInstruction,
@@ -25,6 +26,10 @@ const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
return <VideoInput />; return <VideoInput />;
} }
if (tool === "audio") {
return <AudioInput />;
}
if (tool === "link") { if (tool === "link") {
return <LinkInput />; return <LinkInput />;
} }
@@ -8,6 +8,7 @@ import {
Sticker, Sticker,
Text, Text,
VideoSquare, VideoSquare,
Music,
Minus, Minus,
Link2, Link2,
} from "iconsax-react"; } from "iconsax-react";
@@ -21,6 +22,7 @@ const tools: Array<{ icon: (color: string, variant?: "Bold" | "Outline" | "Broke
{ icon: (color, variant) => <Gallery size={20} color={color} variant={variant} />, tool: "image", label: "گالری" }, { icon: (color, variant) => <Gallery size={20} color={color} variant={variant} />, tool: "image", label: "گالری" },
{ icon: (color, variant) => <Link2 size={20} color={color} variant={variant} />, tool: "link", label: "لینک" }, { icon: (color, variant) => <Link2 size={20} color={color} variant={variant} />, tool: "link", label: "لینک" },
{ icon: (color, variant) => <VideoSquare size={20} color={color} variant={variant} />, tool: "video", label: "ویدیو" }, { icon: (color, variant) => <VideoSquare size={20} color={color} variant={variant} />, tool: "video", label: "ویدیو" },
{ icon: (color, variant) => <Music size={20} color={color} variant={variant} />, tool: "audio", label: "صدا" },
{ icon: (color, variant) => <ArrowLeft size={20} color={color} variant={variant} />, tool: "arrow", label: "پیکان" }, { icon: (color, variant) => <ArrowLeft size={20} color={color} variant={variant} />, tool: "arrow", label: "پیکان" },
{ icon: (color, variant) => <Minus size={20} color={color} variant={variant} />, tool: "line", label: "خط" }, { icon: (color, variant) => <Minus size={20} color={color} variant={variant} />, tool: "line", label: "خط" },
{ icon: (color, variant) => <Sticker size={20} color={color} variant={variant} />, tool: "sticker", label: "استیکر" }, { icon: (color, variant) => <Sticker size={20} color={color} variant={variant} />, tool: "sticker", label: "استیکر" },
@@ -0,0 +1,106 @@
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";
import { useDropzone } from "react-dropzone";
import AudioUploadZone from "@/components/AudioUploadZone";
export const AUDIO_ACCEPT = {
"audio/*": [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac", ".webm"],
} as const;
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[]) => {
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]
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept: AUDIO_ACCEPT,
multiple: true,
disabled: isUploading,
});
const audioObjects = useMemo(() => {
return objects.filter((obj) => obj.type === "audio");
}, [objects]);
const handleDeleteAudio = (audioId: string) => {
deleteObject(audioId);
};
return (
<div className="space-y-4">
<div className="text-base font-bold">صدا</div>
<AudioUploadZone
getRootProps={getRootProps}
getInputProps={getInputProps}
isLoading={isUploading}
/>
{audioObjects.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-bold">فایلهای صوتی آپلود شده</div>
<div className="flex flex-col gap-2">
{audioObjects.map((audioObject) => (
<div
key={audioObject.id}
className="relative flex items-center gap-2 rounded-lg border border-border bg-gray-50 px-3 py-2"
>
<Music size={20} color="#8C90A3" variant="Bold" />
<span className="flex-1 truncate text-xs text-description">
فایل صوتی
</span>
<button
onClick={() => handleDeleteAudio(audioObject.id)}
className="absolute -right-1 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center hover:bg-gray-100 transition-colors"
>
<CloseCircle size={16} color="red" />
</button>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default AudioInput;
@@ -2,6 +2,7 @@ export { default as ImageGallery } from "./ImageGallery";
export { default as ImageUpload } from "./ImageUpload"; export { default as ImageUpload } from "./ImageUpload";
export { default as DocumentUpload } from "./DocumentUpload"; export { default as DocumentUpload } from "./DocumentUpload";
export { default as VideoInput } from "./VideoInput"; export { default as VideoInput } from "./VideoInput";
export { default as AudioInput } from "./AudioInput";
export { default as LinkInput } from "./LinkInput"; export { default as LinkInput } from "./LinkInput";
export { default as SelectInstruction } from "./SelectInstruction"; export { default as SelectInstruction } from "./SelectInstruction";
export { default as RectangleInstruction } from "./RectangleInstruction"; export { default as RectangleInstruction } from "./RectangleInstruction";
@@ -0,0 +1,29 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
import SizeSettings from "./SizeSettings";
type AudioSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const AudioSettings = ({ selectedObject, onUpdate }: AudioSettingsProps) => {
return (
<>
<Input
label="آدرس فایل صوتی"
value={selectedObject.audioUrl || ""}
onChange={(e) => onUpdate(selectedObject.id, { audioUrl: e.target.value })}
placeholder="https://example.com/audio.mp3"
/>
<SizeSettings
selectedObject={selectedObject}
onUpdate={onUpdate}
defaultWidth={320}
defaultHeight={56}
/>
</>
);
};
export default AudioSettings;
@@ -4,6 +4,7 @@ export { default as LineSettings } from "./LineSettings";
export { default as SizeSettings } from "./SizeSettings"; export { default as SizeSettings } from "./SizeSettings";
export { default as LinkSettings } from "./LinkSettings"; export { default as LinkSettings } from "./LinkSettings";
export { default as VideoSettings } from "./VideoSettings"; export { default as VideoSettings } from "./VideoSettings";
export { default as AudioSettings } from "./AudioSettings";
export { default as AlignmentSettings } from "./AlignmentSettings"; export { default as AlignmentSettings } from "./AlignmentSettings";
export { default as TransformSettings } from "./TransformSettings"; export { default as TransformSettings } from "./TransformSettings";
export { default as GridSettings } from "./GridSettings"; export { default as GridSettings } from "./GridSettings";
@@ -0,0 +1,118 @@
import { useRef } from "react";
import { Rect, Group, Circle, Text as KonvaText } from "react-konva";
import Konva from "konva";
import type { ShapeProps } from "./types";
const AudioShape = ({
obj,
isSelected,
onSelect,
onUpdate,
draggable,
}: ShapeProps) => {
const groupRef = useRef<Konva.Group>(null);
const containerWidth = obj.width || 320;
const containerHeight = obj.height || 56;
const barY = containerHeight * 0.55;
const barHeight = Math.max(4, containerHeight * 0.12);
const playRadius = Math.min(18, containerHeight * 0.35);
const handleGroupClick = (e?: Konva.KonvaEventObject<MouseEvent>) => {
if (e) {
const target = e.target;
if (target.hasName("audioControls") || target.getParent()?.hasName("audioControls")) {
e.cancelBubble = true;
const evt = e.evt;
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
return;
}
}
if (groupRef.current) {
onSelect(obj.id, groupRef.current, e);
}
};
return (
<Group
ref={groupRef}
id={obj.id}
x={obj.x}
y={obj.y}
rotation={obj.rotation || 0}
draggable={draggable}
onClick={handleGroupClick}
onDragEnd={(e) => {
const node = e.target;
onUpdate(obj.id, {
x: node.x(),
y: node.y(),
});
}}
>
<Rect
width={containerWidth}
height={containerHeight}
fill="#f3f4f6"
cornerRadius={8}
stroke={isSelected ? "#3b82f6" : "#d1d5db"}
strokeWidth={isSelected ? 3 : 1}
onClick={handleGroupClick}
/>
<Group name="audioControls" listening={true} onClick={handleGroupClick}>
<Circle
x={playRadius + 12}
y={containerHeight / 2}
radius={playRadius}
fill="#e5e7eb"
listening={true}
/>
<KonvaText
x={playRadius + 12}
y={containerHeight / 2}
text="▶"
fontSize={Math.round(playRadius * 0.9)}
fill="#9ca3af"
fontFamily="Arial"
align="center"
verticalAlign="middle"
offsetX={playRadius * 0.45}
offsetY={playRadius * 0.45}
listening={false}
/>
</Group>
<Rect
x={playRadius * 2 + 28}
y={barY}
width={Math.max(0, containerWidth - playRadius * 2 - 44)}
height={barHeight}
fill="#d1d5db"
cornerRadius={barHeight / 2}
listening={false}
/>
<Rect
x={playRadius * 2 + 28}
y={barY}
width={Math.max(0, (containerWidth - playRadius * 2 - 44) * 0.35)}
height={barHeight}
fill="#9ca3af"
cornerRadius={barHeight / 2}
listening={false}
/>
<KonvaText
x={playRadius * 2 + 28}
y={containerHeight * 0.18}
text="فایل صوتی (پیش‌نمایش)"
fontSize={11}
fill="#6b7280"
fontFamily="Arial"
listening={false}
/>
</Group>
);
};
export default AudioShape;
@@ -8,4 +8,5 @@ export { default as ArrowShape } from "./ArrowShape";
export { default as ImageObject } from "./ImageObject"; export { default as ImageObject } from "./ImageObject";
export { default as LinkShape } from "./LinkShape"; export { default as LinkShape } from "./LinkShape";
export { default as VideoShape } from "./VideoShape"; export { default as VideoShape } from "./VideoShape";
export { default as AudioShape } from "./AudioShape";
export type { ShapeProps, TextShapeProps } from "./types"; export type { ShapeProps, TextShapeProps } from "./types";
@@ -22,6 +22,7 @@ export type ToolType =
| "sticker" | "sticker"
| "document" | "document"
| "video" | "video"
| "audio"
| "link" | "link"
| "grid" | "grid"
| "group"; | "group";
@@ -68,6 +69,7 @@ export type EditorObject = {
opacity?: number; opacity?: number;
imageUrl?: string; imageUrl?: string;
videoUrl?: string; videoUrl?: string;
audioUrl?: string;
linkUrl?: string; linkUrl?: string;
rotation?: number; rotation?: number;
scaleX?: number; scaleX?: number;
+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) => { export const useGetCatalogById = (id: string) => {
return useQuery({ return useQuery({
queryKey: ["catalog", id], queryKey: ["catalog", id],
@@ -31,6 +36,15 @@ export const useGetCatalogById = (id: string) => {
staleTime: 5 * 60 * 1000, 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) => { export const useGetCatalogBySlug = (slug: string) => {
return useQuery({ return useQuery({
queryKey: ["catalog", slug], queryKey: ["catalog", slug],
+13 -3
View File
@@ -1,6 +1,9 @@
import { usePageTitle } from "@/hooks/usePageTitle"; import { usePageTitle } from "@/hooks/usePageTitle";
import type { DocumentSettings } from "@/pages/editor/store/editorStore"; 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 { type FC, useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import BookViewer from "./components/BookViewer"; import BookViewer from "./components/BookViewer";
@@ -10,9 +13,16 @@ import { useInitialPagesPreload } from "./hooks/useInitialPagesPreload";
import type { PageData } from "./types"; import type { PageData } from "./types";
import { transformViewerDataToPages } from "./utils/dataTransformer"; import { transformViewerDataToPages } from "./utils/dataTransformer";
const Viewer: FC = () => { type ViewerProps = {
bySlug?: boolean;
};
const Viewer: FC<ViewerProps> = ({ bySlug = false }) => {
const { id } = useParams<{ id: string }>(); 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}`); usePageTitle(`کاتالوگ ${data?.data?.name}`);
const [pages, setPages] = useState<PageData[]>([]); const [pages, setPages] = useState<PageData[]>([]);
const [documentSettings, setDocumentSettings] = useState< const [documentSettings, setDocumentSettings] = useState<
+30
View File
@@ -226,6 +226,36 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
/> />
); );
case 'audio':
return (
<audio
key={obj.id || index}
src={obj.audioUrl || ''}
controls
preload="metadata"
style={withEntrance(
{
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
zIndex: index,
pointerEvents: 'auto',
},
obj,
index,
)}
onClick={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
onTouchStart={(e) => {
e.stopPropagation();
}}
/>
);
case 'link': { case 'link': {
const isInternalLink = obj.linkUrl?.startsWith('page://'); const isInternalLink = obj.linkUrl?.startsWith('page://');
const linkStyle = { const linkStyle = {
@@ -38,6 +38,7 @@ type ViewerDataPage = {
borderRadius?: number; borderRadius?: number;
imageUrl?: string; imageUrl?: string;
videoUrl?: string; videoUrl?: string;
audioUrl?: string;
linkUrl?: string; linkUrl?: string;
rotation?: number; rotation?: number;
opacity?: number; opacity?: number;
@@ -129,6 +130,10 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
baseObject.videoUrl = obj.videoUrl; baseObject.videoUrl = obj.videoUrl;
} }
if (obj.type === "audio" && obj.audioUrl) {
baseObject.audioUrl = obj.audioUrl;
}
if (obj.type === "link" && obj.linkUrl) { if (obj.type === "link" && obj.linkUrl) {
baseObject.linkUrl = obj.linkUrl; baseObject.linkUrl = obj.linkUrl;
} }
+32 -2
View File
@@ -5,7 +5,7 @@ export const INITIAL_PAGES_PRELOAD_COUNT = 5;
type MediaAsset = { type MediaAsset = {
url: string; url: string;
kind: 'image' | 'video'; kind: 'image' | 'video' | 'audio';
}; };
const MEDIA_LOAD_TIMEOUT_MS = 15_000; const MEDIA_LOAD_TIMEOUT_MS = 15_000;
@@ -20,6 +20,11 @@ function addVideoUrl(urls: Map<string, MediaAsset>, url?: string) {
urls.set(url, { url, kind: 'video' }); urls.set(url, { url, kind: 'video' });
} }
function addAudioUrl(urls: Map<string, MediaAsset>, url?: string) {
if (!url) return;
urls.set(url, { url, kind: 'audio' });
}
export function extractPageMediaAssets( export function extractPageMediaAssets(
pages: PageData[], pages: PageData[],
documentSettings?: Partial<DocumentSettings>, documentSettings?: Partial<DocumentSettings>,
@@ -38,6 +43,8 @@ export function extractPageMediaAssets(
addImageUrl(assets, element.imageUrl); addImageUrl(assets, element.imageUrl);
} else if (element.type === 'video') { } else if (element.type === 'video') {
addVideoUrl(assets, element.videoUrl); addVideoUrl(assets, element.videoUrl);
} else if (element.type === 'audio') {
addAudioUrl(assets, element.audioUrl);
} }
} }
} }
@@ -83,6 +90,25 @@ function preloadVideo(url: string): Promise<void> {
}); });
} }
function preloadAudio(url: string): Promise<void> {
return new Promise((resolve) => {
const audio = document.createElement('audio');
audio.preload = 'auto';
const finish = () => {
audio.onloadeddata = null;
audio.onerror = null;
audio.removeAttribute('src');
audio.load();
resolve();
};
audio.onloadeddata = finish;
audio.onerror = finish;
audio.src = url;
});
}
function withTimeout(promise: Promise<void>, ms: number): Promise<void> { function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
return Promise.race([ return Promise.race([
promise, promise,
@@ -94,7 +120,11 @@ function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
export function preloadMediaAsset(asset: MediaAsset): Promise<void> { export function preloadMediaAsset(asset: MediaAsset): Promise<void> {
const load = const load =
asset.kind === 'video' ? preloadVideo(asset.url) : preloadImage(asset.url); asset.kind === 'video'
? preloadVideo(asset.url)
: asset.kind === 'audio'
? preloadAudio(asset.url)
: preloadImage(asset.url);
return withTimeout(load, MEDIA_LOAD_TIMEOUT_MS); return withTimeout(load, MEDIA_LOAD_TIMEOUT_MS);
} }
+112 -93
View File
@@ -1,98 +1,117 @@
import SideBar from '@/shared/SideBar' import { isViewerPath, Paths } from "@/config/Paths";
import Header from '@/shared/Header' import { clx } from "@/helpers/utils";
import { Route, Routes, useLocation } from 'react-router-dom' import CatalogueList from "@/pages/catalogue/List";
import { Paths } from '@/config/Paths' import DesignerRequest from "@/pages/designer/Request";
import PrivateRoute from '@/router/PrivateRoute' import Editor from "@/pages/editor/Editor";
import { clx } from '@/helpers/utils' import Home from "@/pages/home/Home";
import { useSharedStore } from '@/shared/store/sharedStore' import Viewer from "@/pages/viewer/Viewer";
import Home from '@/pages/home/Home' import PrivateRoute from "@/router/PrivateRoute";
import Editor from '@/pages/editor/Editor' import Header from "@/shared/Header";
import Viewer from '@/pages/viewer/Viewer' import SideBar from "@/shared/SideBar";
import CatalogueList from '@/pages/catalogue/List' import { useSharedStore } from "@/shared/store/sharedStore";
import DesignerRequest from '@/pages/designer/Request' import { Route, Routes, useLocation } from "react-router-dom";
const MainRouter = () => { const MainRouter = () => {
const { hasSubMenu } = useSharedStore() const { hasSubMenu } = useSharedStore();
const location = useLocation() const location = useLocation();
const viewerPathPrefix = `${Paths.viewer}/` const isViewerRoute = isViewerPath(location.pathname);
const isViewerRoute = const hiddenSideBarRoutes = [Paths.editor];
location.pathname.startsWith(viewerPathPrefix) && const shouldRenderSideBar =
location.pathname.length > viewerPathPrefix.length !hiddenSideBarRoutes.includes(location.pathname) && !isViewerRoute;
const hiddenSideBarRoutes = [Paths.editor] const routeHasLocalSidebar = location.pathname.includes("editor");
const shouldRenderSideBar = const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar;
!hiddenSideBarRoutes.includes(location.pathname) && !isViewerRoute const headerSidebarSize = routeHasLocalSidebar ? "wide" : "default";
const routeHasLocalSidebar = location.pathname.includes('editor')
const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar
const headerSidebarSize = routeHasLocalSidebar ? 'wide' : 'default'
return ( return (
<div className={clx( <div
'flex min-h-full flex-col overflow-hidden', className={clx(
isViewerRoute ? 'fixed inset-0 z-50 bg-neutral-100' : 'p-4', "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} /> {!isViewerRoute && shouldRenderSideBar ? <SideBar /> : null}
) : null} {!isViewerRoute ? (
<div className={clx( <Header
'flex flex-1 flex-col min-h-0', hasMainSidebar={hasSidebarSpace}
!isViewerRoute && 'mt-[68px] xl:mt-[81px]', sidebarSize={headerSidebarSize}
!isViewerRoute && shouldRenderSideBar && 'xl:ms-[269px]', />
!isViewerRoute && shouldRenderSideBar && hasSubMenu && 'xl:ms-[305px]', ) : null}
!isViewerRoute && routeHasLocalSidebar && 'xl:mr-[374px]', <div
)}> className={clx(
<div className={clx( "flex flex-1 flex-col min-h-0",
'flex-1 flex flex-col w-full min-h-0', !isViewerRoute && "mt-[68px] xl:mt-[81px]",
isViewerRoute ? 'h-full overflow-hidden' : 'overflow-auto max-h-[calc(100vh-113px)]', !isViewerRoute && shouldRenderSideBar && "xl:ms-[269px]",
)}> !isViewerRoute &&
<div className='flex-1 h-full flex min-h-0'> shouldRenderSideBar &&
<Routes> hasSubMenu &&
<Route "xl:ms-[305px]",
path={Paths.home} !isViewerRoute && routeHasLocalSidebar && "xl:mr-[374px]",
element={ )}
<PrivateRoute requireAuth={false}> >
<Home /> <div
</PrivateRoute> className={clx(
} "flex-1 flex flex-col w-full min-h-0",
/> isViewerRoute
<Route ? "h-full overflow-hidden"
path={Paths.editor + '/:id'} : "overflow-auto max-h-[calc(100vh-113px)]",
element={ )}
<PrivateRoute> >
<Editor /> <div className="flex-1 h-full flex min-h-0">
</PrivateRoute> <Routes>
} <Route
/> path={Paths.home}
<Route element={
path={Paths.viewer + '/:id'} <PrivateRoute requireAuth={false}>
element={ <Home />
<PrivateRoute requireAuth={false}> </PrivateRoute>
<Viewer /> }
</PrivateRoute> />
} <Route
/> path={Paths.editor + "/:id"}
<Route element={
path={Paths.catalog.list} <PrivateRoute>
element={ <Editor />
<PrivateRoute> </PrivateRoute>
<CatalogueList /> }
</PrivateRoute> />
} <Route
/> path={Paths.viewer + "/:id"}
<Route element={
path={Paths.designer.request} <PrivateRoute requireAuth={false}>
element={ <Viewer />
<PrivateRoute> </PrivateRoute>
<DesignerRequest /> }
</PrivateRoute> />
} <Route
/> path={Paths.viewerBySlug + "/:id"}
</Routes> element={
</div> <PrivateRoute requireAuth={false}>
</div> <Viewer bySlug={true} />
</div> </PrivateRoute>
}
/>
<Route
path={Paths.catalog.list}
element={
<PrivateRoute>
<CatalogueList />
</PrivateRoute>
}
/>
<Route
path={Paths.designer.request}
element={
<PrivateRoute>
<DesignerRequest />
</PrivateRoute>
}
/>
</Routes>
</div>
</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 { ArrowDown2, CloseCircle, HambergerMenu, Logout, Wallet } from 'iconsax-react'
// import AvatarImage from '../assets/images/avatar_image.png' // import AvatarImage from '../assets/images/avatar_image.png'
import { useTranslation } from 'react-i18next' import { Paths } from "@/config/Paths";
import { Link, useLocation } from 'react-router-dom' import { clx } from "@/helpers/utils";
import { Paths } from '@/config/Paths' import {
import { useSharedStore } from '@/shared/store/sharedStore' isCatalogMongoId,
import { Eye, HambergerMenu } from 'iconsax-react' useGetCatalogByIdOrSlug,
import { clx } from '@/helpers/utils' } from "@/pages/home/hooks/useHomeData";
import { requestViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen' 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 = { type HeaderProps = {
hasMainSidebar?: boolean hasMainSidebar?: boolean;
sidebarSize?: SidebarSize sidebarSize?: SidebarSize;
} };
const sidebarSizeClasses: Record<SidebarSize, { base: string; withSub: string }> = { const sidebarSizeClasses: Record<
default: { SidebarSize,
base: 'xl:right-[285px] xl:w-[calc(100%-305px)]', { base: string; withSub: string }
withSub: 'xl:right-[320px] xl:w-[calc(100%-340px)]', > = {
}, default: {
wide: { base: "xl:right-[285px] xl:w-[calc(100%-305px)]",
base: 'xl:right-[390px] xl:w-[calc(100%-406px)]', withSub: "xl:right-[320px] xl:w-[calc(100%-340px)]",
withSub: 'xl:right-[425px] xl:w-[calc(100%-441px)]', },
}, 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(); // useEffect(() => {
// const [popoverKey, setPopoverKey] = useState(0); // setPopoverKey((prevKey) => prevKey + 1);
const { t } = useTranslation('global') // }, [location.pathname]);
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(() => { // const handleLogout = () => {
// setPopoverKey((prevKey) => prevKey + 1); // removeToken()
// }, [location.pathname]); // removeRefreshToken()
// window.location.href = Pages.auth.login
// }
// const handleLogout = () => { useEffect(() => {
// removeToken() setSearch("");
// removeRefreshToken() // eslint-disable-next-line react-hooks/exhaustive-deps
// window.location.href = Pages.auth.login }, [location.pathname]);
// }
useEffect(() => { return (
setSearch('') <div
// eslint-disable-next-line react-hooks/exhaustive-deps className={clx(
}, [location.pathname]) "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 ( {/* <Link className='xl:hidden' to={Paths.wallet}>
<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}>
<Wallet className='xl:size-[18px] size-[17px]' color='#da2129' /> <Wallet className='xl:size-[18px] size-[17px]' color='#da2129' />
</Link> */} </Link> */}
{/* <Notifications /> */} {/* <Notifications /> */}
{/* { {/* {
data && ( data && (
<Popover className="relative" key={popoverKey}> <Popover className="relative" key={popoverKey}>
<PopoverButton > <PopoverButton >
@@ -144,9 +165,9 @@ const Header: FC<HeaderProps> = ({ hasMainSidebar = true, sidebarSize = 'default
</Popover> </Popover>
) )
} */} } */}
</div> </div>
</div> </div>
) );
} };
export default Header export default Header;