base admin
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
Gallery,
|
||||
Link2,
|
||||
VideoSquare,
|
||||
Music,
|
||||
DocumentText,
|
||||
ArrowLeft,
|
||||
Minus,
|
||||
@@ -46,6 +47,8 @@ const LayersList = () => {
|
||||
return <Link2 size={20} color="black" />
|
||||
case 'video':
|
||||
return <VideoSquare size={20} color="black" />
|
||||
case 'audio':
|
||||
return <Music size={20} color="black" />
|
||||
case 'document':
|
||||
return <DocumentText size={20} color="black" />
|
||||
case 'arrow':
|
||||
@@ -93,6 +96,8 @@ const LayersList = () => {
|
||||
return 'لینک'
|
||||
case 'video':
|
||||
return 'ویدیو'
|
||||
case 'audio':
|
||||
return 'صدا'
|
||||
case 'document':
|
||||
return 'سند'
|
||||
case 'arrow':
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ImageObject,
|
||||
LinkShape,
|
||||
VideoShape,
|
||||
AudioShape,
|
||||
} from "../tools";
|
||||
import Table from "@/components/Table";
|
||||
|
||||
@@ -221,6 +222,10 @@ const ObjectRenderer = ({
|
||||
case "video":
|
||||
shapeElement = <VideoShape key={obj.id} {...commonProps} />;
|
||||
break;
|
||||
|
||||
case "audio":
|
||||
shapeElement = <AudioShape key={obj.id} {...commonProps} />;
|
||||
break;
|
||||
case "document":
|
||||
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
||||
break;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SizeSettings,
|
||||
LinkSettings,
|
||||
VideoSettings,
|
||||
AudioSettings,
|
||||
AlignmentSettings,
|
||||
TransformSettings,
|
||||
GridSettings,
|
||||
@@ -103,7 +104,9 @@ const ObjectSettings = ({
|
||||
<VideoSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||
)}
|
||||
|
||||
|
||||
{roundedSelectedObject.type === "audio" && (
|
||||
<AudioSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||
)}
|
||||
|
||||
{(roundedSelectedObject.type === "document" || roundedSelectedObject.type === "sticker") && (
|
||||
<SizeSettings
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ToolType } from "@/pages/editor/store/editorStore";
|
||||
import {
|
||||
ImageGallery,
|
||||
VideoInput,
|
||||
AudioInput,
|
||||
LinkInput,
|
||||
SelectInstruction,
|
||||
RectangleInstruction,
|
||||
@@ -25,6 +26,10 @@ const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
|
||||
return <VideoInput />;
|
||||
}
|
||||
|
||||
if (tool === "audio") {
|
||||
return <AudioInput />;
|
||||
}
|
||||
|
||||
if (tool === "link") {
|
||||
return <LinkInput />;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Sticker,
|
||||
Text,
|
||||
VideoSquare,
|
||||
Music,
|
||||
Minus,
|
||||
Link2,
|
||||
} 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) => <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) => <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) => <Minus size={20} color={color} variant={variant} />, tool: "line", label: "خط" },
|
||||
{ icon: (color, variant) => <Sticker size={20} color={color} variant={variant} />, tool: "sticker", label: "استیکر" },
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type FC, useCallback, useMemo } 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 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
|
||||
}
|
||||
}
|
||||
},
|
||||
[addObject, setSelectedObjectId, setTool, uploadFile]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
accept: AUDIO_ACCEPT,
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
{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 DocumentUpload } from "./DocumentUpload";
|
||||
export { default as VideoInput } from "./VideoInput";
|
||||
export { default as AudioInput } from "./AudioInput";
|
||||
export { default as LinkInput } from "./LinkInput";
|
||||
export { default as SelectInstruction } from "./SelectInstruction";
|
||||
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 LinkSettings } from "./LinkSettings";
|
||||
export { default as VideoSettings } from "./VideoSettings";
|
||||
export { default as AudioSettings } from "./AudioSettings";
|
||||
export { default as AlignmentSettings } from "./AlignmentSettings";
|
||||
export { default as TransformSettings } from "./TransformSettings";
|
||||
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 LinkShape } from "./LinkShape";
|
||||
export { default as VideoShape } from "./VideoShape";
|
||||
export { default as AudioShape } from "./AudioShape";
|
||||
export type { ShapeProps, TextShapeProps } from "./types";
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ToolType =
|
||||
| "sticker"
|
||||
| "document"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "link"
|
||||
| "grid"
|
||||
| "group";
|
||||
@@ -68,6 +69,7 @@ export type EditorObject = {
|
||||
opacity?: number;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
audioUrl?: string;
|
||||
linkUrl?: string;
|
||||
rotation?: number;
|
||||
scaleX?: number;
|
||||
|
||||
@@ -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': {
|
||||
const isInternalLink = obj.linkUrl?.startsWith('page://');
|
||||
const linkStyle = {
|
||||
|
||||
@@ -38,6 +38,7 @@ type ViewerDataPage = {
|
||||
borderRadius?: number;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
audioUrl?: string;
|
||||
linkUrl?: string;
|
||||
rotation?: number;
|
||||
opacity?: number;
|
||||
@@ -129,6 +130,10 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
||||
baseObject.videoUrl = obj.videoUrl;
|
||||
}
|
||||
|
||||
if (obj.type === "audio" && obj.audioUrl) {
|
||||
baseObject.audioUrl = obj.audioUrl;
|
||||
}
|
||||
|
||||
if (obj.type === "link" && obj.linkUrl) {
|
||||
baseObject.linkUrl = obj.linkUrl;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export const INITIAL_PAGES_PRELOAD_COUNT = 5;
|
||||
|
||||
type MediaAsset = {
|
||||
url: string;
|
||||
kind: 'image' | 'video';
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
};
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
function addAudioUrl(urls: Map<string, MediaAsset>, url?: string) {
|
||||
if (!url) return;
|
||||
urls.set(url, { url, kind: 'audio' });
|
||||
}
|
||||
|
||||
export function extractPageMediaAssets(
|
||||
pages: PageData[],
|
||||
documentSettings?: Partial<DocumentSettings>,
|
||||
@@ -38,6 +43,8 @@ export function extractPageMediaAssets(
|
||||
addImageUrl(assets, element.imageUrl);
|
||||
} else if (element.type === 'video') {
|
||||
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> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
@@ -94,7 +120,11 @@ function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
|
||||
|
||||
export function preloadMediaAsset(asset: MediaAsset): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user