diff --git a/src/common/storage.ts b/src/common/storage.ts index e6f9f4e0..cd1f2bcd 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -58,6 +58,13 @@ export async function removeFromStorage(key: K) { await storage.removeItem(`local:${key}`) } +export function watchStorage( + key: K, + callback: (newValue: StorageKV[K] | null, oldValue: StorageKV[K] | null) => void +) { + return storage.watch(`local:${key}`, callback) +} + export async function setWithExpiry( key: K, value: StorageKV[K], diff --git a/src/common/utils/call-event.ts b/src/common/utils/call-event.ts index a9d933ee..8995c38b 100644 --- a/src/common/utils/call-event.ts +++ b/src/common/utils/call-event.ts @@ -83,6 +83,7 @@ export interface EventName { | { instanceId?: string; widgetId?: string } | null | undefined + cancelWidgetDrag: null } export function callEvent(eventName: K, data?: EventName[K]) { diff --git a/src/context/free-widget.context.tsx b/src/context/free-widget.context.tsx index e7160ced..183d0ee8 100644 --- a/src/context/free-widget.context.tsx +++ b/src/context/free-widget.context.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react' import Analytics from '@/analytics' -import { setToStorage } from '@/common/storage' +import { setToStorage, watchStorage } from '@/common/storage' import { playNativeToastSound, showToast } from '@/common/toast' import { translateError } from '@/common/utils/translate-error' import { @@ -54,6 +54,12 @@ interface FreeWidgetContextType { setSelectedInstanceId: (id: string | null) => void resizeWidget: (instanceId: string, newSize: WidgetSize) => boolean moveWidget: (instanceId: string, targetPosition: WidgetPosition) => boolean + startDragPreview: () => void + updateDragPreview: (instanceId: string, targetPosition: WidgetPosition) => void + endDragPreview: ( + instanceId: string, + targetPosition: WidgetPosition | null + ) => void addWidget: ( id: string, targetPosition?: WidgetPosition, @@ -73,8 +79,22 @@ interface FreeWidgetContextType { export const FreeWidgetContext = createContext(null) +function sanitizeLayout(layout: StoredWidget[], cols: number): StoredWidget[] { + if (validateLayout(layout, cols)) { + return layout + } + + return ( + resolveLayoutChange({ + layout, + operation: 'responsive-reflow', + cols, + }) ?? layout + ) +} + export function FreeWidgetProvider({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isVip } = useAuth() + const { isAuthenticated, isVip, token } = useAuth() const { canvasMode, setCanvasMode, selectedInstanceId, setSelectedInstanceId } = useAppearance() @@ -91,24 +111,37 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) const containerWidthRef = useRef(1200) const syncTimerRef = useRef(null) const hasFetchedServerRef = useRef(false) + const hasLocalEditRef = useRef(false) + const prevTokenRef = useRef(undefined) + const lastPersistedSignatureRef = useRef(null) + const runtimeLayoutRef = useRef([]) + const dragBaseLayoutRef = useRef(null) + + useEffect(() => { + runtimeLayoutRef.current = runtimeLayout + }, [runtimeLayout]) const persistLayout = useCallback((layoutToPersist: StoredWidget[]) => { + lastPersistedSignatureRef.current = JSON.stringify(layoutToPersist) setToStorage('storedWidgets', layoutToPersist) }, []) const reflowForColumns = useCallback( (baseLayout: StoredWidget[], targetCols: number) => { + const safeLayout = sanitizeLayout(baseLayout, targetCols) + if (targetCols >= DEFAULT_COLS) { - return baseLayout + return safeLayout } const reflowed = resolveLayoutChange({ - layout: baseLayout, + layout: safeLayout, operation: 'responsive-reflow', cols: targetCols, + registry: WIDGET_DEFINITIONS, }) - return reflowed || baseLayout + return reflowed || safeLayout }, [] ) @@ -156,31 +189,62 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) [reflowForColumns] ) - useEffect(() => { - async function loadFromLocalStorage() { - try { - const localLayout = await migrateWidgetLayoutIfNeeded() - const finalLayout = - localLayout && localLayout.length > 0 - ? localLayout - : DEFAULT_WIDGET_LAYOUT - savedLayoutRef.current = finalLayout - setSavedLayout(finalLayout) - const reflowed = reflowForColumns(finalLayout, colsRef.current) - setRuntimeLayout(reflowed) - } catch (err) { - console.error('Failed to load local widget layout', err) - savedLayoutRef.current = DEFAULT_WIDGET_LAYOUT - setSavedLayout(DEFAULT_WIDGET_LAYOUT) - setRuntimeLayout(DEFAULT_WIDGET_LAYOUT) - } finally { - setIsLoaded(true) - } + const loadFromLocalStorage = useCallback(async () => { + try { + const localLayout = await migrateWidgetLayoutIfNeeded() + const finalLayout = sanitizeLayout( + localLayout && localLayout.length > 0 + ? localLayout + : DEFAULT_WIDGET_LAYOUT, + DEFAULT_COLS + ) + savedLayoutRef.current = finalLayout + setSavedLayout(finalLayout) + const reflowed = reflowForColumns(finalLayout, colsRef.current) + setRuntimeLayout(reflowed) + } catch (err) { + console.error('Failed to load local widget layout', err) + savedLayoutRef.current = DEFAULT_WIDGET_LAYOUT + setSavedLayout(DEFAULT_WIDGET_LAYOUT) + setRuntimeLayout(DEFAULT_WIDGET_LAYOUT) + } finally { + setIsLoaded(true) } + }, [reflowForColumns]) + useEffect(() => { loadFromLocalStorage() + }, [loadFromLocalStorage]) + + useEffect(() => { + const unwatch = watchStorage('storedWidgets', (newValue) => { + if (!newValue || newValue.length === 0) return + if (JSON.stringify(newValue) === lastPersistedSignatureRef.current) return + + savedLayoutRef.current = newValue + setSavedLayout(newValue) + setRuntimeLayout(reflowForColumns(newValue, colsRef.current)) + }) + return () => unwatch() }, [reflowForColumns]) + useEffect(() => { + if (prevTokenRef.current === undefined) { + prevTokenRef.current = token + return + } + if (prevTokenRef.current === token) return + prevTokenRef.current = token + + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current) + syncTimerRef.current = null + } + hasFetchedServerRef.current = false + hasLocalEditRef.current = false + loadFromLocalStorage() + }, [token, loadFromLocalStorage]) + useEffect(() => { if (!isAuthenticated || hasFetchedServerRef.current) return hasFetchedServerRef.current = true @@ -194,15 +258,22 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) } if (serverWidgets.length > 0) { - const fromSrv: StoredWidget[] = serverWidgets.map((sw) => ({ - id: sw.widgetKey as any, - instanceId: sw.instanceId, - widgetId: sw.instanceId, - position: { col: sw.col, row: sw.row }, - size: { w: sw.width, h: sw.height }, - meta: sw.meta, - disabled: sw.disabled, - })) + if (hasLocalEditRef.current) { + return + } + + const fromSrv: StoredWidget[] = sanitizeLayout( + serverWidgets.map((sw) => ({ + id: sw.widgetKey as any, + instanceId: sw.instanceId, + widgetId: sw.instanceId, + position: { col: sw.col, row: sw.row }, + size: { w: sw.width, h: sw.height }, + meta: sw.meta, + disabled: sw.disabled, + })), + DEFAULT_COLS + ) savedLayoutRef.current = fromSrv setSavedLayout(fromSrv) @@ -293,18 +364,57 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) meta: w.meta, disabled: w.disabled ?? false, })), - }).catch(() => {}) + }) + .then((synced) => { + if (!synced || synced.length === 0) return + + const idMap = new Map() + currentLayout.forEach((w, index) => { + const isValidId = + typeof w.instanceId === 'string' && + /^[0-9a-fA-F]{24}$/.test(w.instanceId) + if (isValidId) return + const matching = + synced.find((s) => s.widgetKey === w.id) || synced[index] + if (matching?.instanceId && matching.instanceId !== w.instanceId) { + idMap.set(w.instanceId, matching.instanceId) + } + }) + + if (idMap.size === 0) return + + const applyIdMap = (list: StoredWidget[]) => + list.map((w) => + idMap.has(w.instanceId) + ? { + ...w, + instanceId: idMap.get(w.instanceId) as string, + widgetId: idMap.get(w.instanceId) as string, + } + : w + ) + + setSavedLayout((prev) => { + const updated = applyIdMap(prev) + savedLayoutRef.current = updated + persistLayout(updated) + return updated + }) + setRuntimeLayout((prev) => applyIdMap(prev)) + }) + .catch(() => {}) }, 1000) }, - [isAuthenticated] + [isAuthenticated, persistLayout] ) const commitMutation = useCallback( (operation: string, nextRuntime: StoredWidget[], targetInstanceId?: string) => { - if (!validateLayout(nextRuntime, cols)) { + if (!validateLayout(nextRuntime, cols, WIDGET_DEFINITIONS)) { return false } + hasLocalEditRef.current = true setRuntimeLayout(nextRuntime) if (cols >= DEFAULT_COLS) { @@ -354,6 +464,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) instanceId, targetSize: newSize, cols, + registry: WIDGET_DEFINITIONS, }) if (!result) { @@ -400,6 +511,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) instanceId, targetSize: newSize, cols, + registry: WIDGET_DEFINITIONS, }) if (!result) { @@ -425,6 +537,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) instanceId, targetPosition, cols, + registry: WIDGET_DEFINITIONS, }) if (!result) { @@ -436,6 +549,71 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) [runtimeLayout, cols, commitMutation] ) + const startDragPreview = useCallback(() => { + dragBaseLayoutRef.current = runtimeLayoutRef.current + }, []) + + const updateDragPreview = useCallback( + (instanceId: string, targetPosition: WidgetPosition) => { + const base = dragBaseLayoutRef.current + if (!base) return + + const result = resolveLayoutChange({ + layout: base, + operation: 'move', + instanceId, + targetPosition, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (result) { + setRuntimeLayout(result) + } + }, + [] + ) + + const endDragPreview = useCallback( + (instanceId: string, targetPosition: WidgetPosition | null) => { + const base = dragBaseLayoutRef.current + dragBaseLayoutRef.current = null + if (!base) return + + const restore = () => { + if (runtimeLayoutRef.current !== base) { + setRuntimeLayout(base) + } + } + + const origin = base.find((w) => w.instanceId === instanceId)?.position + const isUnmoved = + origin && + targetPosition && + origin.col === targetPosition.col && + origin.row === targetPosition.row + + if (!targetPosition || isUnmoved) { + restore() + return + } + + const result = resolveLayoutChange({ + layout: base, + operation: 'move', + instanceId, + targetPosition, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result || !commitMutation('move', result, instanceId)) { + restore() + } + }, + [commitMutation] + ) + const addWidget = useCallback( async ( id: string, @@ -491,6 +669,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) newWidget, targetPosition, cols, + registry: WIDGET_DEFINITIONS, }) if (!result) { @@ -558,6 +737,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) instanceId, newWidget, cols, + registry: WIDGET_DEFINITIONS, }) if (!result) { @@ -609,6 +789,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) operation: 'remove', instanceId, cols, + registry: WIDGET_DEFINITIONS, }) if (!result) { @@ -653,6 +834,9 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) setSelectedInstanceId, resizeWidget, moveWidget, + startDragPreview, + updateDragPreview, + endDragPreview, addWidget, duplicateWidget, removeWidget, diff --git a/src/index.css b/src/index.css index ce0c0810..8c34bf13 100644 --- a/src/index.css +++ b/src/index.css @@ -292,5 +292,5 @@ img { } .widget-canvas-item-transition { - transition: left 200ms ease-out, top 200ms ease-out, width 200ms ease-out, height 200ms ease-out; + transition: left 200ms ease-out, top 200ms ease-out, width 200ms ease-out, height 200ms ease-out, transform 200ms ease-out; } \ No newline at end of file diff --git a/src/layouts/navbar/navbar.layout.tsx b/src/layouts/navbar/navbar.layout.tsx index 71b4b64b..9dfd70f7 100644 --- a/src/layouts/navbar/navbar.layout.tsx +++ b/src/layouts/navbar/navbar.layout.tsx @@ -10,6 +10,7 @@ import { MarketButton } from './market/market-button' import Analytics from '@/analytics' import { Page, usePage } from '@/context/page.context' import { useAuth } from '@/context/auth.context' +import { useAppearance } from '@/context/appearance.context' import { BlurModeButton } from '@/components/blur-mode/blur-mode.button' import type { UserProfile } from '@/services/hooks/user/user-service.hook' import { Tooltip } from '@/components/ui' @@ -91,6 +92,9 @@ export function NavbarLayout(): JSX.Element { const [showSettings, setShowSettings] = useState(false) const [isVisible, setIsVisible] = useState(false) const { user } = useAuth() + const { canvasMode } = useAppearance() + const isEditingCanvas = canvasMode === 'edit' + const showNavbar = isVisible && !isEditingCanvas const [tab, setTab] = useState(null) const handleOpenSettings = useCallback((tabName: string | null) => { setTab(tabName) @@ -126,7 +130,7 @@ export function NavbarLayout(): JSX.Element { useBirthdayConfetti(user?.isBirthdayToday || false) return ( <> - {!isVisible && ( + {!isVisible && !isEditingCanvas && ( + ) +} + +function RemoveFromPageButton({ onRemove }: { onRemove: () => void }) { + return ( + + ) } export function AddWidgetActions({ @@ -25,23 +55,17 @@ export function AddWidgetActions({ isDuplicateRestricted = false, selectedSize, onSave, + onRemove, }: AddWidgetActionsProps) { if (isVipRequired && !isVip) { return ( - + : 'ارتقا به پرو برای فعال‌سازی' + } + /> ) } @@ -59,21 +83,25 @@ export function AddWidgetActions({ ) } - if (isLimitReached) { + if (isCurrentlyActive && (isLimitReached || isDuplicateRestricted)) { return ( - +
+ + +
) } + if (isLimitReached) { + return + } + if (canAddCustom) { return ( - ) - } - if (isCurrentlyActive) { - return ( - - ) - } - - if (isLimitReached) { - return ( - - ) + return } return ( diff --git a/src/layouts/widgets-manager/add-widget-modal/index.tsx b/src/layouts/widgets-manager/add-widget-modal/index.tsx index 24defd9e..a588e3b5 100644 --- a/src/layouts/widgets-manager/add-widget-modal/index.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/index.tsx @@ -32,6 +32,7 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr const runtimeLayout = freeWidgets?.runtimeLayout || [] const addWidget = freeWidgets?.addWidget const updateWidgetVariant = freeWidgets?.updateWidgetVariant + const removeWidget = freeWidgets?.removeWidget const allDefinitions = useMemo(() => { return Object.values(WIDGET_DEFINITIONS) @@ -154,6 +155,13 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr ? !isVip && runtimeLayout.length >= maxFreeWidgets : !isVip && !isCurrentlyActive && visibility.length >= maxFreeWidgets + const handleRemove = () => { + if (!selectedDef || !removeWidget) return + const target = runtimeLayout.find((w) => w.id === selectedDef.id) + if (!target) return + removeWidget(target.instanceId) + } + const handleSave = async () => { if (!selectedDef) return @@ -243,7 +251,7 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr closeOnBackdropClick className="max-w-4xl md:max-w-5xl" > -
+
-
+
{selectedDef ? ( -
-
-
- - {selectedDef.emoji} - -
-

- {selectedDef.label} -

-

- {isCustom - ? selectedDef.canDuplicate - ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' - : 'ویجت تکی صفحه اصلی' - : 'ویجت در چیدمان استاندارد صفحه'} -

+
+
+
+
+ + {selectedDef.emoji} + +
+

+ {selectedDef.label} +

+

+ {isCustom + ? selectedDef.canDuplicate + ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' + : 'ویجت تکی صفحه اصلی' + : 'ویجت در چیدمان استاندارد صفحه'} +

+
+ + {selectedDef.settingsTab && ( + + )}
- {selectedDef.settingsTab && ( - + {isCustom && ( + )} -
- {isCustom && ( - - )} - - +
-
+
diff --git a/src/layouts/widgets-manager/add-widget-modal/sidebar.tsx b/src/layouts/widgets-manager/add-widget-modal/sidebar.tsx index 142282b6..478eb7ea 100644 --- a/src/layouts/widgets-manager/add-widget-modal/sidebar.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/sidebar.tsx @@ -35,8 +35,8 @@ export function AddWidgetSidebar({ onOpenWidgetSettings, }: AddWidgetSidebarProps) { return ( -
-
+
+
{CATEGORIES.map((cat) => (