Compare commits
10 Commits
731353a4a8
...
83b2fd809c
| Author | SHA1 | Date | |
|---|---|---|---|
| 83b2fd809c | |||
| cbdc7edbf5 | |||
| 612deba297 | |||
| 517c77af77 | |||
| ebb6c1c9c6 | |||
| b6bff9956a | |||
| 4390937b19 | |||
| 642155661c | |||
| 5bbd677f41 | |||
| 7589a26706 |
@@ -1,4 +1,4 @@
|
||||
VITE_API_URL = 'http://188.121.103.8:4141'
|
||||
# VITE_API_URL = 'http://188.121.103.8:4141'
|
||||
VITE_API_URL = 'http://89.32.249.26:4010'
|
||||
# VITE_API_URL = 'http://192.168.99.131:4000'
|
||||
VITE_TOKEN_NAME = 'dpage-editor-t'
|
||||
VITE_REFRESH_TOKEN_NAME = 'dpage-editor-refresh-t'
|
||||
+22
-63
@@ -1,81 +1,40 @@
|
||||
import { useEffect, useState, type FC } from 'react'
|
||||
import './assets/fonts/irancell/style.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import MainRouter from './router/MainRouter'
|
||||
import i18next from 'i18next'
|
||||
import FaJson from '@/langs/fa.json'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ToastContainer from './components/Toast'
|
||||
import { getToken, setRefreshToken, setToken } from './config/func'
|
||||
import "react-multi-date-picker/styles/layouts/mobile.css"
|
||||
|
||||
import FaJson from "@/langs/fa.json";
|
||||
import { AuthProvider } from "@/context/AuthContext";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import i18next from "i18next";
|
||||
import { type FC } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import "react-multi-date-picker/styles/layouts/mobile.css";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "./assets/fonts/irancell/style.css";
|
||||
import ToastContainer from "./components/Toast";
|
||||
import MainRouter from "./router/MainRouter";
|
||||
|
||||
i18next.init({
|
||||
interpolation: { escapeValue: false },
|
||||
lng: 'fa',
|
||||
lng: "fa",
|
||||
resources: {
|
||||
fa: {
|
||||
global: FaJson
|
||||
}
|
||||
}
|
||||
})
|
||||
global: FaJson,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App: FC = () => {
|
||||
|
||||
const [isLogin, setIsLogin] = useState<'checking' | 'isLogin' | 'isNotLogin'>('checking')
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// استخراج توکنها از URL در صورت وجود
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tokenFromUrl = urlParams.get('token')
|
||||
const refreshTokenFromUrl = urlParams.get('refreshToken')
|
||||
|
||||
if (tokenFromUrl && refreshTokenFromUrl) {
|
||||
// ذخیره توکنها (جایگزین کردن توکنهای قبلی اگر وجود داشته باشند)
|
||||
setToken(tokenFromUrl)
|
||||
setRefreshToken(refreshTokenFromUrl)
|
||||
|
||||
// حذف پارامترها از URL
|
||||
const newUrl = window.location.pathname
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
|
||||
// تنظیم وضعیت لاگین و هدایت به صفحه اصلی
|
||||
setIsLogin('isLogin')
|
||||
// if (window.location.pathname === Paths.auth.login) {
|
||||
// window.location.href = Paths.home
|
||||
// }
|
||||
return
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
setIsLogin('isLogin')
|
||||
} else {
|
||||
setIsLogin('isNotLogin')
|
||||
if (window.location.href.split('auth').length === 1) {
|
||||
// window.location.href = Paths.auth.login
|
||||
}
|
||||
}
|
||||
|
||||
}, [])
|
||||
|
||||
console.log('isLogin', isLogin);
|
||||
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MainRouter />
|
||||
<ToastContainer />
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -11,7 +11,12 @@ type Props = {
|
||||
preview?: string[],
|
||||
onChangePreview?: (preview: string[]) => void,
|
||||
getCover?: (url: string) => void,
|
||||
coverUrl?: string
|
||||
coverUrl?: string,
|
||||
/** نمایش وضعیت بارگذاری داخل باکس آپلود */
|
||||
isLoading?: boolean,
|
||||
loadingLabel?: string,
|
||||
/** بدون پیشنمایش زیر باکس (مثلاً گالری جداگانه دارد) */
|
||||
hidePreview?: boolean,
|
||||
}
|
||||
|
||||
const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||
@@ -21,6 +26,10 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||
const [cover, setCover] = useState<string>(props.coverUrl ?? "");
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (props.hidePreview) {
|
||||
props.onChange(acceptedFiles);
|
||||
return;
|
||||
}
|
||||
// وقتی parent با preview و onChangePreview مدیریت میکند، فقط onChange بزن تا فقط یک ردیف (perviews) نمایش داده شود
|
||||
if (props.preview !== undefined && props.onChangePreview) {
|
||||
props.onChange(acceptedFiles);
|
||||
@@ -36,7 +45,10 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [files]);
|
||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
disabled: props.isLoading,
|
||||
})
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const array = [...files]
|
||||
@@ -67,8 +79,23 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div {...getRootProps()} className='w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl'>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={clx(
|
||||
'relative w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl',
|
||||
props.isLoading && 'pointer-events-none opacity-80',
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{props.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">
|
||||
{props.loadingLabel ?? 'در حال آپلود...'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Gallery
|
||||
className='size-8'
|
||||
color='#8C90A3'
|
||||
@@ -84,10 +111,12 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||
/>
|
||||
<div className='text-xs'>آپلود</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{
|
||||
files.length > 0 || perviews.length > 0 ? (
|
||||
!props.hidePreview && (files.length > 0 || perviews.length > 0) ? (
|
||||
<div className='mt-4 flex gap-4 items-center'>
|
||||
{
|
||||
perviews && perviews.map((item, index) => {
|
||||
|
||||
@@ -9,3 +9,14 @@ export const Paths = {
|
||||
request: "/designer/request",
|
||||
},
|
||||
};
|
||||
|
||||
const viewerPathPrefix = `${Paths.viewer}/`;
|
||||
|
||||
/** 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)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getToken, setRefreshToken, setToken } from '@/config/func'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
type AuthContextValue = {
|
||||
status: AuthStatus
|
||||
isAuthenticated: boolean
|
||||
isChecking: boolean
|
||||
refreshAuth: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
type AuthProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const AuthProvider: FC<AuthProviderProps> = ({ children }) => {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking')
|
||||
|
||||
const refreshAuth = useCallback(() => {
|
||||
setStatus(getToken() ? 'authenticated' : 'unauthenticated')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tokenFromUrl = urlParams.get('token')
|
||||
const refreshTokenFromUrl = urlParams.get('refreshToken')
|
||||
|
||||
if (tokenFromUrl && refreshTokenFromUrl) {
|
||||
setToken(tokenFromUrl)
|
||||
setRefreshToken(refreshTokenFromUrl)
|
||||
|
||||
const newUrl = window.location.pathname
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
|
||||
setStatus('authenticated')
|
||||
return
|
||||
}
|
||||
|
||||
refreshAuth()
|
||||
}, [refreshAuth])
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
isAuthenticated: status === 'authenticated',
|
||||
isChecking: status === 'checking',
|
||||
refreshAuth,
|
||||
}),
|
||||
[status, refreshAuth],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export const useAuth = (): AuthContextValue => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { transformViewerDataToPages } from '@/pages/viewer/utils/dataTransformer
|
||||
import { getPaperDimensions } from '@/config/paperSizes'
|
||||
import type { CatalogItemType } from '../types/Types'
|
||||
import type { PageData } from '@/pages/viewer/types'
|
||||
import type { Page } from '@/pages/editor/store/editorStore.types'
|
||||
import { clx } from '@/helpers/utils'
|
||||
|
||||
const PREVIEW_WIDTH = 64
|
||||
const PREVIEW_HEIGHT = 89
|
||||
@@ -20,30 +22,57 @@ function getFirstPageFromContent(content: string | undefined): PageData | null {
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
item: CatalogItemType
|
||||
function getPageDataFromEditorPage(page: Page): PageData | null {
|
||||
const transformed = transformViewerDataToPages({
|
||||
pages: [
|
||||
{
|
||||
id: page.id,
|
||||
name: page.name,
|
||||
backgroundType: page.backgroundType,
|
||||
backgroundColor: page.backgroundColor,
|
||||
backgroundGradient: page.backgroundGradient,
|
||||
backgroundImageUrl: page.backgroundImageUrl,
|
||||
objects: page.objects,
|
||||
},
|
||||
],
|
||||
})
|
||||
return transformed[0] ?? null
|
||||
}
|
||||
|
||||
const CatalogPreview: FC<Props> = ({ item }) => {
|
||||
const firstPage = useMemo(
|
||||
() => getFirstPageFromContent(item.content),
|
||||
[item.content]
|
||||
)
|
||||
type Props = {
|
||||
item?: CatalogItemType
|
||||
page?: Page
|
||||
size?: string
|
||||
className?: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
const CatalogPreview: FC<Props> = ({ item, page, size, className, selected }) => {
|
||||
const catalogSize = item?.size ?? size ?? 'a4'
|
||||
|
||||
const firstPage = useMemo(() => {
|
||||
if (page) return getPageDataFromEditorPage(page)
|
||||
return getFirstPageFromContent(item?.content)
|
||||
}, [item?.content, page])
|
||||
|
||||
const { pageWidth, pageHeight, previewScale } = useMemo(() => {
|
||||
const { width, height } = getPaperDimensions(item.size ?? 'a4')
|
||||
const { width, height } = getPaperDimensions(catalogSize)
|
||||
const scale = Math.min(PREVIEW_WIDTH / width, PREVIEW_HEIGHT / height)
|
||||
return {
|
||||
pageWidth: width,
|
||||
pageHeight: height,
|
||||
previewScale: scale,
|
||||
}
|
||||
}, [item.size])
|
||||
}, [catalogSize])
|
||||
|
||||
if (!firstPage) {
|
||||
return (
|
||||
<div
|
||||
className='bg-gray-200 rounded-lg flex items-center justify-center shrink-0'
|
||||
className={clx(
|
||||
'bg-gray-200 rounded-lg flex items-center justify-center shrink-0',
|
||||
selected && 'border border-black',
|
||||
className,
|
||||
)}
|
||||
style={{ width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }}
|
||||
>
|
||||
<span className='text-[10px] text-gray-400'>بدون پیشنمایش</span>
|
||||
@@ -53,7 +82,11 @@ const CatalogPreview: FC<Props> = ({ item }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className='rounded-lg overflow-hidden shrink-0 bg-white border border-gray-200 relative'
|
||||
className={clx(
|
||||
'rounded-lg overflow-hidden shrink-0 bg-white border border-gray-200 relative',
|
||||
selected && 'border-black',
|
||||
className,
|
||||
)}
|
||||
style={{ width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }}
|
||||
dir='rtl'
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ import TrashWithConfrim from '@/components/TrashWithConfrim'
|
||||
import { useDleteCatalog } from '@/pages/home/hooks/useHomeData'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { extractErrorMessage } from '@/helpers/utils'
|
||||
import { requestViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen'
|
||||
|
||||
type Props = {
|
||||
item: CatalogItemType,
|
||||
@@ -69,7 +70,7 @@ const CatalogueItem: FC<Props> = ({ item, refetch }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end gap-1 items-center mt-1'>
|
||||
<Link to={Paths.viewer + `/${item.id}`}>
|
||||
<Link to={Paths.viewer + `/${item.id}`} onClick={requestViewerFullscreen}>
|
||||
<div className='size-6 bg-[#EAECF4] rounded-md flex justify-center items-center'>
|
||||
<Eye size={18} color='#0047FF' />
|
||||
</div>
|
||||
|
||||
+41
-12
@@ -1,4 +1,4 @@
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import Konva from 'konva'
|
||||
import { clx } from '@/helpers/utils'
|
||||
import EditorSidebar from './components/EditorSidebar'
|
||||
@@ -23,33 +23,59 @@ const Editor: FC = () => {
|
||||
}, [])
|
||||
const [isLayersPanelOpen, setIsLayersPanelOpen] = useState(false)
|
||||
const [isInitialContentReady, setIsInitialContentReady] = useState(false)
|
||||
const { loadPages, pages, documentSettings, objects, currentPageId } = useEditorStore()
|
||||
const loadedCatalogIdRef = useRef<string | null>(null)
|
||||
const skipNextAutosaveRef = useRef(true)
|
||||
const { loadPages, resetEditor, pages, documentSettings, gallery, objects, currentPageId } = useEditorStore()
|
||||
const { data, isFetched } = useGetCatalogById(id!)
|
||||
const { scheduleUpdate, isSaving } = useUpdateCatalog()
|
||||
const { scheduleUpdate, cancelPendingUpdate, isSaving } = useUpdateCatalog()
|
||||
|
||||
// هنگام تعویض کاتالوگ: لغو ذخیرهٔ معلق، پاکسازی store، و منتظر بارگذاری مجدد بمان
|
||||
useEffect(() => {
|
||||
if (!isFetched) return
|
||||
cancelPendingUpdate()
|
||||
loadedCatalogIdRef.current = null
|
||||
skipNextAutosaveRef.current = true
|
||||
setIsInitialContentReady(false)
|
||||
resetEditor()
|
||||
}, [id, cancelPendingUpdate, resetEditor])
|
||||
|
||||
if (data?.data?.content) {
|
||||
// بارگذاری محتوا فقط یکبار برای هر id (نه در هر refetch)
|
||||
useEffect(() => {
|
||||
if (!id || !isFetched) return
|
||||
if (loadedCatalogIdRef.current === id) return
|
||||
|
||||
const rawContent = data?.data?.content
|
||||
if (rawContent) {
|
||||
try {
|
||||
const json = JSON.parse(data.data.content)
|
||||
const json = JSON.parse(rawContent)
|
||||
if (Array.isArray(json)) {
|
||||
loadPages(json)
|
||||
} else if (json?.pages) {
|
||||
loadPages(json.pages, json.documentSettings)
|
||||
loadPages(json.pages, json.documentSettings, json.gallery)
|
||||
} else {
|
||||
console.error('Invalid catalog content JSON: missing pages')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Invalid catalog content JSON:', error)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
resetEditor()
|
||||
}
|
||||
|
||||
loadedCatalogIdRef.current = id
|
||||
skipNextAutosaveRef.current = true
|
||||
setIsInitialContentReady(true)
|
||||
}, [data?.data?.content, isFetched, loadPages])
|
||||
}, [id, isFetched, data?.data?.content, loadPages, resetEditor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
if (!isInitialContentReady) return
|
||||
if (!pages || pages.length === 0) return
|
||||
if (skipNextAutosaveRef.current) {
|
||||
skipNextAutosaveRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const pagesToSave = currentPageId
|
||||
? pages.map((p) =>
|
||||
@@ -59,11 +85,11 @@ const Editor: FC = () => {
|
||||
|
||||
scheduleUpdate({
|
||||
id,
|
||||
content: JSON.stringify({ pages: pagesToSave, documentSettings }),
|
||||
content: JSON.stringify({ pages: pagesToSave, documentSettings, gallery }),
|
||||
})
|
||||
// عمداً scheduleUpdate را در وابستگیها نیاوردیم تا از لوپ ذخیرهسازی جلوگیری شود
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, isInitialContentReady, pages, documentSettings, objects, currentPageId])
|
||||
}, [id, isInitialContentReady, pages, documentSettings, gallery, objects, currentPageId])
|
||||
|
||||
|
||||
return (
|
||||
@@ -75,11 +101,14 @@ const Editor: FC = () => {
|
||||
<div className="fixed bottom-10 left-10 z-50 rounded-full shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-black" />
|
||||
{/* <span>در حال ذخیره ...</span> */}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<LayersPanel isOpen={isLayersPanelOpen} setIsOpen={setIsLayersPanelOpen} />
|
||||
<LayersPanel
|
||||
isOpen={isLayersPanelOpen}
|
||||
setIsOpen={setIsLayersPanelOpen}
|
||||
catalogSize={data?.data?.size}
|
||||
/>
|
||||
<EditorCanvas catalogSize={data?.data?.size} />
|
||||
<EditorSidebar catalogSize={data?.data?.size} />
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,16 @@ import ZoomControls from "./ZoomControls";
|
||||
import EditorCanvasToolbar from "./EditorCanvasToolbar";
|
||||
import { createScopedId } from "../store/editorStore.helpers";
|
||||
import { getKonvaGradientProps } from "../utils/gradient";
|
||||
import { STICKER_DRAG_MIME } from "../types/IconTypes";
|
||||
import { GALLERY_DRAG_MIME } from "../types/GalleryTypes";
|
||||
import {
|
||||
createStickerObject,
|
||||
scaleStickerDimensions,
|
||||
} from "../utils/stickerUtils";
|
||||
import {
|
||||
createImageObject,
|
||||
scaleImageDimensions,
|
||||
} from "../utils/imageUtils";
|
||||
|
||||
type EditorCanvasProps = {
|
||||
catalogSize?: string;
|
||||
@@ -50,6 +60,9 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
setLayerRef,
|
||||
setGuides,
|
||||
zoom,
|
||||
addObject,
|
||||
setSelectedObjectId,
|
||||
setTool,
|
||||
} = useEditorStore();
|
||||
const isSmartGuideEnabled = documentSettings.smartGuide;
|
||||
const currentPage = pages.find((page) => page.id === currentPageId);
|
||||
@@ -360,6 +373,73 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
|
||||
const scaledW = stageSize.width * finalScale;
|
||||
const scaledH = stageSize.height * finalScale;
|
||||
|
||||
const SIDEBAR_DRAG_MIMES = [STICKER_DRAG_MIME, GALLERY_DRAG_MIME];
|
||||
|
||||
const handleSidebarDragOver = (e: React.DragEvent) => {
|
||||
if (!SIDEBAR_DRAG_MIMES.some((mime) => e.dataTransfer.types.includes(mime))) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const placeObjectAt = (
|
||||
imageUrl: string,
|
||||
stageX: number,
|
||||
stageY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
type: "sticker" | "image",
|
||||
) => {
|
||||
const object =
|
||||
type === "sticker"
|
||||
? createStickerObject(
|
||||
imageUrl,
|
||||
stageX,
|
||||
stageY,
|
||||
width,
|
||||
height,
|
||||
stageSize.width,
|
||||
stageSize.height,
|
||||
)
|
||||
: createImageObject(
|
||||
imageUrl,
|
||||
stageX,
|
||||
stageY,
|
||||
width,
|
||||
height,
|
||||
stageSize.width,
|
||||
stageSize.height,
|
||||
);
|
||||
addObject(object);
|
||||
setSelectedObjectId(object.id);
|
||||
setTool("select");
|
||||
};
|
||||
|
||||
const handleSidebarDrop = (e: React.DragEvent) => {
|
||||
const stickerUrl = e.dataTransfer.getData(STICKER_DRAG_MIME);
|
||||
const galleryUrl = e.dataTransfer.getData(GALLERY_DRAG_MIME);
|
||||
const imageUrl = stickerUrl || galleryUrl;
|
||||
const dropType = stickerUrl ? "sticker" : galleryUrl ? "image" : null;
|
||||
if (!imageUrl || !dropType || !stageWrapRef.current) return;
|
||||
e.preventDefault();
|
||||
|
||||
const rect = stageWrapRef.current.getBoundingClientRect();
|
||||
const stageX = (e.clientX - rect.left) / finalScale;
|
||||
const stageY = (e.clientY - rect.top) / finalScale;
|
||||
|
||||
const scaleFn =
|
||||
dropType === "sticker" ? scaleStickerDimensions : scaleImageDimensions;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const { width, height } = scaleFn(img.naturalWidth, img.naturalHeight);
|
||||
placeObjectAt(imageUrl, stageX, stageY, width, height, dropType);
|
||||
};
|
||||
img.onerror = () => {
|
||||
placeObjectAt(imageUrl, stageX, stageY, 100, 100, dropType);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
};
|
||||
const backgroundGradientProps = getKonvaGradientProps(
|
||||
currentPage?.backgroundType === "gradient" ? "gradient" : "solid",
|
||||
bgGradient,
|
||||
@@ -410,7 +490,12 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
title="Drag to create horizontal guide"
|
||||
/>
|
||||
<div className="absolute -top-6 -left-6 w-6 h-6 bg-slate-200 border border-slate-300" />
|
||||
<div ref={stageWrapRef} className="shadow-lg">
|
||||
<div
|
||||
ref={stageWrapRef}
|
||||
className="shadow-lg"
|
||||
onDragOver={handleSidebarDragOver}
|
||||
onDrop={handleSidebarDrop}
|
||||
>
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageSize.width * finalScale}
|
||||
@@ -464,6 +549,8 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
onCellDblClick={handleCellDblClick}
|
||||
onGroup={handleGroupWithMask}
|
||||
onUngroup={handleUngroupFromMask}
|
||||
stageWidth={stageSize.width}
|
||||
stageHeight={stageSize.height}
|
||||
/>
|
||||
<GuidesLayer
|
||||
guides={guides}
|
||||
|
||||
@@ -10,6 +10,7 @@ type TabType = 'pages' | 'layers' | 'settings'
|
||||
type LayersPanelProps = {
|
||||
isOpen: boolean
|
||||
setIsOpen: (isOpen: boolean) => void
|
||||
catalogSize?: string
|
||||
}
|
||||
|
||||
const TAB_LABELS: Record<TabType, string> = {
|
||||
@@ -18,7 +19,7 @@ const TAB_LABELS: Record<TabType, string> = {
|
||||
settings: 'تنظیمات',
|
||||
}
|
||||
|
||||
const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => {
|
||||
const LayersPanel = ({ isOpen, setIsOpen, catalogSize }: LayersPanelProps) => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('pages')
|
||||
|
||||
if (!isOpen) {
|
||||
@@ -98,7 +99,7 @@ const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'pages' && <PagesPanel />}
|
||||
{activeTab === 'pages' && <PagesPanel catalogSize={catalogSize} />}
|
||||
{activeTab === 'layers' && <LayersList />}
|
||||
{activeTab === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { DocumentText, Copy, Trash, AddSquare } from 'iconsax-react'
|
||||
import { Copy, Trash, AddSquare, DocumentText } from 'iconsax-react'
|
||||
import { useEditorStore } from '../store/editorStore'
|
||||
import type { Page } from '../store/editorStore.types'
|
||||
import { clx } from '@/helpers/utils'
|
||||
import CatalogPreview from '@/pages/catalogue/components/CatalogPreview'
|
||||
|
||||
const PagesPanel = () => {
|
||||
function isPageEmptyForPreview(page: Page): boolean {
|
||||
if (page.objects.length > 0) return false
|
||||
if (page.backgroundType === 'image' && page.backgroundImageUrl.trim() !== '') {
|
||||
return false
|
||||
}
|
||||
if (page.backgroundType === 'gradient') return false
|
||||
return true
|
||||
}
|
||||
|
||||
type PagesPanelProps = {
|
||||
catalogSize?: string
|
||||
}
|
||||
|
||||
const PagesPanel = ({ catalogSize }: PagesPanelProps) => {
|
||||
const {
|
||||
pages,
|
||||
currentPageId,
|
||||
@@ -37,6 +52,7 @@ const PagesPanel = () => {
|
||||
) : (
|
||||
pages.map((page) => {
|
||||
const isSelected = currentPageId === page.id
|
||||
const isEmpty = isPageEmptyForPreview(page)
|
||||
return (
|
||||
<div
|
||||
key={page.id}
|
||||
@@ -46,6 +62,7 @@ const PagesPanel = () => {
|
||||
isSelected && ' rounded-lg p-0.5'
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div
|
||||
className={clx(
|
||||
'w-[72px] h-[94px] bg-[#EEF0F7] rounded-lg flex items-center justify-center',
|
||||
@@ -54,6 +71,13 @@ const PagesPanel = () => {
|
||||
>
|
||||
<DocumentText size={20} color="#000" variant="Outline" />
|
||||
</div>
|
||||
) : (
|
||||
<CatalogPreview
|
||||
page={page}
|
||||
size={catalogSize}
|
||||
selected={isSelected}
|
||||
/>
|
||||
)}
|
||||
<div className="text-xs font-medium text-center text-black">
|
||||
{page.name}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,8 @@ type ObjectRendererProps = {
|
||||
onCellDblClick?: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void;
|
||||
selectedCellId?: string | null;
|
||||
allObjects?: EditorObject[];
|
||||
stageWidth?: number;
|
||||
stageHeight?: number;
|
||||
};
|
||||
|
||||
const ObjectRenderer = ({
|
||||
@@ -42,6 +44,8 @@ const ObjectRenderer = ({
|
||||
onCellDblClick,
|
||||
selectedCellId,
|
||||
allObjects = [],
|
||||
stageWidth,
|
||||
stageHeight,
|
||||
}: ObjectRendererProps) => {
|
||||
const groupRef = useRef<Konva.Group>(null);
|
||||
|
||||
@@ -110,6 +114,8 @@ const ObjectRenderer = ({
|
||||
onUpdate,
|
||||
// اگر mask اعمال شده، shape را غیر draggable کن (Group drag میشود)
|
||||
draggable: shouldApplyMask ? false : draggable,
|
||||
stageWidth,
|
||||
stageHeight,
|
||||
};
|
||||
|
||||
const renderMaskShape = (mask: EditorObject) => {
|
||||
@@ -218,6 +224,9 @@ const ObjectRenderer = ({
|
||||
case "document":
|
||||
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
||||
break;
|
||||
case "sticker":
|
||||
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
||||
break;
|
||||
case "grid":
|
||||
shapeElement = (
|
||||
<Table
|
||||
|
||||
@@ -31,6 +31,8 @@ interface ObjectsLayerProps {
|
||||
) => void;
|
||||
onGroup: (objectId: string, maskId: string) => void;
|
||||
onUngroup: (groupId: string) => void;
|
||||
stageWidth: number;
|
||||
stageHeight: number;
|
||||
}
|
||||
|
||||
const ObjectsLayer = ({
|
||||
@@ -51,6 +53,8 @@ const ObjectsLayer = ({
|
||||
onCellDblClick,
|
||||
onGroup,
|
||||
onUngroup,
|
||||
stageWidth,
|
||||
stageHeight,
|
||||
}: ObjectsLayerProps) => {
|
||||
return (
|
||||
<Layer ref={layerRef}>
|
||||
@@ -71,6 +75,8 @@ const ObjectsLayer = ({
|
||||
onCellDblClick={onCellDblClick}
|
||||
selectedCellId={selectedCellId}
|
||||
allObjects={objects}
|
||||
stageWidth={stageWidth}
|
||||
stageHeight={stageHeight}
|
||||
/>
|
||||
))}
|
||||
{/* Render non-grouped objects */}
|
||||
@@ -90,6 +96,8 @@ const ObjectsLayer = ({
|
||||
onCellDblClick={onCellDblClick}
|
||||
selectedCellId={selectedCellId}
|
||||
allObjects={objects}
|
||||
stageWidth={stageWidth}
|
||||
stageHeight={stageHeight}
|
||||
/>
|
||||
))}
|
||||
{/* Render objects inside groups - they should not be interactive */}
|
||||
@@ -115,6 +123,8 @@ const ObjectsLayer = ({
|
||||
onCellDblClick={onCellDblClick}
|
||||
selectedCellId={selectedCellId}
|
||||
allObjects={objects}
|
||||
stageWidth={stageWidth}
|
||||
stageHeight={stageHeight}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,15 @@ export const useSelectionHandlers = () => {
|
||||
removeFromSelection,
|
||||
setSelectedTableId,
|
||||
setSelectedCellId,
|
||||
tool,
|
||||
setTool,
|
||||
} = useEditorStore();
|
||||
|
||||
const handleSelect = (id: string, _node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
if (tool !== "select") {
|
||||
setTool("select");
|
||||
}
|
||||
|
||||
const obj = objects.find((o) => o.id === id);
|
||||
const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEditorStore, type ToolType } from "@/pages/editor/store/editorStore";
|
||||
import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
||||
import { type ToolType } from "@/pages/editor/store/editorStore";
|
||||
import {
|
||||
ImageUpload,
|
||||
ImageGallery,
|
||||
VideoInput,
|
||||
LinkInput,
|
||||
SelectInstruction,
|
||||
@@ -18,39 +17,8 @@ type ToolInstructionsProps = {
|
||||
};
|
||||
|
||||
const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
|
||||
const { addObject, setSelectedObjectId, setTool } = useEditorStore();
|
||||
const { mutateAsync: uploadFile } = useSingleUpload();
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const imageUrl = result?.data?.url;
|
||||
if (!imageUrl) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const newImage = {
|
||||
id: `image-${Date.now()}`,
|
||||
type: "image" as ToolType,
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
imageUrl,
|
||||
};
|
||||
addObject(newImage);
|
||||
setSelectedObjectId(newImage.id);
|
||||
setTool("select");
|
||||
};
|
||||
img.src = imageUrl;
|
||||
} catch {
|
||||
// TODO: show error toast
|
||||
}
|
||||
};
|
||||
|
||||
if (tool === "image") {
|
||||
return <ImageUpload onImageUpload={handleImageUpload} />;
|
||||
return <ImageGallery />;
|
||||
}
|
||||
|
||||
if (tool === "video") {
|
||||
|
||||
@@ -18,7 +18,7 @@ const tools: Array<{ icon: (color: string, variant?: "Bold" | "Outline" | "Broke
|
||||
{ icon: (color, variant) => <Shapes size={20} color={color} variant={variant} />, tool: "rectangle", label: "مستطیل" },
|
||||
{ icon: (color, variant) => <Text size={20} color={color} variant={variant} />, tool: "text", label: "متن" },
|
||||
{ icon: (color, variant) => <Grid8 size={20} color={color} variant={variant} />, tool: "grid", label: "گرید" },
|
||||
{ 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) => <VideoSquare size={20} color={color} variant={variant} />, tool: "video", label: "ویدیو" },
|
||||
{ icon: (color, variant) => <ArrowLeft size={20} color={color} variant={variant} />, tool: "arrow", label: "پیکان" },
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { type FC, useState } from "react";
|
||||
import { Trash } from "iconsax-react";
|
||||
import UploadBoxDraggble from "@/components/UploadBoxDraggble";
|
||||
import { useEditorStore } from "@/pages/editor/store/editorStore";
|
||||
import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
||||
import { createScopedId } from "@/pages/editor/store/editorStore.helpers";
|
||||
import { GALLERY_DRAG_MIME } from "@/pages/editor/types/GalleryTypes";
|
||||
|
||||
const ImageGallery: FC = () => {
|
||||
const { gallery, addGalleryItem, removeGalleryItem } = useEditorStore();
|
||||
const { mutateAsync: uploadFile } = useSingleUpload();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileChange = async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
const result = await uploadFile(file);
|
||||
const imageUrl = result?.data?.url;
|
||||
if (!imageUrl) continue;
|
||||
|
||||
const dimensions = await loadImageDimensions(imageUrl);
|
||||
addGalleryItem({
|
||||
id: createScopedId("gallery"),
|
||||
url: imageUrl,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// TODO: show error toast
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent<HTMLImageElement>, url: string) => {
|
||||
e.dataTransfer.setData(GALLERY_DRAG_MIME, url);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-base font-bold">گالری</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
تصویر را آپلود کنید، سپس از گالری بکشید و روی صفحه رها کنید
|
||||
</p>
|
||||
|
||||
<UploadBoxDraggble
|
||||
label="تصویر مورد نظر را آپلود کنید"
|
||||
onChange={handleFileChange}
|
||||
isMultiple={true}
|
||||
hidePreview
|
||||
isLoading={isUploading}
|
||||
/>
|
||||
|
||||
{gallery.length === 0 && !isUploading && (
|
||||
<p className="text-sm text-gray-500">گالری خالی است</p>
|
||||
)}
|
||||
|
||||
{gallery.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 max-h-[320px] overflow-y-auto">
|
||||
{gallery.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group relative flex aspect-square items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-1 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt=""
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item.url)}
|
||||
className="max-h-full max-w-full object-contain cursor-grab active:cursor-grabbing"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeGalleryItem(item.id)}
|
||||
className="absolute -left-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-white shadow group-hover:flex"
|
||||
title="حذف از گالری"
|
||||
>
|
||||
<Trash size={12} color="#ef4444" variant="Bold" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const loadImageDimensions = (url: string): Promise<{ width: number; height: number }> =>
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve({ width: 100, height: 100 });
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
export default ImageGallery;
|
||||
@@ -1,67 +1 @@
|
||||
import { type FC, useState, useRef } from "react";
|
||||
import UploadBoxDraggble from "@/components/UploadBoxDraggble";
|
||||
|
||||
type ImageUploadProps = {
|
||||
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const ImageUpload: FC<ImageUploadProps> = ({ onImageUpload }) => {
|
||||
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
||||
const previousFilesCountRef = useRef<number>(0);
|
||||
|
||||
const handleFileChange = (files: File[]) => {
|
||||
// Update ref to track current files count
|
||||
if (files.length > previousFilesCountRef.current) {
|
||||
// Process only new files
|
||||
const newFiles = files.slice(previousFilesCountRef.current);
|
||||
|
||||
newFiles.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const imageUrl = event.target?.result as string;
|
||||
setUploadedImages((prev) => [...prev, imageUrl]);
|
||||
|
||||
// Create a synthetic event for onImageUpload
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
files: [file],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
||||
|
||||
onImageUpload(syntheticEvent);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Always update ref to track current files count
|
||||
previousFilesCountRef.current = files.length;
|
||||
};
|
||||
|
||||
const handlePreviewChange = (previews: string[]) => {
|
||||
setUploadedImages(previews);
|
||||
// Update ref when previews change (e.g., when files are deleted)
|
||||
previousFilesCountRef.current = previews.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-base font-bold mb-4">تصویر</div>
|
||||
<UploadBoxDraggble
|
||||
label="تصویر مورد نظر را آپلود کنید"
|
||||
onChange={handleFileChange}
|
||||
isMultiple={true}
|
||||
preview={uploadedImages}
|
||||
onChangePreview={handlePreviewChange}
|
||||
/>
|
||||
{uploadedImages.length > 0 && (
|
||||
<div className="mt-4 text-description text-xs">
|
||||
تصویر های آپلود شده
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUpload;
|
||||
|
||||
export { default } from "./ImageGallery";
|
||||
|
||||
@@ -1,9 +1,59 @@
|
||||
const StickerInstruction = () => (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>برای افزودن استیکر، روی کانوس کلیک کنید</p>
|
||||
import { useMemo } from "react";
|
||||
import { useGetIconGroups } from "@/pages/editor/hooks/useIconData";
|
||||
import { STICKER_DRAG_MIME } from "@/pages/editor/types/IconTypes";
|
||||
|
||||
const StickerInstruction = () => {
|
||||
const { data, isLoading, isError } = useGetIconGroups();
|
||||
|
||||
const stickers = useMemo(() => {
|
||||
const groups = data?.data ?? [];
|
||||
return groups.flatMap((group) => group.icons);
|
||||
}, [data]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent<HTMLImageElement>, url: string) => {
|
||||
e.dataTransfer.setData(STICKER_DRAG_MIME, url);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-base font-bold">استیکر</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
استیکر را بکشید و روی صفحه رها کنید
|
||||
</p>
|
||||
|
||||
{isLoading && (
|
||||
<p className="text-sm text-gray-500">در حال بارگذاری استیکرها...</p>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p className="text-sm text-red-500">خطا در بارگذاری استیکرها</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && stickers.length === 0 && (
|
||||
<p className="text-sm text-gray-500">استیکری یافت نشد</p>
|
||||
)}
|
||||
|
||||
{stickers.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 max-h-[320px] overflow-y-auto">
|
||||
{stickers.map((icon) => (
|
||||
<div
|
||||
key={icon.id}
|
||||
className="flex aspect-square items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-1 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={icon.url}
|
||||
alt=""
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, icon.url)}
|
||||
className="max-h-full max-w-full object-contain cursor-grab active:cursor-grabbing"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StickerInstruction;
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as ImageGallery } from "./ImageGallery";
|
||||
export { default as ImageUpload } from "./ImageUpload";
|
||||
export { default as DocumentUpload } from "./DocumentUpload";
|
||||
export { default as VideoInput } from "./VideoInput";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Image as KonvaImage } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import useImage from "use-image";
|
||||
import type { ShapeProps } from "./types";
|
||||
import { clampPositionToStage } from "../../utils/stageBounds";
|
||||
|
||||
const ImageObject = ({
|
||||
obj,
|
||||
@@ -10,6 +11,8 @@ const ImageObject = ({
|
||||
onSelect,
|
||||
onUpdate,
|
||||
draggable,
|
||||
stageWidth,
|
||||
stageHeight,
|
||||
}: ShapeProps) => {
|
||||
const [image] = useImage(obj.imageUrl || "");
|
||||
const shapeRef = useRef<Konva.Image>(null);
|
||||
@@ -18,32 +21,58 @@ const ImageObject = ({
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
const width = obj.width || image.width;
|
||||
const height = obj.height || image.height;
|
||||
const hasStageBounds = stageWidth != null && stageHeight != null;
|
||||
|
||||
return (
|
||||
<KonvaImage
|
||||
ref={shapeRef}
|
||||
id={obj.id}
|
||||
name="canvas-object"
|
||||
x={obj.x}
|
||||
y={obj.y}
|
||||
image={image}
|
||||
width={obj.width || image.width}
|
||||
height={obj.height || image.height}
|
||||
// اگر draggable=false باشد، یعنی mask اعمال شده و نباید stroke نمایش داده شود (Group stroke دارد)
|
||||
stroke={isSelected && draggable ? "#3b82f6" : undefined}
|
||||
strokeWidth={isSelected && draggable ? 3 : 0}
|
||||
width={width}
|
||||
height={height}
|
||||
stroke={isSelected ? "#3b82f6" : undefined}
|
||||
strokeWidth={isSelected ? 3 : 0}
|
||||
rotation={obj.rotation || 0}
|
||||
draggable={draggable}
|
||||
onClick={draggable ? (e) => {
|
||||
// فقط وقتی mask اعمال نشده onClick handler را فعال کن
|
||||
dragBoundFunc={
|
||||
draggable && hasStageBounds
|
||||
? (pos) =>
|
||||
clampPositionToStage(
|
||||
pos.x,
|
||||
pos.y,
|
||||
width,
|
||||
height,
|
||||
stageWidth!,
|
||||
stageHeight!,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (shapeRef.current) {
|
||||
onSelect(obj.id, shapeRef.current, e);
|
||||
}
|
||||
} : undefined}
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
const node = e.target;
|
||||
onUpdate(obj.id, {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
});
|
||||
let x = node.x();
|
||||
let y = node.y();
|
||||
if (hasStageBounds) {
|
||||
({ x, y } = clampPositionToStage(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
stageWidth!,
|
||||
stageHeight!,
|
||||
));
|
||||
node.position({ x, y });
|
||||
}
|
||||
onUpdate(obj.id, { x, y });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ export type ShapeProps = {
|
||||
onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||
draggable: boolean;
|
||||
stageWidth?: number;
|
||||
stageHeight?: number;
|
||||
};
|
||||
|
||||
export type TextShapeProps = ShapeProps & {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as api from "../service/IconService";
|
||||
|
||||
export const useGetIconGroups = () => {
|
||||
return useQuery({
|
||||
queryKey: ["icon-groups"],
|
||||
queryFn: api.getIconGroups,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import axios from "@/config/axios";
|
||||
import type { IconGroupsResponse } from "../types/IconTypes";
|
||||
|
||||
export const getIconGroups = async () => {
|
||||
const { data } = await axios.get<IconGroupsResponse>("/admin/groups/icons");
|
||||
return data;
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
TableData,
|
||||
ToolType,
|
||||
DocumentSettings,
|
||||
GalleryItem,
|
||||
} from "./editorStore.types";
|
||||
|
||||
const MAX_OBJECT_HISTORY = 50;
|
||||
@@ -56,6 +57,7 @@ export type {
|
||||
TableData,
|
||||
ToolType,
|
||||
DocumentSettings,
|
||||
GalleryItem,
|
||||
DisplayStyle,
|
||||
BackgroundType,
|
||||
EntranceAnimationType,
|
||||
@@ -149,7 +151,9 @@ type EditorStoreType = {
|
||||
loadPages: (
|
||||
pages: Page[],
|
||||
documentSettings?: Partial<DocumentSettings>,
|
||||
gallery?: GalleryItem[],
|
||||
) => void;
|
||||
resetEditor: () => void;
|
||||
clipboardObjects: EditorObject[];
|
||||
objectHistoryPast: EditorObject[][];
|
||||
objectHistoryFuture: EditorObject[][];
|
||||
@@ -165,6 +169,26 @@ type EditorStoreType = {
|
||||
updateCurrentPageBackground: (
|
||||
updates: Partial<PageBackgroundSettings>,
|
||||
) => void;
|
||||
gallery: GalleryItem[];
|
||||
addGalleryItem: (item: GalleryItem) => void;
|
||||
removeGalleryItem: (id: string) => void;
|
||||
setGallery: (items: GalleryItem[]) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
|
||||
displayStyle: "double",
|
||||
autoPlay: false,
|
||||
pageFlipSound: true,
|
||||
leftToRightFlip: false,
|
||||
smartGuide: true,
|
||||
backgroundType: "color",
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundGradient: {
|
||||
from: "#ffffff",
|
||||
to: "#e2e8f0",
|
||||
angle: 135,
|
||||
},
|
||||
backgroundImageUrl: "",
|
||||
};
|
||||
|
||||
export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
@@ -209,21 +233,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
};
|
||||
|
||||
return {
|
||||
documentSettings: {
|
||||
displayStyle: "double",
|
||||
autoPlay: false,
|
||||
pageFlipSound: true,
|
||||
leftToRightFlip: false,
|
||||
smartGuide: true,
|
||||
backgroundType: "color",
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundGradient: {
|
||||
from: "#ffffff",
|
||||
to: "#e2e8f0",
|
||||
angle: 135,
|
||||
},
|
||||
backgroundImageUrl: "",
|
||||
},
|
||||
documentSettings: { ...DEFAULT_DOCUMENT_SETTINGS },
|
||||
updateDocumentSettings: (updates) =>
|
||||
set((state) => ({
|
||||
documentSettings: { ...state.documentSettings, ...updates },
|
||||
@@ -242,6 +252,14 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
),
|
||||
};
|
||||
}),
|
||||
gallery: [],
|
||||
addGalleryItem: (item) =>
|
||||
set((state) => ({ gallery: [...state.gallery, item] })),
|
||||
removeGalleryItem: (id) =>
|
||||
set((state) => ({
|
||||
gallery: state.gallery.filter((item) => item.id !== id),
|
||||
})),
|
||||
setGallery: (items) => set({ gallery: items }),
|
||||
tool: "select",
|
||||
setTool: (tool) =>
|
||||
set((state) => ({
|
||||
@@ -984,7 +1002,22 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
const state = get();
|
||||
return state.objects.filter((obj) => obj.groupId === groupId);
|
||||
},
|
||||
loadPages: (pages, documentSettings) => {
|
||||
resetEditor: () => {
|
||||
const page = createInitialPage("جلد");
|
||||
set({
|
||||
pages: [page],
|
||||
currentPageId: page.id,
|
||||
objects: page.objects,
|
||||
guides: [],
|
||||
selectedObjectId: null,
|
||||
selectedObjectIds: [],
|
||||
objectHistoryPast: [],
|
||||
objectHistoryFuture: [],
|
||||
documentSettings: { ...DEFAULT_DOCUMENT_SETTINGS },
|
||||
gallery: [],
|
||||
});
|
||||
},
|
||||
loadPages: (pages, documentSettings, gallery) => {
|
||||
if (pages.length === 0) return;
|
||||
const fallbackSettings = documentSettings ?? {};
|
||||
const normalizedPages = pages.map((page) => ({
|
||||
@@ -1019,6 +1052,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
...(documentSettings && {
|
||||
documentSettings: { ...currentSettings, ...documentSettings },
|
||||
}),
|
||||
...(gallery !== undefined && { gallery }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,3 +120,11 @@ export type DocumentSettings = {
|
||||
backgroundGradient?: LinearGradient;
|
||||
backgroundImageUrl: string;
|
||||
};
|
||||
|
||||
/** تصویر آپلودشده در گالری کاتالوگ (مشترک بین همهٔ صفحات) */
|
||||
export type GalleryItem = {
|
||||
id: string;
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const GALLERY_DRAG_MIME = "application/x-dpage-gallery";
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BaseResponse } from "@/shared/types/SharedTypes";
|
||||
|
||||
export type IconItem = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
url: string;
|
||||
group: string;
|
||||
};
|
||||
|
||||
export type IconGroup = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
name: string;
|
||||
icons: IconItem[];
|
||||
};
|
||||
|
||||
export type IconGroupsResponse = BaseResponse<IconGroup[]>;
|
||||
|
||||
export const STICKER_DRAG_MIME = "application/x-dpage-sticker";
|
||||
@@ -117,7 +117,7 @@ const drawObject = async (
|
||||
ctx.scale(object.scaleX || 1, object.scaleY || 1);
|
||||
|
||||
// برای image objects
|
||||
if (object.type === "image" || object.type === "document") {
|
||||
if (object.type === "image" || object.type === "document" || object.type === "sticker") {
|
||||
if (object.imageUrl) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ToolType } from "../store/editorStore.types";
|
||||
import { clampPositionToStage, fitSizeToStage } from "./stageBounds";
|
||||
|
||||
const IMAGE_MAX_SIZE = 400;
|
||||
|
||||
export const scaleImageDimensions = (
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
maxSize = IMAGE_MAX_SIZE,
|
||||
) => {
|
||||
let width = naturalWidth;
|
||||
let height = naturalHeight;
|
||||
|
||||
if (width > maxSize || height > maxSize) {
|
||||
const ratio = Math.min(maxSize / width, maxSize / height);
|
||||
width = Math.round(width * ratio);
|
||||
height = Math.round(height * ratio);
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const createImageObject = (
|
||||
imageUrl: string,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
stageWidth?: number,
|
||||
stageHeight?: number,
|
||||
) => {
|
||||
let w = width;
|
||||
let h = height;
|
||||
if (stageWidth && stageHeight) {
|
||||
({ width: w, height: h } = fitSizeToStage(w, h, stageWidth, stageHeight));
|
||||
}
|
||||
let x = Math.round(centerX - w / 2);
|
||||
let y = Math.round(centerY - h / 2);
|
||||
if (stageWidth && stageHeight) {
|
||||
({ x, y } = clampPositionToStage(x, y, w, h, stageWidth, stageHeight));
|
||||
}
|
||||
return {
|
||||
id: `image-${Date.now()}`,
|
||||
type: "image" as ToolType,
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
imageUrl,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
export const fitSizeToStage = (
|
||||
width: number,
|
||||
height: number,
|
||||
stageWidth: number,
|
||||
stageHeight: number,
|
||||
) => {
|
||||
if (width <= stageWidth && height <= stageHeight) {
|
||||
return { width, height };
|
||||
}
|
||||
const ratio = Math.min(stageWidth / width, stageHeight / height);
|
||||
return {
|
||||
width: Math.round(width * ratio),
|
||||
height: Math.round(height * ratio),
|
||||
};
|
||||
};
|
||||
|
||||
export const clampPositionToStage = (
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
stageWidth: number,
|
||||
stageHeight: number,
|
||||
) => ({
|
||||
x: Math.max(0, Math.min(stageWidth - width, Math.round(x))),
|
||||
y: Math.max(0, Math.min(stageHeight - height, Math.round(y))),
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ToolType } from "../store/editorStore.types";
|
||||
import { clampPositionToStage, fitSizeToStage } from "./stageBounds";
|
||||
|
||||
const STICKER_MAX_SIZE = 120;
|
||||
|
||||
export const scaleStickerDimensions = (
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
maxSize = STICKER_MAX_SIZE,
|
||||
) => {
|
||||
let width = naturalWidth;
|
||||
let height = naturalHeight;
|
||||
|
||||
if (width > maxSize || height > maxSize) {
|
||||
const ratio = Math.min(maxSize / width, maxSize / height);
|
||||
width = Math.round(width * ratio);
|
||||
height = Math.round(height * ratio);
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const createStickerObject = (
|
||||
imageUrl: string,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
stageWidth?: number,
|
||||
stageHeight?: number,
|
||||
) => {
|
||||
let w = width;
|
||||
let h = height;
|
||||
if (stageWidth && stageHeight) {
|
||||
({ width: w, height: h } = fitSizeToStage(w, h, stageWidth, stageHeight));
|
||||
}
|
||||
let x = Math.round(centerX - w / 2);
|
||||
let y = Math.round(centerY - h / 2);
|
||||
if (stageWidth && stageHeight) {
|
||||
({ x, y } = clampPositionToStage(x, y, w, h, stageWidth, stageHeight));
|
||||
}
|
||||
return {
|
||||
id: `sticker-${Date.now()}`,
|
||||
type: "sticker" as ToolType,
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
imageUrl,
|
||||
};
|
||||
};
|
||||
@@ -144,7 +144,7 @@ export const getEffectiveTextWidth = (
|
||||
return Math.max(1, base + SINGLE_LINE_WIDTH_SLACK_PX);
|
||||
};
|
||||
|
||||
/** Scaled width for viewer text (wrapped / multi-line layout only). */
|
||||
/** Scaled width for viewer text boxes (includes slack to avoid tighter CSS wrapping than Konva). */
|
||||
export const getScaledTextWidth = (
|
||||
width: number | undefined,
|
||||
scale: number,
|
||||
@@ -152,3 +152,68 @@ export const getScaledTextWidth = (
|
||||
if (!width) return undefined;
|
||||
return Math.ceil(width * scale) + Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale);
|
||||
};
|
||||
|
||||
export type TextAlignValue = "left" | "center" | "right";
|
||||
|
||||
export type ViewerTextLayout = {
|
||||
leftPx: number;
|
||||
widthPx: number | undefined;
|
||||
textAlign: TextAlignValue;
|
||||
transform: string | undefined;
|
||||
transformOrigin: "top right";
|
||||
};
|
||||
|
||||
/**
|
||||
* Viewer HTML layout matching Konva TextShape: (x, y) is the top-right anchor of the text box.
|
||||
* Text alignment is applied inside a fixed-width box that extends left from the anchor.
|
||||
*/
|
||||
export function getViewerTextLayout(args: {
|
||||
x: number;
|
||||
width?: number;
|
||||
text?: string;
|
||||
textAlign?: TextAlignValue;
|
||||
rotation?: number;
|
||||
scale: number;
|
||||
measureOpts: TextMeasureOptions;
|
||||
wrapped: boolean;
|
||||
}): ViewerTextLayout {
|
||||
const {
|
||||
x,
|
||||
width: storedWidth,
|
||||
text,
|
||||
textAlign = "right",
|
||||
rotation = 0,
|
||||
scale,
|
||||
measureOpts,
|
||||
wrapped,
|
||||
} = args;
|
||||
|
||||
const layoutWidth = getEffectiveTextWidth(
|
||||
storedWidth,
|
||||
text,
|
||||
measureOpts,
|
||||
!wrapped,
|
||||
);
|
||||
const widthPx = getScaledTextWidth(layoutWidth, scale);
|
||||
const slackPx =
|
||||
widthPx !== undefined ? Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale) : 0;
|
||||
const rightPx = x * scale;
|
||||
const boxWidthUnscaled = layoutWidth ?? storedWidth ?? 0;
|
||||
|
||||
let leftPx: number;
|
||||
if (widthPx !== undefined && boxWidthUnscaled > 0) {
|
||||
leftPx = Math.round(rightPx - boxWidthUnscaled * scale) - slackPx;
|
||||
} else {
|
||||
leftPx = Math.round(rightPx);
|
||||
}
|
||||
|
||||
const rotationTransform = rotation ? `rotate(${rotation}deg)` : undefined;
|
||||
|
||||
return {
|
||||
leftPx,
|
||||
widthPx,
|
||||
textAlign,
|
||||
transform: rotationTransform,
|
||||
transformOrigin: "top right",
|
||||
};
|
||||
}
|
||||
|
||||
+44
-52
@@ -9,20 +9,29 @@ import { useCreateCatalog } from './hooks/useHomeData'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import DirectLogin from '../auth/components/DirectLogin'
|
||||
import { extractErrorMessage } from '@/helpers/utils'
|
||||
import { clx, extractErrorMessage } from '@/helpers/utils'
|
||||
|
||||
const CATALOG_SIZES = [
|
||||
{ id: 'a4', label: 'A4' },
|
||||
{ id: 'a3', label: 'A3' },
|
||||
{ id: 'a5', label: 'A5' },
|
||||
] as const
|
||||
|
||||
const Home: FC = () => {
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [active, setActive] = useState<string>('a4')
|
||||
const [name, setName] = useState<string>('')
|
||||
const [slug, setSlug] = useState<string>('')
|
||||
const { mutate: createCatalog, isPending } = useCreateCatalog()
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!name) {
|
||||
toast('نام کاتالوگ را وارد کنید', 'error')
|
||||
} else if (!slug) {
|
||||
toast('اسلاگ کاتالوگ را وارد کنید', 'error')
|
||||
} else {
|
||||
createCatalog({ name: name, size: active, content: '' }, {
|
||||
createCatalog({ name, slug, size: active, content: '' }, {
|
||||
onSuccess: (data) => {
|
||||
toast('کاتالوگ با موفقیت ساخته شد', 'success')
|
||||
navigate(Paths.editor + `/${data?.data?.id}`)
|
||||
@@ -36,75 +45,58 @@ const Home: FC = () => {
|
||||
|
||||
return (
|
||||
<div className='mt-4 w-full'>
|
||||
<div className='flex justify-between'>
|
||||
<h1 className='text-lg font-light'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<h1 className='text-base font-light sm:text-lg'>
|
||||
ساخت کاتالوگ ب ویرایشگر داناک
|
||||
</h1>
|
||||
<DirectLogin />
|
||||
</div>
|
||||
|
||||
<div className='mt-20 bg-white rounded-4xl p-10'>
|
||||
<div className='text-center'>
|
||||
<div className='mt-8 rounded-2xl bg-white p-4 sm:mt-12 sm:rounded-3xl sm:p-6 lg:mt-20 lg:rounded-4xl lg:p-10'>
|
||||
<div className='mx-auto max-w-xl px-1 text-center text-sm leading-relaxed sm:text-base'>
|
||||
سایز و نام مورد نظر را انتخاب کنبد و یک کاتالوگ زیبا با ویرایشگر داناک بسازید
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mt-8 flex justify-center gap-8'>
|
||||
<div onClick={() => setActive('a4')} className={`
|
||||
w-[160px] h-[166px] rounded-[20px] border border-border flex items-center justify-center
|
||||
${active === 'a4' && 'border-black!'}
|
||||
`}>
|
||||
<div className='mt-6 flex flex-wrap justify-center gap-4 sm:mt-8 sm:gap-6 lg:gap-8'>
|
||||
{CATALOG_SIZES.map(({ id, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type='button'
|
||||
onClick={() => setActive(id)}
|
||||
className={clx(
|
||||
'flex w-full h-[126px] sm:w-[120px] cursor-pointer items-center justify-center rounded-[20px] border border-border sm:h-[146px] sm:w-[140px] lg:h-[166px] lg:w-[160px]',
|
||||
active === id && 'border-black!',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className='size-[94px] bg-[#EEF0F7] rounded flex justify-center items-center'>
|
||||
<div className='flex size-[72px] items-center justify-center rounded bg-[#EEF0F7] sm:size-[84px] lg:size-[94px]'>
|
||||
<DocumentText size={24} color='black' />
|
||||
</div>
|
||||
|
||||
<div className='mt-3 text-center text-sm'>
|
||||
A4
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={() => setActive('a3')} className={`
|
||||
w-[160px] h-[166px] rounded-[20px] border border-border flex items-center justify-center
|
||||
${active === 'a3' && 'border-black!'}
|
||||
`}>
|
||||
<div>
|
||||
<div className='size-[94px] bg-[#EEF0F7] rounded flex justify-center items-center'>
|
||||
<DocumentText size={24} color='black' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='mt-3 text-center text-sm'>
|
||||
A3
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={() => setActive('a5')} className={`
|
||||
w-[160px] h-[166px] rounded-[20px] border border-border flex items-center justify-center
|
||||
${active === 'a5' && 'border-black!'}
|
||||
`}>
|
||||
<div>
|
||||
<div className='size-[94px] bg-[#EEF0F7] rounded flex justify-center items-center'>
|
||||
<DocumentText size={24} color='black' />
|
||||
</div>
|
||||
|
||||
<div className='mt-3 text-center text-sm'>
|
||||
A5
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-5'>
|
||||
<div className='mx-auto mt-5 flex w-full max-w-md flex-col gap-4 px-1 sm:px-0'>
|
||||
<Input
|
||||
label='نام کاتالوگ'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label='اسلاگ'
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<Button disabled={isPending} onClick={handleSubmit} className='mt-8 rounded-xl'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Button disabled={isPending} onClick={handleSubmit} className='mt-6 w-full max-w-md rounded-xl sm:mt-8 sm:w-auto'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
ادامه
|
||||
<ArrowLeft size={16} color='white' />
|
||||
</div>
|
||||
@@ -112,16 +104,16 @@ const Home: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider label='یا' className='mt-10' />
|
||||
<Divider label='یا' className='mt-8 sm:mt-10' />
|
||||
|
||||
<div className='mt-20 flex justify-center'>
|
||||
<div className='w-[183px] h-[193px] rounded-[18px] border flex flex-col items-center justify-center'>
|
||||
<img src={PdfIcon} alt='upload' className='w-12' />
|
||||
<div className='text-sm mt-3'>
|
||||
<div className='mt-12 flex justify-center sm:mt-16 lg:mt-20'>
|
||||
<div className='flex h-[160px] w-[150px] flex-col items-center justify-center rounded-[18px] border sm:h-[193px] sm:w-[183px]'>
|
||||
<img src={PdfIcon} alt='upload' className='w-10 sm:w-12' />
|
||||
<div className='mt-3 text-sm'>
|
||||
آپلود PDF
|
||||
</div>
|
||||
|
||||
<div className='h-5 px-5 rounded-full flex items-center bg-[#D4EDDE] text-[#01973D] text-[10px] mt-3'>
|
||||
<div className='mt-3 flex h-5 items-center rounded-full bg-[#D4EDDE] px-5 text-[10px] text-[#01973D]'>
|
||||
رایگان
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,9 @@ export const useGetCatalogById = (id: string) => {
|
||||
queryKey: ["catalog", id],
|
||||
queryFn: () => api.getCatalogById(id),
|
||||
enabled: !!id,
|
||||
// جلوگیری از بازنویسی state محلی با refetch هنگام فوکوس پنجره (مثلاً دو کاربر همزمان)
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -38,6 +41,15 @@ export const useUpdateCatalog = () => {
|
||||
const pendingParamsRef = useRef<Parameters<typeof api.updateCatalog>[0] | null>(null);
|
||||
const [isScheduledSaving, setIsScheduledSaving] = useState(false);
|
||||
|
||||
const cancelPendingUpdate = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
pendingParamsRef.current = null;
|
||||
setIsScheduledSaving(false);
|
||||
}, []);
|
||||
|
||||
const scheduleUpdate = useCallback(
|
||||
(params: Parameters<typeof api.updateCatalog>[0], delayMs = 3000) => {
|
||||
if (timeoutRef.current) {
|
||||
@@ -79,6 +91,7 @@ export const useUpdateCatalog = () => {
|
||||
return {
|
||||
...mutation,
|
||||
scheduleUpdate,
|
||||
cancelPendingUpdate,
|
||||
isSaving: isScheduledSaving || mutation.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import axios from "@/config/axios";
|
||||
import type {
|
||||
CreateCatalogParamsType,
|
||||
UpdateCatalogParamsType,
|
||||
} from "../types/Types";
|
||||
import type {
|
||||
CatalogByIdResponseType,
|
||||
CatalogResponseType,
|
||||
} from "@/pages/catalogue/types/Types";
|
||||
import type {
|
||||
CreateCatalogParamsType,
|
||||
UpdateCatalogParamsType,
|
||||
} from "../types/Types";
|
||||
|
||||
export const createCatalog = async (params: CreateCatalogParamsType) => {
|
||||
const { data } = await axios.post("/admin/catalogue", params);
|
||||
@@ -20,7 +20,7 @@ export const getCatalogs = async () => {
|
||||
|
||||
export const getCatalogById = async (id: string) => {
|
||||
const { data } = await axios.get<CatalogByIdResponseType>(
|
||||
`/admin/catalogue/${id}`,
|
||||
`/public/catalogue/${id}`,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type CreateCatalogParamsType = {
|
||||
name: string;
|
||||
slug: string;
|
||||
size: string;
|
||||
content?: string;
|
||||
id?: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PageData } from './types';
|
||||
import type { DocumentSettings } from '@/pages/editor/store/editorStore';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetCatalogById } from '@/pages/home/hooks/useHomeData';
|
||||
import { useBrowserFullscreen } from './hooks/useBrowserFullscreen';
|
||||
|
||||
const Viewer: FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -30,6 +31,8 @@ const Viewer: FC = () => {
|
||||
}
|
||||
}, [data?.data?.content, id]);
|
||||
|
||||
useBrowserFullscreen({ enabled: Boolean(id) });
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
@@ -63,7 +66,7 @@ const Viewer: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="w-full h-full min-h-0 flex flex-col">
|
||||
<BookViewer pages={pages} catalogSize={data?.data?.size} documentSettings={documentSettings} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,8 @@ import { toCssLinearGradient } from '@/pages/editor/utils/gradient';
|
||||
import { getFontFamily } from '@/pages/editor/utils/fontFamily';
|
||||
import {
|
||||
getCssFontWeight,
|
||||
getEffectiveTextWidth,
|
||||
getScaledTextWidth,
|
||||
getViewerTextLayout,
|
||||
usesWrappedLayout,
|
||||
SINGLE_LINE_WIDTH_SLACK_PX,
|
||||
} from '@/pages/editor/utils/textStyle';
|
||||
import '@/pages/viewer/styles/entranceAnimations.css';
|
||||
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
||||
@@ -131,51 +129,41 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
letterSpacing: obj.letterSpacing ?? 0,
|
||||
lineHeight,
|
||||
};
|
||||
const layoutWidth = wrapped
|
||||
? getEffectiveTextWidth(obj.width, obj.text, measureOpts, false)
|
||||
: undefined;
|
||||
const textWidth = wrapped ? getScaledTextWidth(layoutWidth, scale) : undefined;
|
||||
// getScaledTextWidth adds SINGLE_LINE_WIDTH_SLACK_PX to prevent unwanted CSS wrapping.
|
||||
// Compensate by shifting textLeft left by the same amount so the right edge stays at obj.x * scale.
|
||||
const textSlackPx = textWidth !== undefined ? Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale) : 0;
|
||||
const textLeft = wrapped
|
||||
? Math.round(((obj.x || 0) - (layoutWidth || obj.width || 0)) * scale) - textSlackPx
|
||||
: Math.round((obj.x || 0) * scale);
|
||||
const textTransform = wrapped
|
||||
? rotation
|
||||
? `rotate(${rotation}deg)`
|
||||
: undefined
|
||||
: rotation
|
||||
? `rotate(${rotation}deg) translateX(-100%)`
|
||||
: 'translateX(-100%)';
|
||||
const textLayout = getViewerTextLayout({
|
||||
x: obj.x || 0,
|
||||
width: obj.width,
|
||||
text: obj.text,
|
||||
textAlign: obj.textAlign,
|
||||
rotation,
|
||||
scale,
|
||||
measureOpts,
|
||||
wrapped,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={withEntrance(
|
||||
{
|
||||
...textBaseStyle,
|
||||
left: `${textLeft}px`,
|
||||
width: wrapped ? (textWidth ? `${textWidth}px` : undefined) : 'max-content',
|
||||
maxWidth: wrapped ? undefined : 'none',
|
||||
left: `${textLayout.leftPx}px`,
|
||||
width: textLayout.widthPx !== undefined ? `${textLayout.widthPx}px` : 'max-content',
|
||||
fontSize: `${fontSize * scale}px`,
|
||||
fontFamily: getFontFamily(obj.fontFamily),
|
||||
fontWeight: getCssFontWeight(obj.fontWeight),
|
||||
lineHeight,
|
||||
textAlign: obj.textAlign ?? 'right',
|
||||
textAlign: textLayout.textAlign,
|
||||
direction: 'rtl',
|
||||
color: getColorWithOpacity(obj.fill, obj.opacity),
|
||||
whiteSpace: wrapped ? 'pre-wrap' : 'nowrap',
|
||||
overflowWrap: wrapped ? 'break-word' : 'normal',
|
||||
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
|
||||
boxSizing: 'content-box',
|
||||
transform: textTransform,
|
||||
transformOrigin: wrapped ? 'top left' : 'top right',
|
||||
transform: textLayout.transform,
|
||||
transformOrigin: textLayout.transformOrigin,
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
wrapped
|
||||
? { rotationDeg: rotation, transformOrigin: 'top left' }
|
||||
: { rotationDeg: rotation, transformOrigin: 'top right' },
|
||||
{ rotationDeg: rotation, transformOrigin: textLayout.transformOrigin },
|
||||
)}
|
||||
>
|
||||
{obj.text || ''}
|
||||
@@ -184,6 +172,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
}
|
||||
|
||||
case 'image':
|
||||
case 'sticker':
|
||||
return (
|
||||
<img
|
||||
key={obj.id || index}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { type FC, useRef, useCallback, useState, useEffect, useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
import { type FC, useRef, useCallback, useState, useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
import HTMLFlipBook from 'react-pageflip';
|
||||
import { type PageData } from '../types';
|
||||
import BookPage from './BookPage';
|
||||
import { ArrowLeft2, ArrowRight2, Pause, Play } from 'iconsax-react';
|
||||
import { ArrowLeft2, ArrowRight2, Maximize4, Pause, Play, RowVertical } from 'iconsax-react';
|
||||
import { getPaperDimensions } from '@/config/paperSizes';
|
||||
import type { DocumentSettings } from '@/pages/editor/store/editorStore';
|
||||
import { useBookEntranceController } from '@/pages/viewer/hooks/useBookEntranceController';
|
||||
import { useViewerViewport } from '@/pages/viewer/hooks/useViewerViewport';
|
||||
import { useIsBrowserFullscreen } from '@/pages/viewer/hooks/useIsBrowserFullscreen';
|
||||
import { toggleViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||
|
||||
/** فاصله پیشفرض بین هر ورق در پخش خودکار (میلیثانیه) */
|
||||
const AUTO_PLAY_INTERVAL_MS = 3000;
|
||||
|
||||
const VIEWER_SCALE = 0.5;
|
||||
/** فاصلهٔ کتاب از بالای صفحه (پیکسل) */
|
||||
const BOOK_TOP_GAP_DESKTOP = 48;
|
||||
const BOOK_TOP_GAP_MOBILE = 32;
|
||||
/** نسخهٔ فعلی `page-flip.mp3` دو ضربهٔ ورق پشتهم دارد؛ فقط تا این سهم از طول پخش میشود */
|
||||
const PAGE_FLIP_SOUND_PLAY_FRACTION = 0.5;
|
||||
/** همراستا با `md` تیلویند (768px) — موبایل: زیر این عرض */
|
||||
@@ -111,6 +117,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
const [isAutoPlayActive, setIsAutoPlayActive] = useState(false);
|
||||
const [startPage, setStartPage] = useState(0);
|
||||
const isDesktop = useIsDesktop();
|
||||
const isBrowserFullscreen = useIsBrowserFullscreen();
|
||||
|
||||
const autoPlay = documentSettings?.autoPlay ?? false;
|
||||
const pageFlipSound = documentSettings?.pageFlipSound ?? true;
|
||||
@@ -130,54 +137,34 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
() => getPaperDimensions(catalogSize ?? 'a4'),
|
||||
[catalogSize]
|
||||
);
|
||||
const displayWidth = Math.round(bookWidth * VIEWER_SCALE);
|
||||
const displayHeight = Math.round(bookHeight * VIEWER_SCALE);
|
||||
const hasMultiplePages = pages.length > 1;
|
||||
const viewport = useViewerViewport();
|
||||
const usePortrait = !isDesktop || displayStyle === 'single';
|
||||
|
||||
// موبایل: از ابعاد viewport برای جا دادن کتاب در صفحه
|
||||
const [viewport, setViewport] = useState(() =>
|
||||
typeof window !== 'undefined'
|
||||
? { w: window.innerWidth, h: window.innerHeight }
|
||||
: { w: 1200, h: 800 }
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight });
|
||||
onResize();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('orientationchange', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('orientationchange', onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mobileFit = useMemo(() => {
|
||||
if (isDesktop) return null;
|
||||
const padX = 20;
|
||||
const edgeBleed = 6;
|
||||
const reservedNav = 120;
|
||||
const viewportFit = useMemo(() => {
|
||||
const padX = isDesktop ? 48 : 20;
|
||||
const edgeBleed = isDesktop ? 0 : 6;
|
||||
const reservedNav = isDesktop ? 96 : 120;
|
||||
const reservedTop = isDesktop ? BOOK_TOP_GAP_DESKTOP : BOOK_TOP_GAP_MOBILE;
|
||||
const maxW = Math.max(1, viewport.w - padX - edgeBleed * 2);
|
||||
const maxH = Math.max(1, viewport.h - reservedNav - edgeBleed);
|
||||
const baseW = bookWidth * VIEWER_SCALE;
|
||||
const baseH = bookHeight * VIEWER_SCALE;
|
||||
const scale = Math.min(1, maxW / baseW, maxH / baseH);
|
||||
const maxH = Math.max(1, viewport.h - reservedNav - reservedTop - edgeBleed);
|
||||
const spreadCount = usePortrait ? 1 : 2;
|
||||
const basePageW = bookWidth * VIEWER_SCALE;
|
||||
const basePageH = bookHeight * VIEWER_SCALE;
|
||||
const baseSpreadW = basePageW * spreadCount;
|
||||
const scale = Math.min(maxW / baseSpreadW, maxH / basePageH);
|
||||
return {
|
||||
w: Math.max(1, Math.round(baseW * scale)),
|
||||
h: Math.max(1, Math.round(baseH * scale)),
|
||||
w: Math.max(1, Math.round(basePageW * scale)),
|
||||
h: Math.max(1, Math.round(basePageH * scale)),
|
||||
contentScale: VIEWER_SCALE * scale,
|
||||
};
|
||||
}, [isDesktop, bookWidth, bookHeight, viewport.w, viewport.h]);
|
||||
}, [isDesktop, bookWidth, bookHeight, viewport.w, viewport.h, usePortrait]);
|
||||
|
||||
// دسکتاپ: دقیقاً مثل قبل — موبایل: مقیاسشده
|
||||
const pagePixelWidth = isDesktop ? displayWidth : (mobileFit?.w ?? displayWidth);
|
||||
const pagePixelHeight = isDesktop ? displayHeight : (mobileFit?.h ?? displayHeight);
|
||||
const contentScale = isDesktop ? VIEWER_SCALE : (mobileFit?.contentScale ?? VIEWER_SCALE);
|
||||
const pagePixelWidth = viewportFit.w;
|
||||
const pagePixelHeight = viewportFit.h;
|
||||
const contentScale = viewportFit.contentScale;
|
||||
|
||||
const flipKey = isDesktop
|
||||
? 'desktop'
|
||||
: `mobile-${pagePixelWidth}-${pagePixelHeight}`;
|
||||
|
||||
const usePortrait = !isDesktop || displayStyle === 'single';
|
||||
const flipKey = `${usePortrait ? 'portrait' : 'spread'}-${pagePixelWidth}-${pagePixelHeight}`;
|
||||
|
||||
const {
|
||||
scheduleEntranceForSpread,
|
||||
@@ -455,9 +442,10 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
dir="rtl"
|
||||
>
|
||||
<div
|
||||
className="relative w-full flex justify-center flex-1 max-md:min-h-0 items-center"
|
||||
className="relative w-full flex flex-col flex-1 max-md:min-h-0 pt-8 md:pt-12 min-h-0"
|
||||
style={{ overflow: isDesktop ? 'hidden' : 'visible' }}
|
||||
>
|
||||
<div className="relative flex flex-1 min-h-0 w-full items-center justify-center">
|
||||
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحهای دسکتاپ) */}
|
||||
{!usePortrait && (
|
||||
<div
|
||||
@@ -524,6 +512,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
</HTMLFlipBook>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 max-md:min-h-12 flex items-center gap-3 md:gap-6 bg-white rounded-full px-4 md:px-6 py-2 md:py-3 shadow-lg">
|
||||
{hasMultiplePages && (
|
||||
@@ -582,6 +571,20 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
{hasMultiplePages && autoPlay && (
|
||||
<AutoPlayToggleButton isActive={isAutoPlayActive} onToggle={toggleAutoPlay} />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleViewerFullscreen}
|
||||
className="p-2 md:p-2 min-h-11 min-w-11 md:min-h-0 md:min-w-0 inline-flex items-center justify-center rounded-full hover:bg-gray-100 active:bg-gray-200 transition-all"
|
||||
aria-label={isBrowserFullscreen ? 'خروج از تمامصفحه' : 'تمامصفحه مرورگر'}
|
||||
title={isBrowserFullscreen ? 'خروج از تمامصفحه' : 'تمامصفحه مرورگر'}
|
||||
>
|
||||
{isBrowserFullscreen ? (
|
||||
<RowVertical color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
|
||||
) : (
|
||||
<Maximize4 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
DEFAULT_ENTRANCE_DURATION_MS,
|
||||
ENTRANCE_ANIMATION_BASE_DELAY_MS,
|
||||
MAX_ENTRANCE_DELAY_MS,
|
||||
} from "@/shared/entranceAnimation";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { exitViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||
|
||||
type Options = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/** Leaves browser fullscreen when the viewer route unmounts. */
|
||||
export function useBrowserFullscreen({ enabled }: Options) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return () => {
|
||||
exitViewerFullscreen();
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { isViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||
|
||||
function subscribeFullscreen(callback: () => void) {
|
||||
document.addEventListener('fullscreenchange', callback);
|
||||
return () => document.removeEventListener('fullscreenchange', callback);
|
||||
}
|
||||
|
||||
export function useIsBrowserFullscreen() {
|
||||
return useSyncExternalStore(subscribeFullscreen, isViewerFullscreen, () => false);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
export type ViewerViewport = { w: number; h: number };
|
||||
|
||||
function readViewport(): ViewerViewport {
|
||||
const vv = window.visualViewport;
|
||||
return {
|
||||
w: Math.round(vv?.width ?? window.innerWidth),
|
||||
h: Math.round(vv?.height ?? window.innerHeight),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tracks layout size; updates on resize, orientation, fullscreen, and visualViewport changes. */
|
||||
export function useViewerViewport(): ViewerViewport {
|
||||
const [viewport, setViewport] = useState<ViewerViewport>(() =>
|
||||
typeof window !== 'undefined' ? readViewport() : { w: 1200, h: 800 },
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const update = () => setViewport(readViewport());
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
document.addEventListener('fullscreenchange', update);
|
||||
window.visualViewport?.addEventListener('resize', update);
|
||||
window.visualViewport?.addEventListener('scroll', update);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
document.removeEventListener('fullscreenchange', update);
|
||||
window.visualViewport?.removeEventListener('resize', update);
|
||||
window.visualViewport?.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return viewport;
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
||||
baseObject.borderRadius = obj.borderRadius;
|
||||
}
|
||||
|
||||
if (obj.type === "image" && obj.imageUrl) {
|
||||
if ((obj.type === "image" || obj.type === "sticker") && obj.imageUrl) {
|
||||
baseObject.imageUrl = obj.imageUrl;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Enter browser fullscreen (must run inside a user-gesture handler, e.g. link click). */
|
||||
export function requestViewerFullscreen(): void {
|
||||
if (document.fullscreenElement) return;
|
||||
void document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
export function exitViewerFullscreen(): void {
|
||||
if (document.fullscreenElement !== document.documentElement) return;
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
export function toggleViewerFullscreen(): void {
|
||||
if (document.fullscreenElement) {
|
||||
exitViewerFullscreen();
|
||||
} else {
|
||||
requestViewerFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
export function isViewerFullscreen(): boolean {
|
||||
return document.fullscreenElement === document.documentElement;
|
||||
}
|
||||
+65
-15
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -13,30 +14,79 @@ import DesignerRequest from '@/pages/designer/Request'
|
||||
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)
|
||||
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='flex min-h-full flex-col overflow-hidden p-4'>
|
||||
{shouldRenderSideBar ? <SideBar /> : null}
|
||||
<Header hasMainSidebar={hasSidebarSpace} sidebarSize={headerSidebarSize} />
|
||||
<div className={clx(
|
||||
'mt-[68px] flex flex-1 flex-col xl:mt-[81px]',
|
||||
shouldRenderSideBar && 'xl:ms-[269px]',
|
||||
shouldRenderSideBar && hasSubMenu && 'xl:ms-[305px]',
|
||||
routeHasLocalSidebar && 'xl:mr-[374px]',
|
||||
'flex min-h-full flex-col overflow-hidden',
|
||||
isViewerRoute ? 'fixed inset-0 z-50 bg-neutral-100' : 'p-4',
|
||||
)}>
|
||||
<div className='flex-1 flex flex-col overflow-auto w-full max-h-[calc(100vh-113px)]'>
|
||||
<div className='flex-1 h-full flex'>
|
||||
{!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={<Home />} />
|
||||
<Route path={Paths.editor + '/:id'} element={<Editor />} />
|
||||
<Route path={Paths.viewer + '/:id'} element={<Viewer />} />
|
||||
<Route path={Paths.catalog.list} element={<CatalogueList />} />
|
||||
<Route path={Paths.designer.request} element={<DesignerRequest />} />
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
type PrivateRouteProps = {
|
||||
children: ReactNode
|
||||
/** When true (default), user must be logged in to access the route */
|
||||
requireAuth?: boolean
|
||||
/** Redirect target when auth is required but user is not logged in */
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
const PrivateRoute: FC<PrivateRouteProps> = ({
|
||||
children,
|
||||
requireAuth = true,
|
||||
redirectTo = Paths.home,
|
||||
}) => {
|
||||
const { isAuthenticated, isChecking } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (!requireAuth) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-gray-600">در حال بارگذاری...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={redirectTo} replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default PrivateRoute
|
||||
@@ -8,6 +8,7 @@ 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'
|
||||
|
||||
type SidebarSize = 'default' | 'wide'
|
||||
|
||||
@@ -85,6 +86,7 @@ const Header: FC<HeaderProps> = ({ hasMainSidebar = true, sidebarSize = 'default
|
||||
className='flex items-center'
|
||||
aria-label={t('header.open_viewer')}
|
||||
title={t('header.open_viewer')}
|
||||
onClick={requestViewerFullscreen}
|
||||
>
|
||||
<Eye size={20} color='black' />
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user