Compare commits

...

10 Commits

Author SHA1 Message Date
hamid zarghami 83b2fd809c gallery:
deploy to danak / build_and_deploy (push) Has been cancelled
2026-05-25 09:35:57 +03:30
hamid zarghami cbdc7edbf5 auth provider + private route 2026-05-23 16:40:03 +03:30
hamid zarghami 612deba297 viewer do not need login 2026-05-23 16:21:19 +03:30
hamid zarghami 517c77af77 fix preview pages: 2026-05-23 14:06:25 +03:30
hamid zarghami ebb6c1c9c6 preview page in pages sections 2026-05-23 12:33:02 +03:30
hamid zarghami b6bff9956a dpage 2026-05-21 12:21:44 +03:30
Hamid 4390937b19 book viewer full screen 2026-05-17 01:50:20 -07:00
Hamid 642155661c fix text align 2026-05-17 01:26:34 -07:00
Hamid 5bbd677f41 check fix data 2026-05-17 00:50:23 -07:00
Hamid 7589a26706 fix build 2026-05-16 23:56:50 -07:00
49 changed files with 1234 additions and 417 deletions
+2 -2
View File
@@ -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
View File
@@ -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;
+33 -4
View File
@@ -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) => {
+11
View File
@@ -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)
);
};
+73
View File
@@ -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
View File
@@ -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>
+88 -1
View File
@@ -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}
+3 -2
View File
@@ -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>
+26 -2
View File
@@ -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 & {
+10
View File
@@ -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,
});
};
+7
View File
@@ -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;
};
+50 -16
View File
@@ -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;
};
+1
View File
@@ -0,0 +1 @@
export const GALLERY_DRAG_MIME = "application/x-dpage-gallery";
+23
View File
@@ -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";
+1 -1
View File
@@ -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";
+51
View File
@@ -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,
};
};
+27
View File
@@ -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))),
});
+51
View File
@@ -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,
};
};
+66 -1
View File
@@ -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
View File
@@ -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>
+13
View File
@@ -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,
};
};
+5 -5
View File
@@ -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
View File
@@ -1,5 +1,6 @@
export type CreateCatalogParamsType = {
name: string;
slug: string;
size: string;
content?: string;
id?: string;
+4 -1
View File
@@ -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>
);
+18 -29
View File
@@ -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}
+46 -43
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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>
+41
View File
@@ -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
+2
View File
@@ -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>