From 932ff6aba3ee81c29c3c19fd9b0fc676494d8458 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Sat, 15 Aug 2026 04:00:51 +0330 Subject: [PATCH 001/260] init liquid glass --- src/context/theme.context.tsx | 28 +- .../components/bookmark-empty-slot.tsx | 15 +- .../bookmark/components/bookmark-folder.tsx | 6 +- .../bookmark/components/bookmark-item.tsx | 2 +- src/services/theme/liquid-glass-renderer.ts | 406 ++++++++++++++++++ src/styles/theme-colors.css | 3 +- src/styles/theme/liquid-glass.css | 229 ++++++++++ 7 files changed, 670 insertions(+), 19 deletions(-) create mode 100644 src/services/theme/liquid-glass-renderer.ts create mode 100644 src/styles/theme/liquid-glass.css diff --git a/src/context/theme.context.tsx b/src/context/theme.context.tsx index 4082b7ec0..7bc28d12d 100644 --- a/src/context/theme.context.tsx +++ b/src/context/theme.context.tsx @@ -5,6 +5,7 @@ import { getFromStorage, setToStorage } from '@/common/storage' import { listenEvent } from '@/common/utils/call-event' import { useChangeTheme } from '@/services/hooks/extension/update-setting.hook' import { useAuth } from './auth.context' +import { liquidGlassRenderer } from '@/services/theme/liquid-glass-renderer' interface ThemeContextType { theme: string @@ -17,8 +18,15 @@ export enum Theme { Icy = 'icy', Zarna = 'zarna', esteghlal = 'esteghlal', + LiquidGlass = 'liquid-glass', } -const freeThemes = [Theme.Light, Theme.Dark, Theme.Glass, Theme.Icy, Theme.Zarna] +const freeThemes = [ + Theme.Light, + Theme.Dark, + Theme.Glass, + Theme.Icy, + Theme.Zarna, +] export const ThemeContext = createContext(null) @@ -51,6 +59,24 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { } }, []) + useEffect(() => { + if (theme === Theme.LiquidGlass || theme === 'liquid-glass') { + liquidGlassRenderer.mount() + } else { + liquidGlassRenderer.unmount() + } + + const wpUnsub = listenEvent('wallpaper_change', () => { + if (theme === Theme.LiquidGlass || theme === 'liquid-glass') { + liquidGlassRenderer.syncBackground() + } + }) + + return () => { + wpUnsub() + } + }, [theme]) + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( diff --git a/src/layouts/bookmark/components/bookmark-empty-slot.tsx b/src/layouts/bookmark/components/bookmark-empty-slot.tsx index 5c2cfc0d3..9eed31979 100644 --- a/src/layouts/bookmark/components/bookmark-empty-slot.tsx +++ b/src/layouts/bookmark/components/bookmark-empty-slot.tsx @@ -8,22 +8,11 @@ export function EmptyBookmarkSlot({ theme?: string canAdd: boolean }) { - const getBookmarkStyle = () => { - return 'bg-content hover:!bg-base-300 text-content backdrop-blur-sm border-content bg-glass' - } - - const getEmptySlotStyle = () => { - if (!canAdd) { - return `opacity-30 bg-content ${getBookmarkStyle()} cursor-default` - } - - return getBookmarkStyle() - } - + return ( -
- setActivePortal('image')} /> - setActivePortal('voice')} /> -
+ {!isCompact && ( +
+ setActivePortal('image')} /> + setActivePortal('voice')} /> +
+ )}
onSearchButtonClick()} diff --git a/src/layouts/widgetify-card/pets/setting/pet-setting.tsx b/src/layouts/widgetify-card/pets/setting/pet-setting.tsx index 79b3e1ff7..86c8c4d70 100644 --- a/src/layouts/widgetify-card/pets/setting/pet-setting.tsx +++ b/src/layouts/widgetify-card/pets/setting/pet-setting.tsx @@ -110,7 +110,7 @@ export function PetSettings() {

💡 نکات مراقبت:

-
    +
    • • برای بازی با حیوان خانگی، روی آن کلیک کنید
    • • برای غذا دادن به حیوان، در محیط اطراف کلیک کنید
    diff --git a/src/layouts/widgetify-card/widgetify.layout.tsx b/src/layouts/widgetify-card/widgetify.layout.tsx index 3eac98871..fa12732ae 100644 --- a/src/layouts/widgetify-card/widgetify.layout.tsx +++ b/src/layouts/widgetify-card/widgetify.layout.tsx @@ -3,9 +3,15 @@ import { NotificationCenter } from './notification-center/notification-center' import { Pet } from './pets/pet' import { PetProvider } from './pets/pet.context' -export const WidgetifyLayout = () => { +import type { WidgetSize } from '../widgets/layout-engine/types' + +interface WidgetifyLayoutProps { + size?: WidgetSize +} + +export const WidgetifyLayout: React.FC = ({ size }) => { return ( - +
    { @@ -13,7 +19,7 @@ export const WidgetifyLayout = () => { } -
    +
    diff --git a/src/layouts/widgets/calendar/calendar.tsx b/src/layouts/widgets/calendar/calendar.tsx index bd0224979..3bce57aa7 100644 --- a/src/layouts/widgets/calendar/calendar.tsx +++ b/src/layouts/widgets/calendar/calendar.tsx @@ -47,7 +47,13 @@ const CalendarTabSelector: React.FC = ({ ) } -const CalendarLayout: React.FC = () => { +import type { WidgetSize } from '../layout-engine/types' + +interface CalendarLayoutProps { + size?: WidgetSize +} + +const CalendarLayout: React.FC = ({ size }) => { const { currentDate, selectedDate, setCurrentDate, setSelectedDate, goToToday } = useDate() const [activeTab, setActiveTab] = useState('calendar') diff --git a/src/layouts/widgets/canvas/add-widget-modal.tsx b/src/layouts/widgets/canvas/add-widget-modal.tsx new file mode 100644 index 000000000..9be853c52 --- /dev/null +++ b/src/layouts/widgets/canvas/add-widget-modal.tsx @@ -0,0 +1,99 @@ +import { Modal } from '@/components/ui' +import { useFreeWidgets } from '@/context/free-widget.context' +import { WIDGET_DEFINITIONS } from '../widget-registry' +import { WidgetKeys } from '../layout-engine/types' + +interface AddWidgetModalProps { + isOpen: boolean + onClose: () => void +} + +export function AddWidgetModal({ isOpen, onClose }: AddWidgetModalProps) { + const { runtimeLayout, addWidget } = useFreeWidgets() + + const allDefinitions = Object.values(WIDGET_DEFINITIONS).filter( + (def) => def.id !== WidgetKeys.youtube + ) + + const handleAdd = (id: string) => { + const success = addWidget(id) + if (success) { + onClose() + } + } + + return ( + +
    + {allDefinitions.map((def) => { + const isCurrentlyActive = runtimeLayout.some((w) => w.id === def.id) + const canAdd = def.canDuplicate || !isCurrentlyActive + + return ( +
    canAdd && handleAdd(def.id)} + > +
    +
    + {def.emoji} + {def.label} +
    + + {def.defaultSize.w}×{def.defaultSize.h} + +
    + +
    + {def.preview()} +
    + +
    + {isCurrentlyActive && ( + + {def.canDuplicate + ? 'فعال (امکان تکرار)' + : 'قبلاً اضافه شده'} + + )} + {!isCurrentlyActive && ( + + آماده افزودن + + )} + + +
    +
    + ) + })} +
    +
    + ) +} diff --git a/src/layouts/widgets/canvas/canvas-context-menu.tsx b/src/layouts/widgets/canvas/canvas-context-menu.tsx new file mode 100644 index 000000000..940fa7f24 --- /dev/null +++ b/src/layouts/widgets/canvas/canvas-context-menu.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef } from 'react' +import { Icon } from '@/src/icons' + +interface CanvasContextMenuProps { + x: number + y: number + canvasMode: 'normal' | 'edit' + onClose: () => void + onToggleEditMode: () => void + onOpenAddWidget: () => void + onOpenAppearanceSettings: () => void + onResetLayout: () => void +} + +export function CanvasContextMenu({ + x, + y, + canvasMode, + onClose, + onToggleEditMode, + onOpenAddWidget, + onOpenAppearanceSettings, + onResetLayout, +}: CanvasContextMenuProps) { + const menuRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose() + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + const adjustedLeft = Math.min(Math.max(10, x), window.innerWidth - 220) + const adjustedTop = Math.min(Math.max(10, y), window.innerHeight - 220) + + return ( +
    e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + + + + + + +
    + + +
    + ) +} diff --git a/src/layouts/widgets/canvas/canvas-widget-outer.tsx b/src/layouts/widgets/canvas/canvas-widget-outer.tsx new file mode 100644 index 000000000..7ca16a2ce --- /dev/null +++ b/src/layouts/widgets/canvas/canvas-widget-outer.tsx @@ -0,0 +1,269 @@ +import type React from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { callEvent } from '@/common/utils/call-event' +import { useFreeWidgets } from '@/context/free-widget.context' +import { getWidgetPixelRect } from '../grid-geometry' +import type { + StoredWidget, + WidgetDefinition, + WidgetPosition, + WidgetSize, +} from '../layout-engine/types' +import { WidgetContextMenu } from './widget-context-menu' +import { WidgetSizeToolbar } from './widget-size-toolbar' + +interface CanvasWidgetOuterProps { + widget: StoredWidget + definition: WidgetDefinition + cellWidth: number + cellHeight: number + gap: number + cols: number +} + +export function CanvasWidgetOuter({ + widget, + definition, + cellWidth, + cellHeight, + gap, + cols, +}: CanvasWidgetOuterProps) { + const { + canvasMode, + setCanvasMode, + selectedInstanceId, + setSelectedInstanceId, + resizeWidget, + moveWidget, + duplicateWidget, + removeWidget, + } = useFreeWidgets() + + const [isDragging, setIsDragging] = useState(false) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + const [contextMenuPos, setContextMenuPos] = useState<{ + x: number + y: number + } | null>(null) + + const isSelected = canvasMode === 'edit' && selectedInstanceId === widget.instanceId + const isWiggling = canvasMode === 'edit' && selectedInstanceId !== widget.instanceId + + const longPressTimerRef = useRef | null>(null) + const pointerStartRef = useRef<{ x: number; y: number } | null>(null) + const dragStartPosRef = useRef(widget.position) + const isDragActiveRef = useRef(false) + + const pixelRect = getWidgetPixelRect( + widget.position, + widget.size, + cellWidth, + cellHeight, + gap + ) + + const clearLongPress = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + }, []) + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) return + + const target = e.target as HTMLElement + if ( + target.closest('button') || + target.closest('input') || + target.closest('textarea') || + target.closest('a') || + target.closest('.no-drag') + ) { + if (canvasMode !== 'edit') { + return + } + } + + clearLongPress() + pointerStartRef.current = { x: e.clientX, y: e.clientY } + dragStartPosRef.current = { ...widget.position } + isDragActiveRef.current = false + + if (canvasMode === 'normal') { + longPressTimerRef.current = setTimeout(() => { + setCanvasMode('edit') + setSelectedInstanceId(widget.instanceId) + clearLongPress() + }, 450) + } + + ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (!pointerStartRef.current) return + + const dx = e.clientX - pointerStartRef.current.x + const dy = e.clientY - pointerStartRef.current.y + const dist = Math.sqrt(dx * dx + dy * dy) + + if (dist > 8 && longPressTimerRef.current) { + clearLongPress() + } + + const dragThreshold = canvasMode === 'edit' ? 6 : 14 + + if (dist > dragThreshold && (canvasMode === 'edit' || isDragActiveRef.current)) { + if (!isDragActiveRef.current) { + isDragActiveRef.current = true + setIsDragging(true) + } + setDragOffset({ x: dx, y: dy }) + } + } + + const handlePointerUp = (e: React.PointerEvent) => { + clearLongPress() + + if (isDragActiveRef.current) { + const unitW = cellWidth + gap + const unitH = cellHeight + gap + + const deltaCol = unitW > 0 ? Math.round(dragOffset.x / unitW) : 0 + const deltaRow = unitH > 0 ? Math.round(dragOffset.y / unitH) : 0 + + const targetCol = Math.max( + 0, + Math.min(cols - widget.size.w, dragStartPosRef.current.col + deltaCol) + ) + const targetRow = Math.max(0, dragStartPosRef.current.row + deltaRow) + + if (targetCol !== widget.position.col || targetRow !== widget.position.row) { + moveWidget(widget.instanceId, { col: targetCol, row: targetRow }) + } + } else if (canvasMode === 'edit') { + setSelectedInstanceId(widget.instanceId) + } + + pointerStartRef.current = null + isDragActiveRef.current = false + setIsDragging(false) + setDragOffset({ x: 0, y: 0 }) + + try { + ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) + } catch {} + } + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPos({ x: e.clientX, y: e.clientY }) + } + + const handleResize = (newSize: WidgetSize) => { + resizeWidget(widget.instanceId, newSize) + } + + const handleDuplicate = () => { + duplicateWidget(widget.instanceId) + } + + const handleSettings = () => { + if (definition.settingsTab) { + callEvent('openWidgetsSettings', { tab: definition.settingsTab }) + } + } + + const handleDelete = () => { + removeWidget(widget.instanceId) + } + + const currentLeft = isDragging ? pixelRect.left + dragOffset.x : pixelRect.left + const currentTop = isDragging ? pixelRect.top + dragOffset.y : pixelRect.top + + return ( + <> +
    { + if (canvasMode === 'edit') { + const target = e.target as HTMLElement + if ( + target.closest('button[title="حذف ویجت"]') || + target.closest('.widget-size-toolbar') + ) { + return + } + e.preventDefault() + e.stopPropagation() + } + }} + > + {canvasMode === 'edit' && ( + + )} + +
    + {definition.node(widget.instanceId, widget.size)} + {canvasMode === 'edit' && ( +
    + )} +
    +
    + + {contextMenuPos && ( + setContextMenuPos(null)} + onResize={handleResize} + onDuplicate={handleDuplicate} + onSettings={definition.settingsTab ? handleSettings : undefined} + onDelete={handleDelete} + /> + )} + + ) +} diff --git a/src/layouts/widgets/canvas/free-widget-canvas.tsx b/src/layouts/widgets/canvas/free-widget-canvas.tsx new file mode 100644 index 000000000..bc6af3e30 --- /dev/null +++ b/src/layouts/widgets/canvas/free-widget-canvas.tsx @@ -0,0 +1,255 @@ +import type React from 'react' +import { useEffect, useRef, useState } from 'react' +import { callEvent } from '@/common/utils/call-event' +import { useContainerSize } from '@/hooks/use-container-size' +import { useFreeWidgets } from '@/context/free-widget.context' +import { getCanvasHeight } from '../grid-geometry' +import { WIDGET_DEFINITIONS } from '../widget-registry' +import { AddWidgetModal } from './add-widget-modal' +import { CanvasContextMenu } from './canvas-context-menu' +import { CanvasWidgetOuter } from './canvas-widget-outer' + +export function FreeWidgetCanvas() { + const containerRef = useRef(null) + const containerSize = useContainerSize(containerRef) + + const { + runtimeLayout, + cols, + cellWidth, + cellHeight, + gap, + isListFallback, + canvasMode, + setCanvasMode, + setSelectedInstanceId, + updateContainerWidth, + resetToDefaultLayout, + removeWidget, + } = useFreeWidgets() + + const [isAddModalOpen, setIsAddModalOpen] = useState(false) + const [canvasContextMenuPos, setCanvasContextMenuPos] = useState<{ + x: number + y: number + } | null>(null) + + useEffect(() => { + if (containerSize.width > 0) { + updateContainerWidth(containerSize.width) + } + }, [containerSize.width, updateContainerWidth]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && canvasMode === 'edit') { + setCanvasMode('normal') + setSelectedInstanceId(null) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [canvasMode, setCanvasMode, setSelectedInstanceId]) + + const handleCanvasClick = (e: React.MouseEvent) => { + if ( + e.target === containerRef.current || + (e.target as HTMLElement).classList.contains('canvas-background') + ) { + if (canvasMode === 'edit') { + setCanvasMode('normal') + setSelectedInstanceId(null) + } + } + } + + const handleCanvasContextMenu = (e: React.MouseEvent) => { + const target = e.target as HTMLElement + if ( + target === containerRef.current || + target.classList.contains('canvas-background') + ) { + e.preventDefault() + setCanvasContextMenuPos({ x: e.clientX, y: e.clientY }) + } + } + + const maxWidgetRow = Math.max( + 0, + ...runtimeLayout.map((w) => w.position.row + w.size.h) + ) + const totalGridRows = Math.max(4, maxWidgetRow + 1) + const canvasPixelHeight = Math.max( + totalGridRows * cellHeight + Math.max(0, totalGridRows - 1) * gap, + getCanvasHeight(runtimeLayout, cellHeight, gap) + ) + + if (isListFallback) { + const sortedList = [...runtimeLayout].sort((a, b) => { + if (a.position.row !== b.position.row) { + return a.position.row - b.position.row + } + return a.position.col - b.position.col + }) + + return ( +
    + {sortedList.map((widget) => { + const def = WIDGET_DEFINITIONS[widget.id] + if (!def) return null + + return ( +
    +
    +
    + {def.emoji} + {def.label} +
    +
    + {def.settingsTab && ( + + )} + +
    +
    +
    + {def.node(widget.instanceId, widget.size)} +
    +
    + ) + })} +
    + ) + } + + return ( +
    + {canvasMode === 'edit' && ( +
    + + حالت ویرایش ویجت‌ها فعال است + + + +
    + )} + +
    + {canvasMode === 'edit' && ( + + + {canvasContextMenuPos && ( + setCanvasContextMenuPos(null)} + onToggleEditMode={() => { + setCanvasMode(canvasMode === 'edit' ? 'normal' : 'edit') + setSelectedInstanceId(null) + }} + onOpenAddWidget={() => setIsAddModalOpen(true)} + onOpenAppearanceSettings={() => callEvent('openSettings')} + onResetLayout={resetToDefaultLayout} + /> + )} + + setIsAddModalOpen(false)} + /> +
    + ) +} diff --git a/src/layouts/widgets/canvas/widget-context-menu.tsx b/src/layouts/widgets/canvas/widget-context-menu.tsx new file mode 100644 index 000000000..90c13e4b0 --- /dev/null +++ b/src/layouts/widgets/canvas/widget-context-menu.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef } from 'react' +import { Icon } from '@/src/icons' +import type { StoredWidget, WidgetDefinition, WidgetSize } from '../layout-engine/types' + +interface WidgetContextMenuProps { + x: number + y: number + widget: StoredWidget + definition: WidgetDefinition + cols: number + onClose: () => void + onResize: (size: WidgetSize) => void + onDuplicate: () => void + onSettings?: () => void + onDelete: () => void +} + +export function WidgetContextMenu({ + x, + y, + widget, + definition, + cols, + onClose, + onResize, + onDuplicate, + onSettings, + onDelete, +}: WidgetContextMenuProps) { + const menuRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose() + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleKeyDown) + } + }, [onClose]) + + const fittingSizes = definition.allowedSizes.filter((s) => s.w <= cols) + + const adjustedLeft = Math.min(Math.max(10, x), window.innerWidth - 220) + const adjustedTop = Math.min(Math.max(10, y), window.innerHeight - 260) + + return ( +
    e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > +
    + + {definition.emoji} + {definition.label} + +
    + + {fittingSizes.length > 1 && ( +
    + + تغییر اندازه + +
    + {fittingSizes.map((size) => { + const isCurrent = + size.w === widget.size.w && size.h === widget.size.h + return ( + + ) + })} +
    +
    + )} + +
    + + {definition.canDuplicate && ( + + )} + + {definition.settingsTab && ( + + )} + + +
    + ) +} diff --git a/src/layouts/widgets/canvas/widget-size-toolbar.tsx b/src/layouts/widgets/canvas/widget-size-toolbar.tsx new file mode 100644 index 000000000..7492d161b --- /dev/null +++ b/src/layouts/widgets/canvas/widget-size-toolbar.tsx @@ -0,0 +1,51 @@ +import type { WidgetSize } from '../layout-engine/types' + +interface WidgetSizeToolbarProps { + allowedSizes: WidgetSize[] + currentSize: WidgetSize + cols: number + onSelectSize: (size: WidgetSize) => void +} + +export function WidgetSizeToolbar({ + allowedSizes, + currentSize, + cols, + onSelectSize, +}: WidgetSizeToolbarProps) { + const fittingSizes = allowedSizes.filter((size) => size.w <= cols) + + if (fittingSizes.length <= 1) { + return null + } + + return ( +
    e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + {fittingSizes.map((size) => { + const isCurrent = size.w === currentSize.w && size.h === currentSize.h + return ( + + ) + })} +
    + ) +} diff --git a/src/layouts/widgets/clock/clock.widget.tsx b/src/layouts/widgets/clock/clock.widget.tsx new file mode 100644 index 000000000..eeddf3ede --- /dev/null +++ b/src/layouts/widgets/clock/clock.widget.tsx @@ -0,0 +1,331 @@ +import { useEffect, useMemo, useState } from 'react' +import { getFromStorage } from '@/common/storage' +import { listenEvent } from '@/common/utils/call-event' +import { useGeneralSetting } from '@/context/general-setting.context' +import type { FetchedTimezone } from '@/services/hooks/timezone/get-timezones.hook' +import { type ClockSettings, ClockType } from '../wigi-pad/clock-display/clock-setting.interface' +import { WidgetContainer } from '../widget-container' +import type { WidgetSize } from '../layout-engine/types' + +interface ClockWidgetProps { + size?: WidgetSize +} + +function toPersianDigits(val: string | number): string { + return String(val).replace(/[0-9]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]) +} + +export function ClockWidget({ size = { w: 2, h: 1 } }: ClockWidgetProps) { + const [clockSettings, setClockSettings] = useState(null) + const { selected_timezone: timezone, isOptimalMode } = useGeneralSetting() + + useEffect(() => { + async function load() { + const clockFromStore = await getFromStorage('clock') + if (clockFromStore) { + setClockSettings(clockFromStore) + } else { + setClockSettings({ + clockType: ClockType.Digital, + showSeconds: false, + showTimeZone: false, + useSelectedFont: true, + }) + } + } + + const event = listenEvent('wigiPadClockSettingsChanged', (data) => { + setClockSettings({ + clockType: data.clockType, + showSeconds: data.showSeconds, + showTimeZone: data.showTimeZone, + useSelectedFont: data.useSelectedFont, + }) + }) + + load() + + return () => { + event() + } + }, []) + + if (!clockSettings) { + return null + } + + return ( + + + + ) +} + +interface ClockContentProps { + size: WidgetSize + setting: ClockSettings + timezone: FetchedTimezone + isOptimalMode: boolean +} + +function ClockContent({ + size, + setting, + timezone, + isOptimalMode, +}: ClockContentProps) { + const [time, setTime] = useState( + () => + new Date( + new Date().toLocaleString('en-US', { + timeZone: timezone?.value || undefined, + }) + ) + ) + + const showSeconds = setting.showSeconds && !isOptimalMode + + useEffect(() => { + const updateTime = () => { + setTime( + new Date( + new Date().toLocaleString('en-US', { + timeZone: timezone?.value || undefined, + }) + ) + ) + } + + updateTime() + const interval = showSeconds ? 1000 : 30_000 + const timer = setInterval(updateTime, interval) + + return () => clearInterval(timer) + }, [timezone?.value, showSeconds]) + + const rawHours = time.getHours().toString().padStart(2, '0') + const rawMinutes = time.getMinutes().toString().padStart(2, '0') + const rawSeconds = time.getSeconds().toString().padStart(2, '0') + + const hours = setting.useSelectedFont ? toPersianDigits(rawHours) : rawHours + const minutes = setting.useSelectedFont ? toPersianDigits(rawMinutes) : rawMinutes + const seconds = setting.useSelectedFont ? toPersianDigits(rawSeconds) : rawSeconds + + const timezoneLabel = useMemo(() => { + if (!timezone?.value) return 'UTC' + const parts = timezone.value.split('/') + if (parts.length > 1) { + return parts[1].replace(/_/g, ' ') + } + return timezone.value + }, [timezone]) + + const isAnalog = setting.clockType === ClockType.Analog + + if (size.w === 1) { + return ( +
    + {isAnalog ? ( + + ) : ( +
    + + {hours} + +
    + + {minutes} + + {showSeconds && ( + + :{seconds} + + )} +
    + )} +
    + ) + } + + if (size.w >= 4) { + return ( +
    +
    + {isAnalog && ( +
    + +
    + )} +
    +
    + + {hours} : {minutes} + + {showSeconds && ( + + :{seconds} + + )} +
    + {setting.showTimeZone && ( + + {timezone?.label || timezoneLabel} + + )} +
    +
    + +
    +
    + {timezoneLabel} +
    + + {time.toLocaleDateString('fa-IR', { + weekday: 'long', + month: 'short', + day: 'numeric', + })} + +
    +
    + ) + } + + return ( +
    + {isAnalog ? ( +
    + +
    + + {hours} : {minutes} + + {setting.showTimeZone && ( + + {timezoneLabel} + + )} +
    +
    + ) : ( +
    +
    + + {hours} : {minutes} + + {showSeconds && ( + + :{seconds} + + )} +
    + {setting.showTimeZone && ( +
    + {timezoneLabel} +
    + )} +
    + )} +
    + ) +} + +function MiniAnalogClock({ + time, + showSeconds, + size = 56, +}: { + time: Date + showSeconds: boolean + size?: number +}) { + const hours = time.getHours() % 12 + const minutes = time.getMinutes() + const seconds = time.getSeconds() + + const hourAngle = hours * 30 + minutes * 0.5 + const minuteAngle = minutes * 6 + const secondAngle = seconds * 6 + + return ( +
    + + {[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map( + (angle, i) => { + const isMain = angle % 90 === 0 + const r1 = isMain ? 38 : 42 + const r2 = 46 + const rad = ((angle - 90) * Math.PI) / 180 + return ( + + ) + } + )} + + + + + + {showSeconds && ( + + )} + + + +
    + ) +} diff --git a/src/layouts/widgets/combo-widget/combo-widget.layout.tsx b/src/layouts/widgets/combo-widget/combo-widget.layout.tsx index dc81354c6..fefcf43c4 100644 --- a/src/layouts/widgets/combo-widget/combo-widget.layout.tsx +++ b/src/layouts/widgets/combo-widget/combo-widget.layout.tsx @@ -11,7 +11,13 @@ import { Icon } from '@/src/icons' export type ComboTabType = 'news' | 'currency' -export function ComboWidget() { +import type { WidgetSize } from '../layout-engine/types' + +interface ComboWidgetProps { + size?: WidgetSize +} + +export function ComboWidget({ size }: ComboWidgetProps = {}) { const [activeTab, setActiveTab] = useState(null) const handleSettingsClick = () => { if (activeTab === 'currency') { diff --git a/src/layouts/widgets/date/date.widget.tsx b/src/layouts/widgets/date/date.widget.tsx new file mode 100644 index 000000000..c5546a228 --- /dev/null +++ b/src/layouts/widgets/date/date.widget.tsx @@ -0,0 +1,182 @@ +import { useDate } from '@/context/date.context' +import { combineAndSortEvents } from '@/layouts/widgets/tools/events/utils' +import { useGetEvents } from '@/services/hooks/date/get-events.hook' +import { hijriMonthNames } from '@/layouts/widgets/calendar/utils' +import { DateDisplay } from '../wigi-pad/date-display/date.display' +import { WidgetContainer } from '../widget-container' +import type { WidgetSize } from '../layout-engine/types' + +function toPersianDigits(val: string | number): string { + const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return String(val).replace(/\d/g, (x) => farsiDigits[Number.parseInt(x, 10)]) +} + +interface DateWidgetProps { + size?: WidgetSize +} + +function DateCompactSquare() { + const { today, todayIsHoliday } = useDate() + const dayOfWeek = today.locale('fa').format('dddd') + const dayNumber = toPersianDigits(today.jDate()) + const monthName = today.locale('fa').format('MMMM') + + return ( +
    +
    + + {dayOfWeek} + + {todayIsHoliday && ( + + )} +
    +
    + {dayNumber} +
    +
    + {monthName} +
    +
    + ) +} + +function DateCompactRow() { + const { today, todayIsHoliday, getHijriDate } = useDate() + const dayOfWeek = today.locale('fa').format('dddd') + const dayNumber = toPersianDigits(today.jDate()) + const monthName = today.locale('fa').format('MMMM') + const yearNumber = toPersianDigits(today.jYear()) + + const gregorianShort = today.doAsGregorian().format('D MMM') + + const hijriRaw = getHijriDate(today) + const [_, hijriMonth, hijriDate] = hijriRaw.split('/') + const hijriMonthName = hijriMonthNames[Number(hijriMonth) - 1] || hijriMonth + const hijriFormatted = `${toPersianDigits(hijriDate)} ${hijriMonthName}` + + return ( +
    +
    +
    + {dayNumber} +
    +
    +
    + + {dayOfWeek} + + + {monthName} + + {todayIsHoliday && ( + + تعطیل + + )} +
    + + {yearNumber} + +
    +
    + +
    + {gregorianShort} + {hijriFormatted} +
    +
    + ) +} + +function DateWideBanner() { + const { today, todayIsHoliday, getHijriDate } = useDate() + const { data: events } = useGetEvents() + + const eventsForCalendar = events || { + gregorianEvents: [], + hijriEvents: [], + shamsiEvents: [], + } + const sortedEvents = combineAndSortEvents(eventsForCalendar, today.clone(), []) + const isHoliday = sortedEvents.some((event) => event.isHoliday) || todayIsHoliday + const mainEvent = sortedEvents.find((e) => e.title) + + const dayOfWeek = today.locale('fa').format('dddd') + const dayNumber = toPersianDigits(today.jDate()) + const monthName = today.locale('fa').format('MMMM') + const yearNumber = toPersianDigits(today.jYear()) + + const gregorianFull = today.doAsGregorian().format('D MMMM YYYY') + + const hijriRaw = getHijriDate(today) + const [_, hijriMonth, hijriDate] = hijriRaw.split('/') + const hijriMonthName = hijriMonthNames[Number(hijriMonth) - 1] || hijriMonth + const hijriFormatted = `${toPersianDigits(hijriDate)} ${hijriMonthName}` + + return ( +
    +
    +
    + {dayNumber} +
    +
    + + {dayOfWeek} + + + {monthName} {yearNumber} + + {isHoliday && ( + + تعطیل رسمی + + )} +
    +
    + +
    + {mainEvent && ( + + {mainEvent.title} + + )} + + {gregorianFull} · {hijriFormatted} + +
    +
    + ) +} + +export function DateWidget({ size = { w: 2, h: 2 } }: DateWidgetProps) { + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h === 1) { + return ( + + + + ) + } + + return ( + + + + ) +} diff --git a/src/layouts/widgets/grid-geometry.ts b/src/layouts/widgets/grid-geometry.ts new file mode 100644 index 000000000..640610a76 --- /dev/null +++ b/src/layouts/widgets/grid-geometry.ts @@ -0,0 +1,77 @@ +import type { StoredWidget, WidgetPosition, WidgetSize } from './layout-engine/types' + +export interface PixelRect { + left: number + top: number + width: number + height: number +} + +export function getCellWidth( + containerWidth: number, + cols: number, + gap: number +): number { + if (cols <= 0) return 0 + return (containerWidth - (cols - 1) * gap) / cols +} + +export function getWidgetPixelRect( + position: WidgetPosition, + size: WidgetSize, + cellWidth: number, + cellHeight: number, + gap: number +): PixelRect { + const left = position.col * (cellWidth + gap) + const top = position.row * (cellHeight + gap) + const width = size.w * cellWidth + (size.w - 1) * gap + const height = size.h * cellHeight + (size.h - 1) * gap + + return { left, top, width, height } +} + +export function getCanvasRowCount(layout: StoredWidget[]): number { + if (layout.length === 0) return 0 + return Math.max(...layout.map((w) => w.position.row + w.size.h), 0) +} + +export function getCanvasHeight( + layout: StoredWidget[], + cellHeight: number, + gap: number +): number { + const rows = getCanvasRowCount(layout) + if (rows === 0) return 0 + return rows * cellHeight + Math.max(0, rows - 1) * gap +} + +export function pixelToGrid( + pixelX: number, + pixelY: number, + cellWidth: number, + cellHeight: number, + gap: number +): WidgetPosition { + const unitW = cellWidth + gap + const unitH = cellHeight + gap + const col = unitW > 0 ? Math.round(pixelX / unitW) : 0 + const row = unitH > 0 ? Math.round(pixelY / unitH) : 0 + return { + col: Math.max(0, col), + row: Math.max(0, row), + } +} + +export function gridToPixel( + col: number, + row: number, + cellWidth: number, + cellHeight: number, + gap: number +): { left: number; top: number } { + return { + left: col * (cellWidth + gap), + top: row * (cellHeight + gap), + } +} diff --git a/src/layouts/widgets/habit/habits.layout.tsx b/src/layouts/widgets/habit/habits.layout.tsx index 9df848f23..4d3af27d0 100644 --- a/src/layouts/widgets/habit/habits.layout.tsx +++ b/src/layouts/widgets/habit/habits.layout.tsx @@ -199,7 +199,13 @@ export function HabitsContent() { ) } -export function HabitsLayout() { +import type { WidgetSize } from '../layout-engine/types' + +interface HabitsLayoutProps { + size?: WidgetSize +} + +export function HabitsLayout({ size }: HabitsLayoutProps = {}) { return ( diff --git a/src/layouts/widgets/layout-engine/__tests__/layout-engine.test.ts b/src/layouts/widgets/layout-engine/__tests__/layout-engine.test.ts new file mode 100644 index 000000000..aa022f07f --- /dev/null +++ b/src/layouts/widgets/layout-engine/__tests__/layout-engine.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'bun:test' +import { + WidgetKeys, + compactLayout, + DEFAULT_WIDGET_LAYOUT, + findAvailableSlot, + getCollisions, + rectanglesOverlap, + resolveLayoutChange, + validateLayout, +} from '../index' +import type { StoredWidget } from '../types' + +describe('Layout Engine Tests', () => { + it('validates default layout without errors', () => { + const isValid = validateLayout(DEFAULT_WIDGET_LAYOUT, 8) + expect(isValid).toBe(true) + }) + + it('detects rectangle overlap correctly', () => { + const a: StoredWidget = { + id: WidgetKeys.search, + instanceId: 'a', + position: { col: 0, row: 0 }, + size: { w: 4, h: 2 }, + } + const b: StoredWidget = { + id: WidgetKeys.bookmarks, + instanceId: 'b', + position: { col: 2, row: 1 }, + size: { w: 4, h: 2 }, + } + const c: StoredWidget = { + id: WidgetKeys.clock, + instanceId: 'c', + position: { col: 4, row: 0 }, + size: { w: 2, h: 1 }, + } + + expect(rectanglesOverlap(a, b)).toBe(true) + expect(rectanglesOverlap(a, c)).toBe(false) + expect(getCollisions(a, [a, b, c])).toEqual([b]) + }) + + it('pushes conflicting widget when resizing larger', () => { + const initial: StoredWidget[] = [ + { + id: WidgetKeys.weather, + instanceId: 'weather', + position: { col: 0, row: 0 }, + size: { w: 2, h: 2 }, + }, + { + id: WidgetKeys.calendar, + instanceId: 'calendar', + position: { col: 2, row: 0 }, + size: { w: 2, h: 2 }, + }, + ] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'resize', + instanceId: 'weather', + targetSize: { w: 4, h: 2 }, + cols: 8, + }) + + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + + const weather = result!.find((w) => w.instanceId === 'weather')! + const calendar = result!.find((w) => w.instanceId === 'calendar')! + + expect(weather.size).toEqual({ w: 4, h: 2 }) + expect(weather.position).toEqual({ col: 0, row: 0 }) + expect(rectanglesOverlap(weather, calendar)).toBe(false) + }) + + it('pushes recursively when moving a widget into occupied cells', () => { + const initial: StoredWidget[] = [ + { + id: WidgetKeys.search, + instanceId: 'search', + position: { col: 0, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks', + position: { col: 0, row: 1 }, + size: { w: 4, h: 2 }, + }, + { + id: WidgetKeys.wigiPad, + instanceId: 'wigipad', + position: { col: 0, row: 3 }, + size: { w: 2, h: 2 }, + }, + ] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'wigipad', + targetPosition: { col: 0, row: 0 }, + cols: 8, + }) + + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + + const wigipad = result!.find((w) => w.instanceId === 'wigipad')! + expect(wigipad.position).toEqual({ col: 0, row: 0 }) + }) + + it('compacts layout removing vertical gaps', () => { + const withGaps: StoredWidget[] = [ + { + id: WidgetKeys.search, + instanceId: 'search', + position: { col: 0, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks', + position: { col: 0, row: 5 }, + size: { w: 4, h: 2 }, + }, + ] + + const compacted = compactLayout(withGaps, 8) + const bookmarks = compacted.find((w) => w.instanceId === 'bookmarks')! + expect(bookmarks.position.row).toBe(1) + }) + + it('finds available slots deterministically', () => { + const layout: StoredWidget[] = [ + { + id: WidgetKeys.search, + instanceId: 'search', + position: { col: 0, row: 0 }, + size: { w: 8, h: 1 }, + }, + ] + + const slot = findAvailableSlot(layout, { w: 2, h: 2 }, 8) + expect(slot).toEqual({ col: 0, row: 1 }) + }) +}) diff --git a/src/layouts/widgets/layout-engine/collision.ts b/src/layouts/widgets/layout-engine/collision.ts new file mode 100644 index 000000000..3c1febdf9 --- /dev/null +++ b/src/layouts/widgets/layout-engine/collision.ts @@ -0,0 +1,44 @@ +import type { StoredWidget, WidgetPosition, WidgetSize } from './types' + +export function doRectanglesOverlap( + posA: WidgetPosition, + sizeA: WidgetSize, + posB: WidgetPosition, + sizeB: WidgetSize +): boolean { + const overlapsH = + posA.col < posB.col + sizeB.w && posA.col + sizeA.w > posB.col + const overlapsV = + posA.row < posB.row + sizeB.h && posA.row + sizeA.h > posB.row + + return overlapsH && overlapsV +} + +export function rectanglesOverlap( + a: StoredWidget, + b: StoredWidget +): boolean { + return doRectanglesOverlap(a.position, a.size, b.position, b.size) +} + +export function getCollisions( + widget: StoredWidget, + layout: StoredWidget[] +): StoredWidget[] { + return layout.filter( + (other) => + other.instanceId !== widget.instanceId && + rectanglesOverlap(widget, other) + ) +} + +export function hasAnyCollision(layout: StoredWidget[]): boolean { + for (let i = 0; i < layout.length; i++) { + for (let j = i + 1; j < layout.length; j++) { + if (rectanglesOverlap(layout[i], layout[j])) { + return true + } + } + } + return false +} diff --git a/src/layouts/widgets/layout-engine/compact.ts b/src/layouts/widgets/layout-engine/compact.ts new file mode 100644 index 000000000..57d92a68a --- /dev/null +++ b/src/layouts/widgets/layout-engine/compact.ts @@ -0,0 +1,81 @@ +import { doRectanglesOverlap } from './collision' +import type { StoredWidget } from './types' + +export function compactLayout( + layout: StoredWidget[], + cols: number, + fixedIds: Set = new Set() +): StoredWidget[] { + const sorted = [...layout].sort((a, b) => { + if (a.position.row !== b.position.row) { + return a.position.row - b.position.row + } + return a.position.col - b.position.col + }) + + const result: StoredWidget[] = [] + + for (const widget of sorted) { + if (fixedIds.has(widget.instanceId)) { + result.push({ ...widget, position: { ...widget.position } }) + continue + } + + let bestRow = widget.position.row + + for (let r = widget.position.row - 1; r >= 0; r--) { + let collides = false + + for (const placed of result) { + if ( + doRectanglesOverlap( + { col: widget.position.col, row: r }, + widget.size, + placed.position, + placed.size + ) + ) { + collides = true + break + } + } + + if (!collides) { + for (const fixed of layout) { + if ( + fixedIds.has(fixed.instanceId) && + fixed.instanceId !== widget.instanceId + ) { + if ( + doRectanglesOverlap( + { col: widget.position.col, row: r }, + widget.size, + fixed.position, + fixed.size + ) + ) { + collides = true + break + } + } + } + } + + if (!collides) { + bestRow = r + } else { + break + } + } + + result.push({ + ...widget, + position: { + col: widget.position.col, + row: bestRow, + }, + }) + } + + return result +} diff --git a/src/layouts/widgets/layout-engine/constants.ts b/src/layouts/widgets/layout-engine/constants.ts new file mode 100644 index 000000000..468d0c436 --- /dev/null +++ b/src/layouts/widgets/layout-engine/constants.ts @@ -0,0 +1,88 @@ +import { type GridBreakpoint, type StoredWidget, WidgetKeys } from './types' + +export const DEFAULT_COLS = 8 +export const DEFAULT_CELL_HEIGHT = 96 +export const DEFAULT_GAP = 8 +export const MIN_CELL_WIDTH = 72 + +export const GRID_BREAKPOINTS: GridBreakpoint[] = [ + { + name: 'lg', + minContainerWidth: 900, + cols: 8, + cellHeight: 96, + gap: 8, + }, + { + name: 'md', + minContainerWidth: 640, + cols: 6, + cellHeight: 88, + gap: 8, + }, + { + name: 'sm', + minContainerWidth: 420, + cols: 4, + cellHeight: 80, + gap: 6, + }, + { + name: 'xs', + minContainerWidth: 0, + cols: 2, + cellHeight: 72, + gap: 6, + }, +] + +export const DEFAULT_WIDGET_LAYOUT: StoredWidget[] = [ + { + id: WidgetKeys.widgetify, + instanceId: 'widgetify-default', + position: { col: 0, row: 0 }, + size: { w: 2, h: 3 }, + }, + { + id: WidgetKeys.search, + instanceId: 'search-default', + position: { col: 2, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks-default', + position: { col: 2, row: 1 }, + size: { w: 4, h: 2 }, + }, + { + id: WidgetKeys.wigiPad, + instanceId: 'wigipad-default', + position: { col: 6, row: 0 }, + size: { w: 2, h: 3 }, + }, + { + id: WidgetKeys.calendar, + instanceId: 'calendar-default', + position: { col: 0, row: 3 }, + size: { w: 2, h: 2 }, + }, + { + id: WidgetKeys.tools, + instanceId: 'tools-default', + position: { col: 2, row: 3 }, + size: { w: 2, h: 2 }, + }, + { + id: WidgetKeys.yadKar, + instanceId: 'yadkar-default', + position: { col: 4, row: 3 }, + size: { w: 2, h: 2 }, + }, + { + id: WidgetKeys.comboWidget, + instanceId: 'combo-default', + position: { col: 6, row: 3 }, + size: { w: 2, h: 2 }, + }, +] diff --git a/src/layouts/widgets/layout-engine/index.ts b/src/layouts/widgets/layout-engine/index.ts new file mode 100644 index 000000000..4008da29f --- /dev/null +++ b/src/layouts/widgets/layout-engine/index.ts @@ -0,0 +1,9 @@ +export * from './types' +export * from './constants' +export * from './collision' +export * from './validation' +export * from './push' +export * from './compact' +export * from './placement' +export * from './layout-engine' +export * from '../grid-geometry' diff --git a/src/layouts/widgets/layout-engine/layout-engine.ts b/src/layouts/widgets/layout-engine/layout-engine.ts new file mode 100644 index 000000000..d53f6079c --- /dev/null +++ b/src/layouts/widgets/layout-engine/layout-engine.ts @@ -0,0 +1,207 @@ +import { compactLayout } from './compact' +import { findAvailableSlot, getBestAllowedSizeForColumns } from './placement' +import { resolveCollisions } from './push' +import type { LayoutEngineOptions, StoredWidget, WidgetSize } from './types' +import { isWithinHorizontalBounds, validateLayout } from './validation' + +export function resolveLayoutChange( + options: LayoutEngineOptions +): StoredWidget[] | null { + const { + layout, + operation, + instanceId, + targetPosition, + targetSize, + newWidget, + cols, + allowedSizes, + } = options + + const cloned: StoredWidget[] = layout.map((w) => ({ + ...w, + position: { ...w.position }, + size: { ...w.size }, + })) + + switch (operation) { + case 'move': { + if (!instanceId || !targetPosition) return null + const index = cloned.findIndex((w) => w.instanceId === instanceId) + if (index === -1) return null + + const widget = cloned[index] + const clampedCol = Math.max( + 0, + Math.min(cols - widget.size.w, targetPosition.col) + ) + const clampedRow = Math.max(0, targetPosition.row) + + widget.position = { col: clampedCol, row: clampedRow } + + if (!isWithinHorizontalBounds(widget, cols)) { + return null + } + + const resolved = resolveCollisions(cloned, new Set([instanceId]), cols) + if (!resolved) { + return null + } + + if (!validateLayout(resolved, cols)) { + return null + } + + return resolved + } + + case 'resize': { + if (!instanceId || !targetSize) return null + const index = cloned.findIndex((w) => w.instanceId === instanceId) + if (index === -1) return null + + const widget = cloned[index] + if (targetSize.w > cols || targetSize.w <= 0 || targetSize.h <= 0) { + return null + } + + widget.size = { ...targetSize } + + if (!isWithinHorizontalBounds(widget, cols)) { + widget.position.col = Math.max(0, cols - widget.size.w) + } + + const resolved = resolveCollisions(cloned, new Set([instanceId]), cols) + if (!resolved) { + return null + } + + if (!validateLayout(resolved, cols)) { + return null + } + + return resolved + } + + case 'add': { + if (!newWidget) return null + if (newWidget.size.w > cols) return null + + const toAdd: StoredWidget = { + ...newWidget, + position: { ...newWidget.position }, + size: { ...newWidget.size }, + } + + if ( + targetPosition && + targetPosition.col + toAdd.size.w <= cols && + targetPosition.col >= 0 && + targetPosition.row >= 0 + ) { + toAdd.position = { ...targetPosition } + cloned.push(toAdd) + + const resolved = resolveCollisions( + cloned, + new Set([toAdd.instanceId]), + cols + ) + if (resolved) { + const compacted = compactLayout( + resolved, + cols, + new Set([toAdd.instanceId]) + ) + if (validateLayout(compacted, cols)) { + return compacted + } + } + cloned.pop() + } + + const slot = findAvailableSlot(cloned, toAdd.size, cols) + toAdd.position = slot + cloned.push(toAdd) + + const compacted = compactLayout(cloned, cols) + if (!validateLayout(compacted, cols)) { + return null + } + + return compacted + } + + case 'duplicate': { + if (!instanceId) return null + const original = cloned.find((w) => w.instanceId === instanceId) + if (!original) return null + + const newInstanceId = `${original.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` + const duplicated: StoredWidget = { + id: original.id, + instanceId: newInstanceId, + size: { ...original.size }, + position: { ...original.position }, + } + + const slot = findAvailableSlot(cloned, duplicated.size, cols) + duplicated.position = slot + cloned.push(duplicated) + + const compacted = compactLayout(cloned, cols) + if (!validateLayout(compacted, cols)) { + return null + } + + return compacted + } + + case 'remove': { + if (!instanceId) return null + const filtered = cloned.filter((w) => w.instanceId !== instanceId) + const compacted = compactLayout(filtered, cols) + if (!validateLayout(compacted, cols)) { + return null + } + return compacted + } + + case 'responsive-reflow': { + const sorted = [...cloned].sort((a, b) => { + if (a.position.row !== b.position.row) { + return a.position.row - b.position.row + } + return a.position.col - b.position.col + }) + + const reflowed: StoredWidget[] = [] + + for (const widget of sorted) { + const validSizes = allowedSizes || [widget.size] + const adaptedSize: WidgetSize = getBestAllowedSizeForColumns( + validSizes, + widget.size, + cols + ) + + const slot = findAvailableSlot(reflowed, adaptedSize, cols) + reflowed.push({ + ...widget, + size: adaptedSize, + position: slot, + }) + } + + const compacted = compactLayout(reflowed, cols) + if (!validateLayout(compacted, cols)) { + return null + } + + return compacted + } + + default: + return null + } +} diff --git a/src/layouts/widgets/layout-engine/placement.ts b/src/layouts/widgets/layout-engine/placement.ts new file mode 100644 index 000000000..f0c6b66d6 --- /dev/null +++ b/src/layouts/widgets/layout-engine/placement.ts @@ -0,0 +1,77 @@ +import { doRectanglesOverlap } from './collision' +import type { StoredWidget, WidgetPosition, WidgetSize } from './types' + +export function findAvailableSlot( + layout: StoredWidget[], + size: WidgetSize, + cols: number +): WidgetPosition { + const maxRow = + layout.length > 0 + ? Math.max(...layout.map((w) => w.position.row + w.size.h)) + 2 + : 2 + + const clampedW = Math.min(size.w, cols) + + for (let row = 0; row <= maxRow + 10; row++) { + for (let col = 0; col <= cols - clampedW; col++) { + let collides = false + + for (const widget of layout) { + if ( + doRectanglesOverlap( + { col, row }, + { w: clampedW, h: size.h }, + widget.position, + widget.size + ) + ) { + collides = true + break + } + } + + if (!collides) { + return { col, row } + } + } + } + + return { col: 0, row: maxRow } +} + +export function getBestAllowedSizeForColumns( + allowedSizes: WidgetSize[], + currentSize: WidgetSize, + cols: number +): WidgetSize { + if (currentSize.w <= cols) { + const isCurrentAllowed = allowedSizes.some( + (s) => s.w === currentSize.w && s.h === currentSize.h + ) + if (isCurrentAllowed) { + return currentSize + } + } + + const fittingSizes = allowedSizes.filter((s) => s.w <= cols) + if (fittingSizes.length === 0) { + return { + w: Math.max(1, Math.min(cols, currentSize.w)), + h: currentSize.h, + } + } + + fittingSizes.sort((a, b) => { + const diffW_A = Math.abs(a.w - currentSize.w) + const diffW_B = Math.abs(b.w - currentSize.w) + if (diffW_A !== diffW_B) { + return diffW_A - diffW_B + } + const diffH_A = Math.abs(a.h - currentSize.h) + const diffH_B = Math.abs(b.h - currentSize.h) + return diffH_A - diffH_B + }) + + return fittingSizes[0] +} diff --git a/src/layouts/widgets/layout-engine/push.ts b/src/layouts/widgets/layout-engine/push.ts new file mode 100644 index 000000000..9d7f0d6d6 --- /dev/null +++ b/src/layouts/widgets/layout-engine/push.ts @@ -0,0 +1,141 @@ +import { doRectanglesOverlap, getCollisions, rectanglesOverlap } from './collision' +import type { StoredWidget, WidgetPosition, WidgetSize } from './types' + +export function getPushCost( + original: WidgetPosition, + candidate: WidgetPosition +): number { + const dx = Math.abs(candidate.col - original.col) + const dy = Math.abs(candidate.row - original.row) + return dx + dy * 2 + candidate.row * 0.05 + candidate.col * 0.01 +} + +export function generatePushCandidates( + widget: StoredWidget, + blocker: StoredWidget, + layout: StoredWidget[], + cols: number, + fixedIds: Set +): WidgetPosition[] { + const candidates: WidgetPosition[] = [] + const seen = new Set() + + const addCandidate = (col: number, row: number) => { + if (col < 0 || col + widget.size.w > cols || row < 0) { + return + } + const key = `${col},${row}` + if (seen.has(key)) return + seen.add(key) + + for (const other of layout) { + if (fixedIds.has(other.instanceId)) { + if ( + doRectanglesOverlap( + { col, row }, + widget.size, + other.position, + other.size + ) + ) { + return + } + } + } + + candidates.push({ col, row }) + } + + addCandidate(blocker.position.col + blocker.size.w, widget.position.row) + addCandidate(blocker.position.col - widget.size.w, widget.position.row) + addCandidate(widget.position.col, blocker.position.row + blocker.size.h) + addCandidate(0, blocker.position.row + blocker.size.h) + + const maxScanRow = Math.max( + ...layout.map((w) => w.position.row + w.size.h), + blocker.position.row + blocker.size.h + 4, + 8 + ) + + for (let r = 0; r <= maxScanRow; r++) { + for (let c = 0; c <= cols - widget.size.w; c++) { + addCandidate(c, r) + } + } + + candidates.sort((a, b) => { + const costA = getPushCost(widget.position, a) + const costB = getPushCost(widget.position, b) + return costA - costB + }) + + return candidates +} + +export function resolveCollisions( + layout: StoredWidget[], + fixedIds: Set, + cols: number, + maxDepth = 30 +): StoredWidget[] | null { + const findFirstCollision = ( + currentLayout: StoredWidget[] + ): { widget: StoredWidget; blocker: StoredWidget } | null => { + for (const w of currentLayout) { + const collisions = getCollisions(w, currentLayout) + if (collisions.length > 0) { + if (!fixedIds.has(w.instanceId)) { + return { widget: w, blocker: collisions[0] } + } + const movable = collisions.find((c) => !fixedIds.has(c.instanceId)) + if (movable) { + return { widget: movable, blocker: w } + } + return null + } + } + return null + } + + const solve = ( + currentLayout: StoredWidget[], + depth: number + ): StoredWidget[] | null => { + if (depth > maxDepth) { + return null + } + + const collision = findFirstCollision(currentLayout) + if (!collision) { + return currentLayout + } + + const { widget, blocker } = collision + const candidates = generatePushCandidates( + widget, + blocker, + currentLayout, + cols, + fixedIds + ) + + const topCandidates = candidates.slice(0, 15) + + for (const pos of topCandidates) { + const nextLayout = currentLayout.map((item) => + item.instanceId === widget.instanceId + ? { ...item, position: { ...pos } } + : item + ) + + const result = solve(nextLayout, depth + 1) + if (result) { + return result + } + } + + return null + } + + return solve(layout, 0) +} diff --git a/src/layouts/widgets/layout-engine/types.ts b/src/layouts/widgets/layout-engine/types.ts new file mode 100644 index 000000000..0b7ecff57 --- /dev/null +++ b/src/layouts/widgets/layout-engine/types.ts @@ -0,0 +1,79 @@ +export enum WidgetKeys { + comboWidget = 'comboWidget', + arzLive = 'arzLive', + news = 'news', + calendar = 'calendar', + weather = 'weather', + todos = 'todos', + tools = 'tools', + notes = 'notes', + youtube = 'youtube', + wigiPad = 'wigiPad', + network = 'network', + yadKar = 'yadKar', + HabitTracker = 'HabitTracker', + search = 'search', + bookmarks = 'bookmarks', + widgetify = 'widgetify', + clock = 'clock', + date = 'date', +} + +export interface WidgetPosition { + col: number + row: number +} + +export interface WidgetSize { + w: number + h: number +} + +export interface StoredWidget { + id: WidgetKeys + instanceId: string + position: WidgetPosition + size: WidgetSize + widgetId?: string | null +} + +export type LayoutOperation = + | 'move' + | 'resize' + | 'add' + | 'duplicate' + | 'remove' + | 'responsive-reflow' + +export interface GridBreakpoint { + name: 'xs' | 'sm' | 'md' | 'lg' + minContainerWidth: number + cols: number + cellHeight: number + gap: number +} + +import type { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' + +export interface WidgetDefinition { + id: WidgetKeys + label: string + emoji: string + allowedSizes: WidgetSize[] + defaultSize: WidgetSize + settingsTab?: WidgetTabKeys + canDuplicate: boolean + preview: () => React.ReactNode + node: (instanceId: string, size: WidgetSize) => React.ReactNode +} + +export interface LayoutEngineOptions { + layout: StoredWidget[] + operation: LayoutOperation + instanceId?: string + targetPosition?: WidgetPosition + targetSize?: WidgetSize + newWidget?: StoredWidget + cols: number + allowedSizes?: WidgetSize[] +} diff --git a/src/layouts/widgets/layout-engine/validation.ts b/src/layouts/widgets/layout-engine/validation.ts new file mode 100644 index 000000000..def8fec72 --- /dev/null +++ b/src/layouts/widgets/layout-engine/validation.ts @@ -0,0 +1,56 @@ +import { hasAnyCollision } from './collision' +import type { StoredWidget, WidgetDefinition } from './types' + +export function isWithinHorizontalBounds( + widget: StoredWidget, + cols: number +): boolean { + return ( + widget.position.col >= 0 && + widget.size.w > 0 && + widget.position.col + widget.size.w <= cols + ) +} + +export function isValidVerticalPosition(widget: StoredWidget): boolean { + return widget.position.row >= 0 && widget.size.h > 0 +} + +export function validateLayout( + layout: StoredWidget[], + cols: number, + registry?: Record +): boolean { + const instanceIds = new Set() + + for (const widget of layout) { + if (!widget.instanceId || instanceIds.has(widget.instanceId)) { + return false + } + instanceIds.add(widget.instanceId) + + if (!isWithinHorizontalBounds(widget, cols)) { + return false + } + + if (!isValidVerticalPosition(widget)) { + return false + } + + if (registry && registry[widget.id]) { + const def = registry[widget.id] + const sizeValid = def.allowedSizes.some( + (s) => s.w === widget.size.w && s.h === widget.size.h + ) + if (!sizeValid) { + return false + } + } + } + + if (hasAnyCollision(layout)) { + return false + } + + return true +} diff --git a/src/layouts/widgets/migration.ts b/src/layouts/widgets/migration.ts new file mode 100644 index 000000000..8ef636d8d --- /dev/null +++ b/src/layouts/widgets/migration.ts @@ -0,0 +1,94 @@ +import { getFromStorage, setToStorage } from '@/common/storage' +import { + compactLayout, + DEFAULT_WIDGET_LAYOUT, + findAvailableSlot, + validateLayout, +} from './layout-engine' +import { type StoredWidget, WidgetKeys } from './layout-engine/types' +import { WIDGET_DEFINITIONS } from './widget-registry' + +export const MIGRATION_VERSION_KEY = 'widgetLayoutMigrationVersion' +export const CURRENT_MIGRATION_VERSION = 1 + +export async function migrateWidgetLayoutIfNeeded(): Promise { + const version = await getFromStorage('widgetLayoutMigrationVersion') + const existingStored = await getFromStorage('storedWidgets') + + if (version && version >= CURRENT_MIGRATION_VERSION && existingStored && existingStored.length > 0) { + if (validateLayout(existingStored, 8)) { + return existingStored + } + } + + const legacyActive = await getFromStorage('activeWidgets') + + let candidateLayout: StoredWidget[] = [] + + if (legacyActive && Array.isArray(legacyActive) && legacyActive.length > 0) { + candidateLayout = [ + { + id: WidgetKeys.widgetify, + instanceId: 'widgetify-default', + position: { col: 0, row: 0 }, + size: { w: 2, h: 3 }, + }, + { + id: WidgetKeys.search, + instanceId: 'search-default', + position: { col: 2, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks-default', + position: { col: 2, row: 1 }, + size: { w: 4, h: 2 }, + }, + { + id: WidgetKeys.wigiPad, + instanceId: 'wigipad-default', + position: { col: 6, row: 0 }, + size: { w: 2, h: 3 }, + }, + ] + + const sortedLegacy = [...legacyActive].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0) + ) + + for (const item of sortedLegacy) { + const id = item.id as WidgetKeys + if ( + id === WidgetKeys.widgetify || + id === WidgetKeys.search || + id === WidgetKeys.bookmarks || + id === WidgetKeys.wigiPad + ) { + continue + } + + const def = WIDGET_DEFINITIONS[id] + const size = def?.defaultSize || { w: 2, h: 2 } + const slot = findAvailableSlot(candidateLayout, size, 8) + + candidateLayout.push({ + id, + instanceId: `${id}-default`, + position: slot, + size, + }) + } + } else { + candidateLayout = [...DEFAULT_WIDGET_LAYOUT] + } + + const finalLayout = compactLayout(candidateLayout, 8) + + await Promise.all([ + setToStorage('storedWidgets', finalLayout), + setToStorage('widgetLayoutMigrationVersion', CURRENT_MIGRATION_VERSION), + ]) + + return finalLayout +} diff --git a/src/layouts/widgets/network/network.layout.tsx b/src/layouts/widgets/network/network.layout.tsx index a4ec135ba..357bb01ef 100644 --- a/src/layouts/widgets/network/network.layout.tsx +++ b/src/layouts/widgets/network/network.layout.tsx @@ -22,11 +22,14 @@ interface NetworkInfo { speed: string } +import type { WidgetSize } from '../layout-engine/types' + interface Prop { inComboWidget: boolean enableBackground: boolean + size?: WidgetSize } -export function NetworkLayout({ enableBackground, inComboWidget }: Prop) { +export function NetworkLayout({ enableBackground, inComboWidget, size }: Prop) { const { blurMode } = useGeneralSetting() const { isAuthenticated } = useAuth() diff --git a/src/layouts/widgets/news/news.layout.tsx b/src/layouts/widgets/news/news.layout.tsx index 3dd2f4633..19f8388a6 100644 --- a/src/layouts/widgets/news/news.layout.tsx +++ b/src/layouts/widgets/news/news.layout.tsx @@ -7,14 +7,18 @@ import { NewsContainer } from './components/news-container' import { NewsHeader } from './components/news-header' import type { WigiNewsSetting } from './rss.interface' +import type { WidgetSize } from '../layout-engine/types' + interface NewsLayoutProps { inComboWidget: boolean enableBackground?: boolean + size?: WidgetSize } export const NewsLayout: React.FC = ({ enableBackground = true, inComboWidget, + size, }) => { const [rssState, setRssState] = useState({ customFeeds: [], diff --git a/src/layouts/widgets/tools/religious/religious-time.tsx b/src/layouts/widgets/tools/religious/religious-time.tsx index dd100d72e..11a0968a6 100644 --- a/src/layouts/widgets/tools/religious/religious-time.tsx +++ b/src/layouts/widgets/tools/religious/religious-time.tsx @@ -98,7 +98,7 @@ export function ReligiousTime({ currentDate }: { currentDate: any }) {
    - + ذکر روز {weekDay}
    diff --git a/src/layouts/widgets/weather/utils/clean-city-name.ts b/src/layouts/widgets/weather/utils/clean-city-name.ts new file mode 100644 index 000000000..1fa8e3971 --- /dev/null +++ b/src/layouts/widgets/weather/utils/clean-city-name.ts @@ -0,0 +1,5 @@ +export function cleanCityName(name?: string | null): string { + if (!name) return '' + const regex = /\s*شهرستان\s*/g + return name.replace(regex, ' ').trim() +} diff --git a/src/layouts/widgets/weather/variants/weather-compact-row.tsx b/src/layouts/widgets/weather/variants/weather-compact-row.tsx new file mode 100644 index 000000000..e594a8c5c --- /dev/null +++ b/src/layouts/widgets/weather/variants/weather-compact-row.tsx @@ -0,0 +1,67 @@ +import type { FetchedWeather } from '@/layouts/widgets/weather/weather.interface' +import { unitsFlag } from '../unit-symbols' +import { cleanCityName } from '../utils/clean-city-name' +import { Icon } from '@/src/icons' + +interface WeatherCompactRowProps { + fetchedWeather: FetchedWeather | null + temperatureUnit: keyof typeof unitsFlag +} + +export function WeatherCompactRow({ + fetchedWeather, + temperatureUnit, +}: WeatherCompactRowProps) { + const temp = Math.round(fetchedWeather?.weather?.temperature?.temp || 0) + const cityName = cleanCityName(fetchedWeather?.city?.fa || '') + const iconUrl = fetchedWeather?.weather?.icon?.url + const description = fetchedWeather?.weather?.description?.text || '' + const humidity = fetchedWeather?.weather?.temperature?.humidity || 0 + const windSpeed = Math.round( + fetchedWeather?.weather?.temperature?.wind_speed || 0 + ) + + return ( +
    +
    + {iconUrl ? ( + {description} + ) : ( +
    + )} + +
    +
    + + {temp} + + {unitsFlag[temperatureUnit || 'metric']} + + + + {cityName} + +
    + + {description} + +
    +
    + +
    +
    + + {windSpeed} m/s +
    +
    + + {humidity}% +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/weather/variants/weather-compact-square.tsx b/src/layouts/widgets/weather/variants/weather-compact-square.tsx new file mode 100644 index 000000000..adbe6f1b8 --- /dev/null +++ b/src/layouts/widgets/weather/variants/weather-compact-square.tsx @@ -0,0 +1,49 @@ +import type { FetchedWeather } from '@/layouts/widgets/weather/weather.interface' +import { unitsFlag } from '../unit-symbols' +import { cleanCityName } from '../utils/clean-city-name' + +interface WeatherCompactSquareProps { + fetchedWeather: FetchedWeather | null + temperatureUnit: keyof typeof unitsFlag +} + +export function WeatherCompactSquare({ + fetchedWeather, + temperatureUnit, +}: WeatherCompactSquareProps) { + const temp = Math.round(fetchedWeather?.weather?.temperature?.temp || 0) + const cityName = cleanCityName(fetchedWeather?.city?.fa || '') + const iconUrl = fetchedWeather?.weather?.icon?.url + const description = fetchedWeather?.weather?.description?.text || '' + + return ( +
    + + {cityName} + + +
    + {iconUrl ? ( + {description} + ) : ( +
    + )} + + + {temp} + + {unitsFlag[temperatureUnit || 'metric']} + + +
    + + + {description} + +
    + ) +} diff --git a/src/layouts/widgets/weather/variants/weather-wide-banner.tsx b/src/layouts/widgets/weather/variants/weather-wide-banner.tsx new file mode 100644 index 000000000..106e5c434 --- /dev/null +++ b/src/layouts/widgets/weather/variants/weather-wide-banner.tsx @@ -0,0 +1,78 @@ +import type { FetchedWeather } from '@/layouts/widgets/weather/weather.interface' +import { unitsFlag } from '../unit-symbols' +import { cleanCityName } from '../utils/clean-city-name' +import { Icon } from '@/src/icons' + +interface WeatherWideBannerProps { + fetchedWeather: FetchedWeather | null + temperatureUnit: keyof typeof unitsFlag +} + +export function WeatherWideBanner({ + fetchedWeather, + temperatureUnit, +}: WeatherWideBannerProps) { + const temp = Math.round(fetchedWeather?.weather?.temperature?.temp || 0) + const cityName = cleanCityName(fetchedWeather?.city?.fa || '') + const iconUrl = fetchedWeather?.weather?.icon?.url + const description = fetchedWeather?.weather?.description?.text || '' + const tempDescription = + fetchedWeather?.weather?.temperature?.temp_description || '' + const humidity = fetchedWeather?.weather?.temperature?.humidity || 0 + const windSpeed = Math.round( + fetchedWeather?.weather?.temperature?.wind_speed || 0 + ) + const clouds = fetchedWeather?.weather?.temperature?.clouds || 0 + + return ( +
    +
    + {iconUrl ? ( + {description} + ) : ( +
    + )} + +
    + + {temp} + + {unitsFlag[temperatureUnit || 'metric']} + + + {cityName} +
    + +
    + {description} + {tempDescription && ( + + {tempDescription} + + )} +
    +
    + +
    +
    + + {windSpeed} m/s +
    + +
    + + {humidity}% +
    + +
    + + {clouds}% +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/weather/variants/weather-wide-full.tsx b/src/layouts/widgets/weather/variants/weather-wide-full.tsx new file mode 100644 index 000000000..725fd75d8 --- /dev/null +++ b/src/layouts/widgets/weather/variants/weather-wide-full.tsx @@ -0,0 +1,37 @@ +import type { FetchedWeather } from '@/layouts/widgets/weather/weather.interface' +import { unitsFlag } from '../unit-symbols' +import { CurrentWeatherBox } from '../current/current-box.weather' +import { Forecast } from '../forecast/forecast' + +interface WeatherWideFullProps { + fetchedWeather: FetchedWeather | null + temperatureUnit: keyof typeof unitsFlag +} + +export function WeatherWideFull({ + fetchedWeather, + temperatureUnit, +}: WeatherWideFullProps) { + return ( +
    +
    + +
    + +
    +
    + پیش‌بینی ساعات آینده +
    +
    + +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/weather/weather.layout.tsx b/src/layouts/widgets/weather/weather.layout.tsx index b0594db3d..5687db2c4 100644 --- a/src/layouts/widgets/weather/weather.layout.tsx +++ b/src/layouts/widgets/weather/weather.layout.tsx @@ -5,9 +5,18 @@ import type { WeatherSettings } from '@/layouts/widgets/weather/weather.interfac import { WidgetContainer } from '../widget-container' import { Forecast } from './forecast/forecast' import { CurrentWeatherBox } from './current/current-box.weather' +import { WeatherCompactSquare } from './variants/weather-compact-square' +import { WeatherCompactRow } from './variants/weather-compact-row' +import { WeatherWideBanner } from './variants/weather-wide-banner' +import { WeatherWideFull } from './variants/weather-wide-full' import { useGetWeatherByLatLon } from '@/services/hooks/weather/get-weather-by-lat-lon' +import type { WidgetSize } from '../layout-engine/types' -export function WeatherLayout() { +interface WeatherLayoutProps { + size?: WidgetSize +} + +export function WeatherLayout({ size = { w: 2, h: 2 } }: WeatherLayoutProps = {}) { const [weatherSettings, setWeatherSettings] = useState(null) const { data } = useGetWeatherByLatLon(true) @@ -40,6 +49,50 @@ export function WeatherLayout() { if (!weatherSettings) return null + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h >= 2) { + return ( + + + + ) + } + return (
    @@ -48,7 +101,7 @@ export function WeatherLayout() { temperatureUnit={weatherSettings.temperatureUnit} /> -
    +
    - {children} +
    +
    + {children} +
    ) } diff --git a/src/layouts/widgets/widget-registry.tsx b/src/layouts/widgets/widget-registry.tsx new file mode 100644 index 000000000..83f7e96c5 --- /dev/null +++ b/src/layouts/widgets/widget-registry.tsx @@ -0,0 +1,442 @@ +import type React from 'react' +import { BookmarkProvider } from '@/layouts/bookmark/context/bookmark.context' +import { BookmarksList } from '@/layouts/bookmark/bookmarks' +import { SearchLayout } from '@/layouts/search/search' +import { WidgetifyLayout } from '@/layouts/widgetify-card/widgetify.layout' +import CalendarLayout from '@/layouts/widgets/calendar/calendar' +import { ComboWidget } from '@/layouts/widgets/combo-widget/combo-widget.layout' +import { NetworkLayout } from '@/layouts/widgets/network/network.layout' +import { NewsLayout } from '@/layouts/widgets/news/news.layout' +import { ToolsLayout } from '@/layouts/widgets/tools/tools.layout' +import { WeatherLayout } from '@/layouts/widgets/weather/weather.layout' +import { WigiArzLayout } from '@/layouts/widgets/wigi-arz/wigi_arz.layout' +import { WigiPadWidget } from '@/layouts/widgets/wigi-pad/wigi-pad.layout' +import { YadkarWidget } from '@/layouts/widgets/yadkar/yadkar' +import { HabitsLayout } from '@/layouts/widgets/habit/habits.layout' +import { CurrencyProvider } from '@/context/currency.context' +import { ClockWidget } from './clock/clock.widget' +import { DateWidget } from './date/date.widget' +import { TodosLayout } from './todos/todos' +import { NotesLayout } from './notes/notes.layout' +import { WidgetContainer } from './widget-container' +import { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' +import { type WidgetDefinition, WidgetKeys, type WidgetSize } from './layout-engine/types' + +export const WIDGET_DEFINITIONS: Record = { + [WidgetKeys.search]: { + id: WidgetKeys.search, + label: 'جستجو', + emoji: '🔍', + allowedSizes: [ + { w: 2, h: 1 }, + { w: 4, h: 1 }, + { w: 6, h: 1 }, + { w: 8, h: 1 }, + ], + defaultSize: { w: 4, h: 1 }, + canDuplicate: false, + preview: () => ( +
    + 🔍 + جستجو... +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.bookmarks]: { + id: WidgetKeys.bookmarks, + label: 'بوکمارک‌ها', + emoji: '🔖', + allowedSizes: [ + { w: 2, h: 1 }, + { w: 2, h: 2 }, + { w: 4, h: 1 }, + { w: 4, h: 2 }, + { w: 8, h: 1 }, + { w: 8, h: 2 }, + ], + defaultSize: { w: 4, h: 2 }, + canDuplicate: true, + preview: () => ( +
    + {[...Array(4)].map((_, i) => ( +
    + 🌐 +
    + ))} +
    + ), + node: (instanceId, size) => ( + + + + ), + }, + [WidgetKeys.widgetify]: { + id: WidgetKeys.widgetify, + label: 'ویجتیفای', + emoji: '✨', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 2, h: 3 }, + ], + defaultSize: { w: 2, h: 3 }, + settingsTab: WidgetTabKeys.Pet, + canDuplicate: false, + preview: () => ( +
    + 🐱 + ویجتیفای +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.wigiPad]: { + id: WidgetKeys.wigiPad, + label: 'ویجی‌پد', + emoji: '⏰', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 2, h: 3 }, + ], + defaultSize: { w: 2, h: 3 }, + settingsTab: WidgetTabKeys.wigiPad, + canDuplicate: false, + preview: () => ( +
    + 12:30 + سه‌شنبه، ۲۸ مرداد +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.clock]: { + id: WidgetKeys.clock, + label: 'ساعت', + emoji: '🕒', + allowedSizes: [ + { w: 1, h: 1 }, + { w: 2, h: 1 }, + { w: 4, h: 1 }, + ], + defaultSize: { w: 2, h: 1 }, + settingsTab: WidgetTabKeys.wigiPad, + canDuplicate: true, + preview: () => ( +
    + 12:30:45 +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.date]: { + id: WidgetKeys.date, + label: 'تاریخ', + emoji: '📅', + allowedSizes: [ + { w: 1, h: 1 }, + { w: 2, h: 1 }, + { w: 2, h: 2 }, + { w: 4, h: 1 }, + ], + defaultSize: { w: 2, h: 2 }, + settingsTab: WidgetTabKeys.wigiPad, + canDuplicate: true, + preview: () => ( +
    + ۲۸ مرداد ۱۴۰۵ + سه‌شنبه +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.calendar]: { + id: WidgetKeys.calendar, + label: 'تقویم', + emoji: '📆', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 2, h: 3 }, + { w: 4, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + preview: () => ( +
    + + مرداد ۱۴۰۵ + +
    + {[...Array(7)].map((_, i) => ( + {i + 1} + ))} +
    +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.weather]: { + id: WidgetKeys.weather, + label: 'آب و هوا', + emoji: '🌤️', + allowedSizes: [ + { w: 1, h: 1 }, + { w: 2, h: 1 }, + { w: 2, h: 2 }, + { w: 4, h: 1 }, + { w: 4, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + settingsTab: WidgetTabKeys.weather_settings, + canDuplicate: true, + preview: () => ( +
    + 🌤️ + 26°C +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.comboWidget]: { + id: WidgetKeys.comboWidget, + label: 'ویجت ترکیبی (ارز و اخبار)', + emoji: '🔗', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 4, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + preview: () => ( +
    +
    + ارزها + اخبار +
    +
    + USD + 60,000 +
    +
    + ), + node: (instanceId, size) => ( + + + + ), + }, + [WidgetKeys.yadKar]: { + id: WidgetKeys.yadKar, + label: 'یادکار (وظایف/یادداشت/عادت‌ها)', + emoji: '📒', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 2, h: 3 }, + { w: 4, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + preview: () => ( +
    +
    + تسک‌ها + یادداشت +
    +
    ۳ تسک باقی‌مانده
    +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.tools]: { + id: WidgetKeys.tools, + label: 'ابزارها', + emoji: '🧰', + allowedSizes: [{ w: 2, h: 2 }], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + preview: () => ( +
    + ⏱️ + 🕌 + 🔄 +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.arzLive]: { + id: WidgetKeys.arzLive, + label: 'ویجی ارز', + emoji: '💰', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 4, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + settingsTab: WidgetTabKeys.wigiArz, + canDuplicate: true, + preview: () => ( +
    +
    + USD / IRR + 60,000 +
    +
    + EUR / IRR + 65,000 +
    +
    + ), + node: (instanceId, size) => ( + + + + ), + }, + [WidgetKeys.news]: { + id: WidgetKeys.news, + label: 'ویجی نیوز', + emoji: '📰', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 4, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + settingsTab: WidgetTabKeys.news_settings, + canDuplicate: true, + preview: () => ( +
    + + آخرین سرخط اخبار ایران و جهان + + ۲ دقیقه پیش +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.network]: { + id: WidgetKeys.network, + label: 'شبکه', + emoji: '🌐', + allowedSizes: [{ w: 2, h: 2 }], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + preview: () => ( +
    +
    +
    + متصل +
    + Ping: 32ms +
    + ), + node: (instanceId, size) => ( + + ), + }, + [WidgetKeys.HabitTracker]: { + id: WidgetKeys.HabitTracker, + label: 'عادات', + emoji: '🎯', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 2, h: 3 }, + ], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + preview: () => ( +
    + + عادات روزانه + +
    + {[...Array(5)].map((_, i) => ( +
    + ✓ +
    + ))} +
    +
    + ), + node: (instanceId, size) => , + }, + [WidgetKeys.todos]: { + id: WidgetKeys.todos, + label: 'تسک‌ها', + emoji: '✅', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 2, h: 3 }, + { w: 4, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + preview: () => ( +
    +
    + ☑️ + مطالعه کتاب +
    +
    + ⬜ + ورزش صبحگاهی +
    +
    + ), + node: (instanceId, size) => ( + + + + ), + }, + [WidgetKeys.notes]: { + id: WidgetKeys.notes, + label: 'یادداشت', + emoji: '📝', + allowedSizes: [ + { w: 2, h: 2 }, + { w: 2, h: 3 }, + { w: 4, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + preview: () => ( +
    + + یادداشت‌های من + + + ایده‌های طراحی جدید... + +
    + ), + node: (instanceId, size) => ( + + + + ), + }, + [WidgetKeys.youtube]: { + id: WidgetKeys.youtube, + label: 'یوتیوب', + emoji: '▶️', + allowedSizes: [{ w: 2, h: 2 }], + defaultSize: { w: 2, h: 2 }, + canDuplicate: false, + preview: () => ( +
    + ▶️ +
    + ), + node: () => null, + }, +} diff --git a/src/layouts/widgets/wigi-arz/wigi_arz.layout.tsx b/src/layouts/widgets/wigi-arz/wigi_arz.layout.tsx index 1923bc834..29a7f7a7c 100644 --- a/src/layouts/widgets/wigi-arz/wigi_arz.layout.tsx +++ b/src/layouts/widgets/wigi-arz/wigi_arz.layout.tsx @@ -20,16 +20,20 @@ import { ArzHeader } from './components/arz-header' import { SortableCurrencyBox } from './components/sortable-currency-box' import { Button } from '@/components/ui' +import type { WidgetSize } from '../layout-engine/types' + interface WigiArzLayoutProps { enableBackground?: boolean inComboWidget: boolean comboClassName?: string + size?: WidgetSize } export function WigiArzLayout({ enableBackground = true, inComboWidget, comboClassName, + size, }: WigiArzLayoutProps) { const { selectedCurrencies, currencyColorMode, reorderCurrencies } = useCurrencyStore() diff --git a/src/layouts/widgets/wigi-pad/wigi-pad.layout.tsx b/src/layouts/widgets/wigi-pad/wigi-pad.layout.tsx index ef6f58784..55043e76a 100644 --- a/src/layouts/widgets/wigi-pad/wigi-pad.layout.tsx +++ b/src/layouts/widgets/wigi-pad/wigi-pad.layout.tsx @@ -4,17 +4,23 @@ import { DateDisplay } from './date-display/date.display' import { useGetNotifications } from '@/services/hooks/extension/get-notifications.hook' import { RenderWigiPadItem } from './info-panel/components/ann-item' -export function WigiPadWidget() { +import type { WidgetSize } from '../layout-engine/types' + +interface WigiPadWidgetProps { + size?: WidgetSize +} + +export function WigiPadWidget({ size }: WigiPadWidgetProps = {}) { const { data: fetchedData } = useGetNotifications() return ( - +
    -
    -
    +
    +
    {fetchedData?.wigiPad.map((notification, index) => ( ))} diff --git a/src/layouts/widgets/yadkar/yadkar.tsx b/src/layouts/widgets/yadkar/yadkar.tsx index 40db406f4..47efa4aca 100644 --- a/src/layouts/widgets/yadkar/yadkar.tsx +++ b/src/layouts/widgets/yadkar/yadkar.tsx @@ -10,7 +10,14 @@ import { useEffect } from 'react' import { Icon } from '@/src/icons' type Tab = 'todos' | 'notes' | 'rabbit' -export function YadkarWidget() { + +import type { WidgetSize } from '../layout-engine/types' + +interface YadkarWidgetProps { + size?: WidgetSize +} + +export function YadkarWidget({ size }: YadkarWidgetProps = {}) { const [tab, setTab] = useState('todos') const onChangeTab = (newTab: Tab) => { diff --git a/src/pages/home/ui/content-section.tsx b/src/pages/home/ui/content-section.tsx index 1f9eee16d..0dc4af22b 100644 --- a/src/pages/home/ui/content-section.tsx +++ b/src/pages/home/ui/content-section.tsx @@ -1,196 +1,31 @@ -import { - closestCenter, - DndContext, - type DragEndEvent, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core' -import { rectSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import Analytics from '@/analytics' import { useAppearanceSetting } from '@/context/appearance.context' import { DateProvider } from '@/context/date.context' -import { useWidgetVisibility, type WidgetItem } from '@/context/widget-visibility.context' -import { BookmarksList } from '@/layouts/bookmark/bookmarks' -import { SearchLayout } from '@/layouts/search/search' -import { WidgetifyLayout } from '@/layouts/widgetify-card/widgetify.layout' -import { WigiPadWidget } from '@/layouts/widgets/wigi-pad/wigi-pad.layout' -import { BookmarkProvider } from '@/layouts/bookmark/context/bookmark.context' - -function SortableWidget({ widget }: { widget: WidgetItem }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ - id: widget.id, - disabled: false, - }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 999 : 'auto', - } - - const dragListeners = { - ...listeners, - onPointerDown: (event: React.PointerEvent) => { - const target = event.target as HTMLElement - const isInput = - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.contentEditable === 'true' || - target.closest('input, textarea, [contenteditable="true"]') || - target.closest('button, select, a') - - if (isInput) { - return - } - - if (listeners?.onPointerDown) { - listeners.onPointerDown(event) - } - }, - } - - return ( -
    - {widget.node} -
    - ) -} +import { FreeWidgetProvider } from '@/context/free-widget.context' +import { FreeWidgetCanvas } from '@/layouts/widgets/canvas/free-widget-canvas' export function ContentSection() { - const { canReOrderWidget, contentAlignment } = useAppearanceSetting() - - const { getSortedWidgets, reorderWidgets } = useWidgetVisibility() - const sortedWidgets = getSortedWidgets().filter((widget) => !widget.disabled) - - const totalWidgetCount = sortedWidgets.length - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ) - - const handleDragEnd = (event: DragEndEvent) => { - if (!canReOrderWidget) return - const { active, over } = event - - if (!over || active.id === over.id) { - return - } - - const oldIndex = sortedWidgets.findIndex((item) => item.id === active.id) - const newIndex = sortedWidgets.findIndex((item) => item.id === over.id) - - if (oldIndex !== -1 && newIndex !== -1) { - reorderWidgets(oldIndex, newIndex) - } - - Analytics.event('widget_reorder') - } - - let layoutClasses = - 'grid w-full grid-cols-1 gap-2 transition-all duration-300 md:grid-cols-2 lg:grid-cols-4 md:gap-4' - if (totalWidgetCount === 2) { - layoutClasses = - 'flex flex-col flex-wrap w-full gap-2 lg:flex-nowrap md:flex-row md:gap-4 justify-between transition-all duration-300 items-center' - } + const { contentAlignment } = useAppearanceSetting() return ( -
    +
    -
    -
    - -
    - -
    - - - - -
    - -
    - +
    +
    +
    - - {sortedWidgets.length > 0 && ( -
    - - widget.id)} - strategy={rectSortingStrategy} - > -
    - {sortedWidgets.map((widget) => { - if (totalWidgetCount === 2) { - return ( -
    - {canReOrderWidget ? ( - - ) : ( - widget.node - )} -
    - ) - } - return canReOrderWidget ? ( - - ) : ( - widget.node - ) - })} -
    -
    -
    -
    - )}
    -
    + ) } diff --git a/src/services/hooks/bookmark/add-bookmark.hook.ts b/src/services/hooks/bookmark/add-bookmark.hook.ts index b12558561..fba372a59 100644 --- a/src/services/hooks/bookmark/add-bookmark.hook.ts +++ b/src/services/hooks/bookmark/add-bookmark.hook.ts @@ -12,6 +12,7 @@ export interface BookmarkCreationPayload { customTextColor: string | null customBackground: string | null icon: File | null + widgetId?: string | null } export const useAddBookmark = () => { diff --git a/src/services/hooks/bookmark/get-bookmarks.hook.ts b/src/services/hooks/bookmark/get-bookmarks.hook.ts index 6a63d7c23..55fac5364 100644 --- a/src/services/hooks/bookmark/get-bookmarks.hook.ts +++ b/src/services/hooks/bookmark/get-bookmarks.hook.ts @@ -21,6 +21,7 @@ export interface FetchedBookmark { customBackground?: string sticker?: string order?: number + widgetId?: string | null } export const useGetBookmarks = (id: string | null, enabled: boolean) => { diff --git a/src/services/hooks/widgets/widget-sync.hook.ts b/src/services/hooks/widgets/widget-sync.hook.ts new file mode 100644 index 000000000..3fc1f356f --- /dev/null +++ b/src/services/hooks/widgets/widget-sync.hook.ts @@ -0,0 +1,64 @@ +import { useMutation } from '@tanstack/react-query' +import { getMainClient, safeAwait } from '@/services/api' +import type { WidgetPosition, WidgetSize } from '@/layouts/widgets/layout-engine/types' +import type { AxiosError } from 'axios' + +export interface CreateWidgetPayload { + type: string + position: WidgetPosition + size: WidgetSize + clientInstanceId: string + settings?: Record +} + +export interface ServerWidgetResponse { + id: string + type: string + position: WidgetPosition + size: WidgetSize + createdAt: string +} + +/** + * Creates a widget record on the server. + * + * NOTE (Future Server Contract): + * Endpoint: POST /widgets + * When the server implementation is completed: + * 1. The client sends { type, position, size, clientInstanceId, settings } + * 2. The server creates the widget entity and returns { id, type, position, size, createdAt } + * 3. The returned `id` is stored as `widgetId` on the client widget instance. + * 4. All bookmarks, notes, or widgets-scoped items created inside this widget will reference this `widgetId`. + */ +export async function createWidgetApi( + payload: CreateWidgetPayload +): Promise { + const client = getMainClient() + + const [err, response] = await safeAwait( + client.post('/widgets', payload) + ) + + if (err || !response) { + // Mock fallback until server endpoint is implemented + const mockServerWidgetId = `widget-srv-${payload.clientInstanceId}-${Date.now().toString(36)}` + return { + id: mockServerWidgetId, + type: payload.type, + position: payload.position, + size: payload.size, + createdAt: new Date().toISOString(), + } + } + + return response +} + +export const useCreateWidget = () => { + return useMutation({ + mutationKey: ['createWidget'], + mutationFn: async (payload: CreateWidgetPayload) => { + return await createWidgetApi(payload) + }, + }) +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe2a..f162406c6 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,8 @@ /// + +declare module 'bun:test' { + export const describe: (name: string, fn: () => void) => void + export const it: (name: string, fn: () => void | Promise) => void + export const expect: (actual: any) => any +} + From 256a4d9c10e4ef4734389405714fd6c084e73b33 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Wed, 19 Aug 2026 21:06:27 +0330 Subject: [PATCH 004/260] feat: implement base widget layouts, variants, and registry architecture --- src/common/utils/persian-digits.ts | 3 + src/context/widget-visibility.context.tsx | 1 + .../widgetify-card/pets/pet-factory.tsx | 6 +- .../habit/variants/habit-compact-row.tsx | 87 ++++++ .../habit/variants/habit-compact-square.tsx | 63 +++++ src/layouts/widgets/layout-engine/types.ts | 1 + src/layouts/widgets/pet/pet-emoji-map.ts | 9 + src/layouts/widgets/pet/pet.widget.tsx | 35 +++ .../pet/variants/pet-compact-square.tsx | 39 +++ src/layouts/widgets/todos/todos.tsx | 20 +- .../todos/variants/todo-compact-row.tsx | 85 ++++++ src/layouts/widgets/widget-registry.tsx | 38 ++- .../variants/currency-compact-row.tsx | 110 ++++++++ .../variants/currency-compact-square.tsx | 89 +++++++ .../variants/currency-wide-banner.tsx | 110 ++++++++ .../wigi-arz/variants/currency-wide-grid.tsx | 75 ++++++ .../widgets/wigi-arz/wigi_arz.layout.tsx | 249 +++++++++--------- src/layouts/widgets/yadkar/yadkar.tsx | 2 +- 18 files changed, 889 insertions(+), 133 deletions(-) create mode 100644 src/common/utils/persian-digits.ts create mode 100644 src/layouts/widgets/habit/variants/habit-compact-row.tsx create mode 100644 src/layouts/widgets/habit/variants/habit-compact-square.tsx create mode 100644 src/layouts/widgets/pet/pet-emoji-map.ts create mode 100644 src/layouts/widgets/pet/pet.widget.tsx create mode 100644 src/layouts/widgets/pet/variants/pet-compact-square.tsx create mode 100644 src/layouts/widgets/todos/variants/todo-compact-row.tsx create mode 100644 src/layouts/widgets/wigi-arz/variants/currency-compact-row.tsx create mode 100644 src/layouts/widgets/wigi-arz/variants/currency-compact-square.tsx create mode 100644 src/layouts/widgets/wigi-arz/variants/currency-wide-banner.tsx create mode 100644 src/layouts/widgets/wigi-arz/variants/currency-wide-grid.tsx diff --git a/src/common/utils/persian-digits.ts b/src/common/utils/persian-digits.ts new file mode 100644 index 000000000..46b5be416 --- /dev/null +++ b/src/common/utils/persian-digits.ts @@ -0,0 +1,3 @@ +export function toPersianDigits(val: string | number): string { + return String(val).replace(/[0-9]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]) +} diff --git a/src/context/widget-visibility.context.tsx b/src/context/widget-visibility.context.tsx index 01bbd19de..8abe1c530 100644 --- a/src/context/widget-visibility.context.tsx +++ b/src/context/widget-visibility.context.tsx @@ -40,6 +40,7 @@ export enum WidgetKeys { widgetify = 'widgetify', clock = 'clock', date = 'date', + pet = 'pet', } export interface WidgetItem { id: WidgetKeys diff --git a/src/layouts/widgetify-card/pets/pet-factory.tsx b/src/layouts/widgetify-card/pets/pet-factory.tsx index b710eeebe..63900157e 100644 --- a/src/layouts/widgetify-card/pets/pet-factory.tsx +++ b/src/layouts/widgetify-card/pets/pet-factory.tsx @@ -7,8 +7,12 @@ import { ChickenComponent } from './pet-item/pet-chicken' import { CrabComponent } from './pet-item/pet-crab' import { DogComponent } from './pet-item/pet-dog' import { FrogComponent } from './pet-item/pet-frog' +import { cn } from '@/common/utils/cn' -export const PetFactory: React.FC = () => { +interface Prop { + className?: string +} +export const PetFactory: React.FC = ({ className }) => { const { petType, getPetHungryState } = usePetContext() if (!petType) return null diff --git a/src/layouts/widgets/habit/variants/habit-compact-row.tsx b/src/layouts/widgets/habit/variants/habit-compact-row.tsx new file mode 100644 index 000000000..83283c89d --- /dev/null +++ b/src/layouts/widgets/habit/variants/habit-compact-row.tsx @@ -0,0 +1,87 @@ +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { SimpleProgressRing } from '../components/item/button.simple-progress-ring' +import { Icon } from '@/src/icons' + +function toPersianDigits(val: string | number): string { + const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return String(val).replace(/\d/g, (x) => farsiDigits[Number.parseInt(x, 10)]) +} + +interface HabitCompactRowProps { + habits: Habit[] + isLoading: boolean +} + +export function HabitCompactRow({ habits, isLoading }: HabitCompactRowProps) { + const displayHabits = habits.slice(0, 2) + + if (isLoading) { + return ( +
    +
    +
    +
    + ) + } + + if (habits.length === 0) { + return ( +
    + + عادتی ثبت نشده است +
    + ) + } + + return ( +
    + {displayHabits.map((habit) => { + const color = habit.color || '#536dfe' + const target = habit.target || 1 + const value = habit.today.value + const isCompleted = value >= target + + return ( +
    +
    +
    +
    + + {isCompleted && ( + + ✓ + + )} +
    + + {habit.title} + +
    +
    + +
    + + {toPersianDigits(value)} / {toPersianDigits(target)} + + {habit.streak?.current ? ( + + 🔥 + {toPersianDigits(habit.streak.current)} + + ) : null} +
    +
    + ) + })} +
    + ) +} diff --git a/src/layouts/widgets/habit/variants/habit-compact-square.tsx b/src/layouts/widgets/habit/variants/habit-compact-square.tsx new file mode 100644 index 000000000..ea3dbb9f8 --- /dev/null +++ b/src/layouts/widgets/habit/variants/habit-compact-square.tsx @@ -0,0 +1,63 @@ +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { Icon } from '@/src/icons' + +function toPersianDigits(val: string | number): string { + const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return String(val).replace(/\d/g, (x) => farsiDigits[Number.parseInt(x, 10)]) +} + +interface HabitCompactSquareProps { + habits: Habit[] + isLoading: boolean +} + +export function HabitCompactSquare({ + habits, + isLoading, +}: HabitCompactSquareProps) { + const total = habits.length + const completed = habits.filter( + (h) => h.today.value >= (h.target || 1) + ).length + const percent = total > 0 ? Math.round((completed / total) * 100) : 0 + const maxStreak = Math.max(...habits.map((h) => h.streak?.current || 0), 0) + + if (isLoading) { + return ( +
    +
    +
    +
    +
    + ) + } + + return ( +
    +
    + + عادت‌ها +
    + +
    + + {toPersianDigits(percent)}٪ + + + {toPersianDigits(completed)} از {toPersianDigits(total)} انجام شد + +
    + +
    + {maxStreak > 0 ? ( + <> + 🔥 + بیشترین استریک: {toPersianDigits(maxStreak)} روز + + ) : ( + امروز رو ثبت کن! + )} +
    +
    + ) +} diff --git a/src/layouts/widgets/layout-engine/types.ts b/src/layouts/widgets/layout-engine/types.ts index 0b7ecff57..0518f1445 100644 --- a/src/layouts/widgets/layout-engine/types.ts +++ b/src/layouts/widgets/layout-engine/types.ts @@ -17,6 +17,7 @@ export enum WidgetKeys { widgetify = 'widgetify', clock = 'clock', date = 'date', + pet = 'pet', } export interface WidgetPosition { diff --git a/src/layouts/widgets/pet/pet-emoji-map.ts b/src/layouts/widgets/pet/pet-emoji-map.ts new file mode 100644 index 000000000..9552eb979 --- /dev/null +++ b/src/layouts/widgets/pet/pet-emoji-map.ts @@ -0,0 +1,9 @@ +import { PetTypes } from '@/layouts/widgetify-card/pets/pet.context' + +export const PetEmojiMap: Record = { + [PetTypes.DOG_AKITA]: '🐶', + [PetTypes.CHICKEN]: '🐔', + [PetTypes.CRAB]: '🦀', + [PetTypes.FROG]: '🐸', + [PetTypes.CAT]: '🐱', +} diff --git a/src/layouts/widgets/pet/pet.widget.tsx b/src/layouts/widgets/pet/pet.widget.tsx new file mode 100644 index 000000000..8645d7414 --- /dev/null +++ b/src/layouts/widgets/pet/pet.widget.tsx @@ -0,0 +1,35 @@ +import { PetProvider } from '@/layouts/widgetify-card/pets/pet.context' +import { PetFactory } from '@/layouts/widgetify-card/pets/pet-factory' +import { WidgetContainer } from '../widget-container' +import type { WidgetSize } from '../layout-engine/types' +import { PetCompactSquare } from './variants/pet-compact-square' + +interface PetWidgetContentProps { + size?: WidgetSize +} + +function PetWidgetContent({ size = { w: 2, h: 1 } }: PetWidgetContentProps) { + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + return ( + +
    + +
    +
    + ) +} + +export function PetWidget({ size = { w: 2, h: 1 } }: { size?: WidgetSize }) { + return ( + + + + ) +} diff --git a/src/layouts/widgets/pet/variants/pet-compact-square.tsx b/src/layouts/widgets/pet/variants/pet-compact-square.tsx new file mode 100644 index 000000000..7148155a9 --- /dev/null +++ b/src/layouts/widgets/pet/variants/pet-compact-square.tsx @@ -0,0 +1,39 @@ +import { usePetContext } from '@/layouts/widgetify-card/pets/pet.context' +import { PetEmojiMap } from '../pet-emoji-map' + +export function PetCompactSquare() { + const { petType, getCurrentPetName, getPetHungryState } = usePetContext() + if (!petType) return null + + const petName = getCurrentPetName(petType) + const hungryState = getPetHungryState(petType) + const level = hungryState?.level ?? 100 + + return ( +
    + + {petName} + + +
    + {PetEmojiMap[petType] || '🐾'} +
    + +
    +
    +
    50 + ? 'bg-success' + : level > 20 + ? 'bg-warning' + : 'bg-error' + }`} + style={{ width: `${level}%` }} + /> +
    + {level}% غذا +
    +
    + ) +} diff --git a/src/layouts/widgets/todos/todos.tsx b/src/layouts/widgets/todos/todos.tsx index 41da87e45..70472637d 100644 --- a/src/layouts/widgets/todos/todos.tsx +++ b/src/layouts/widgets/todos/todos.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { ExpandableTodoInput } from './expandable-todo-input' import { useAuth } from '@/context/auth.context' import Analytics from '@/analytics' @@ -13,6 +13,8 @@ import { useGetTodos } from '@/services/hooks/todo/get-todos.hook' import { TodoItem } from './todo.item' import { Icon } from '@/src/icons' import { TodosEmpty } from './components/todo-empty' +import { TodoCompactRow } from './variants/todo-compact-row' +import type { WidgetSize } from '../layout-engine/types' const filterOptions = [ { value: 'all', label: 'همه' }, @@ -30,7 +32,11 @@ const sortOptions = [ ] const TagList = ['', '-all-'] -export function TodosLayout() { +interface TodosLayoutProps { + size?: WidgetSize +} + +export function TodosLayout({ size = { w: 2, h: 2 } }: TodosLayoutProps = {}) { const { isAuthenticated } = useAuth() const { blurMode } = useGeneralSetting() const [editingTodo, setEditingTodo] = useState(null) @@ -174,6 +180,16 @@ export function TodosLayout() { Analytics.event(`todo_refetch`) } + if (size.w === 2 && size.h === 1) { + return ( + + ) + } + return ( <>
    diff --git a/src/layouts/widgets/todos/variants/todo-compact-row.tsx b/src/layouts/widgets/todos/variants/todo-compact-row.tsx new file mode 100644 index 000000000..6b05129ab --- /dev/null +++ b/src/layouts/widgets/todos/variants/todo-compact-row.tsx @@ -0,0 +1,85 @@ +import type { Todo } from '@/services/hooks/todo/todo.interface' +import { useUpdateTodo } from '@/services/hooks/todo/update-todo.hook' +import { Icon } from '@/src/icons' + +interface TodoCompactRowProps { + todos: Todo[] + isLoading: boolean + onRefresh: () => void +} + +export function TodoCompactRow({ todos, isLoading, onRefresh }: TodoCompactRowProps) { + const totalCount = todos.length + const completedCount = todos.filter((t) => t.completed).length + const nextPending = todos.find((t) => !t.completed) || todos[0] + + const { mutateAsync: updateTodo } = useUpdateTodo(nextPending?.id || null) + + const handleToggle = async (e: React.MouseEvent) => { + e.stopPropagation() + if (!nextPending) return + await updateTodo({ + id: nextPending.id, + input: { completed: !nextPending.completed }, + }) + onRefresh() + } + + if (isLoading) { + return ( +
    +
    +
    +
    +
    +
    +
    + ) + } + + return ( +
    +
    + {nextPending ? ( + <> + + + {nextPending.text} + + + ) : ( +
    + + همه تسک‌ها انجام شدند +
    + )} +
    + +
    +
    + {completedCount} + / + {totalCount} +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/widget-registry.tsx b/src/layouts/widgets/widget-registry.tsx index 83f7e96c5..79ced6c13 100644 --- a/src/layouts/widgets/widget-registry.tsx +++ b/src/layouts/widgets/widget-registry.tsx @@ -16,6 +16,7 @@ import { HabitsLayout } from '@/layouts/widgets/habit/habits.layout' import { CurrencyProvider } from '@/context/currency.context' import { ClockWidget } from './clock/clock.widget' import { DateWidget } from './date/date.widget' +import { PetWidget } from './pet/pet.widget' import { TodosLayout } from './todos/todos' import { NotesLayout } from './notes/notes.layout' import { WidgetContainer } from './widget-container' @@ -94,6 +95,25 @@ export const WIDGET_DEFINITIONS: Record = { ), node: (instanceId, size) => , }, + [WidgetKeys.pet]: { + id: WidgetKeys.pet, + label: 'پت (حیوان خانگی)', + emoji: '🐾', + allowedSizes: [ + { w: 1, h: 1 }, + { w: 2, h: 1 }, + ], + defaultSize: { w: 2, h: 1 }, + settingsTab: WidgetTabKeys.Pet, + canDuplicate: true, + preview: () => ( +
    + 🐶 + حیوان خانگی +
    + ), + node: (instanceId, size) => , + }, [WidgetKeys.wigiPad]: { id: WidgetKeys.wigiPad, label: 'ویجی‌پد', @@ -271,7 +291,11 @@ export const WIDGET_DEFINITIONS: Record = { label: 'ویجی ارز', emoji: '💰', allowedSizes: [ + { w: 1, h: 1 }, + { w: 2, h: 1 }, { w: 2, h: 2 }, + { w: 2, h: 3 }, + { w: 4, h: 1 }, { w: 4, h: 2 }, ], defaultSize: { w: 2, h: 2 }, @@ -333,11 +357,7 @@ export const WIDGET_DEFINITIONS: Record = {
    ), node: (instanceId, size) => ( - + ), }, [WidgetKeys.HabitTracker]: { @@ -352,9 +372,7 @@ export const WIDGET_DEFINITIONS: Record = { canDuplicate: true, preview: () => (
    - - عادات روزانه - + عادات روزانه
    {[...Array(5)].map((_, i) => (
    = { label: 'تسک‌ها', emoji: '✅', allowedSizes: [ + { w: 2, h: 1 }, { w: 2, h: 2 }, { w: 2, h: 3 }, - { w: 4, h: 2 }, ], defaultSize: { w: 2, h: 2 }, canDuplicate: true, @@ -394,7 +412,7 @@ export const WIDGET_DEFINITIONS: Record = { ), node: (instanceId, size) => ( - + ), }, diff --git a/src/layouts/widgets/wigi-arz/variants/currency-compact-row.tsx b/src/layouts/widgets/wigi-arz/variants/currency-compact-row.tsx new file mode 100644 index 000000000..7064f26d1 --- /dev/null +++ b/src/layouts/widgets/wigi-arz/variants/currency-compact-row.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react' +import { getFromStorage, setToStorage } from '@/common/storage' +import { + type FetchedCurrency, + useGetCurrencyByCode, +} from '@/services/hooks/currency/get-currency-by-code.hook' +import { GetPrice } from '../utils/get-price' + +interface CurrencyItemCompactProps { + code: string +} + +function CurrencyItemCompact({ code }: CurrencyItemCompactProps) { + const { data, dataUpdatedAt } = useGetCurrencyByCode(code, { + refetchInterval: null, + }) + + const [currency, setCurrency] = useState(null) + + useEffect(() => { + async function load() { + const cached = await getFromStorage(`currency:${code}`) + if (cached) { + setCurrency(cached) + } + } + load() + }, [code]) + + useEffect(() => { + if (data) { + setCurrency(data) + setToStorage(`currency:${code}`, data) + } + }, [dataUpdatedAt]) + + if (!currency) { + return ( +
    +
    +
    +
    +
    +
    +
    + ) + } + + const priceResult = GetPrice(code, currency) + const change = currency.changePercentage + const isPositive = (change || 0) >= 0 + + return ( +
    +
    +
    + {currency.icon && ( + {currency.name?.en + )} + + {code} + +
    + + + {priceResult.label} + +
    + +
    + + {currency.name?.fa || currency.name?.en} + + {change !== undefined && ( + + {isPositive ? '+' : ''} + {change}% + + )} +
    +
    + ) +} + +interface CurrencyCompactRowProps { + currencies: string[] +} + +export function CurrencyCompactRow({ currencies }: CurrencyCompactRowProps) { + const displayCurrencies = currencies.slice(0, 2) + + return ( +
    + {displayCurrencies.map((code) => ( + + ))} +
    + ) +} diff --git a/src/layouts/widgets/wigi-arz/variants/currency-compact-square.tsx b/src/layouts/widgets/wigi-arz/variants/currency-compact-square.tsx new file mode 100644 index 000000000..f00f85330 --- /dev/null +++ b/src/layouts/widgets/wigi-arz/variants/currency-compact-square.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react' +import { getFromStorage, setToStorage } from '@/common/storage' +import { + type FetchedCurrency, + useGetCurrencyByCode, +} from '@/services/hooks/currency/get-currency-by-code.hook' +import { GetPrice } from '../utils/get-price' + +interface CurrencyCompactSquareProps { + code: string +} + +export function CurrencyCompactSquare({ code }: CurrencyCompactSquareProps) { + const { data, dataUpdatedAt } = useGetCurrencyByCode(code, { + refetchInterval: null, + }) + + const [currency, setCurrency] = useState(null) + + useEffect(() => { + async function load() { + const cached = await getFromStorage(`currency:${code}`) + if (cached) { + setCurrency(cached) + } + } + load() + }, [code]) + + useEffect(() => { + if (data) { + setCurrency(data) + setToStorage(`currency:${code}`, data) + } + }, [dataUpdatedAt]) + + if (!currency) { + return ( +
    +
    +
    +
    +
    + ) + } + + const priceResult = GetPrice(code, currency) + const change = currency.changePercentage + const isPositive = (change || 0) >= 0 + + return ( +
    +
    + {currency.icon && ( + {currency.name?.en + )} + + {currency.name?.fa || code} + +
    + +
    + + {priceResult.label} + +
    + +
    + {change !== undefined && ( + + {isPositive ? '+' : ''} + {change}% + + )} +
    +
    + ) +} diff --git a/src/layouts/widgets/wigi-arz/variants/currency-wide-banner.tsx b/src/layouts/widgets/wigi-arz/variants/currency-wide-banner.tsx new file mode 100644 index 000000000..fc7702ef6 --- /dev/null +++ b/src/layouts/widgets/wigi-arz/variants/currency-wide-banner.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react' +import { getFromStorage, setToStorage } from '@/common/storage' +import { + type FetchedCurrency, + useGetCurrencyByCode, +} from '@/services/hooks/currency/get-currency-by-code.hook' +import { GetPrice } from '../utils/get-price' + +interface CurrencyItemBannerProps { + code: string +} + +function CurrencyItemBanner({ code }: CurrencyItemBannerProps) { + const { data, dataUpdatedAt } = useGetCurrencyByCode(code, { + refetchInterval: null, + }) + + const [currency, setCurrency] = useState(null) + + useEffect(() => { + async function load() { + const cached = await getFromStorage(`currency:${code}`) + if (cached) { + setCurrency(cached) + } + } + load() + }, [code]) + + useEffect(() => { + if (data) { + setCurrency(data) + setToStorage(`currency:${code}`, data) + } + }, [dataUpdatedAt]) + + if (!currency) { + return ( +
    +
    +
    +
    +
    +
    +
    + ) + } + + const priceResult = GetPrice(code, currency) + const change = currency.changePercentage + const isPositive = (change || 0) >= 0 + + return ( +
    +
    +
    + {currency.icon && ( + {currency.name?.en + )} + + {code} + +
    + + + {priceResult.label} + +
    + +
    + + {currency.name?.fa || currency.name?.en} + + {change !== undefined && ( + + {isPositive ? '+' : ''} + {change}% + + )} +
    +
    + ) +} + +interface CurrencyWideBannerProps { + currencies: string[] +} + +export function CurrencyWideBanner({ currencies }: CurrencyWideBannerProps) { + const displayCurrencies = currencies.slice(0, 4) + + return ( +
    + {displayCurrencies.map((code) => ( + + ))} +
    + ) +} diff --git a/src/layouts/widgets/wigi-arz/variants/currency-wide-grid.tsx b/src/layouts/widgets/wigi-arz/variants/currency-wide-grid.tsx new file mode 100644 index 000000000..1db119e05 --- /dev/null +++ b/src/layouts/widgets/wigi-arz/variants/currency-wide-grid.tsx @@ -0,0 +1,75 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import Analytics from '@/analytics' +import { useCurrencyStore } from '@/context/currency.context' +import { SortableCurrencyBox } from '../components/sortable-currency-box' + +interface CurrencyWideGridProps { + currencies: string[] +} + +export function CurrencyWideGrid({ currencies }: CurrencyWideGridProps) { + const { currencyColorMode, reorderCurrencies } = useCurrencyStore() + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, + }, + }) + ) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + + if (!over || active.id === over.id) { + return + } + + const activeIndex = currencies.indexOf(String(active.id)) + const overIndex = currencies.indexOf(String(over.id)) + + if (activeIndex !== -1 && overIndex !== -1) { + const reorderedCurrencies = arrayMove(currencies, activeIndex, overIndex) + reorderCurrencies(reorderedCurrencies) + Analytics.event('currency_reorder') + } + } + + return ( +
    + +
    + + {currencies.map((currency) => ( + + ))} + +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/wigi-arz/wigi_arz.layout.tsx b/src/layouts/widgets/wigi-arz/wigi_arz.layout.tsx index 29a7f7a7c..c4f33a0f0 100644 --- a/src/layouts/widgets/wigi-arz/wigi_arz.layout.tsx +++ b/src/layouts/widgets/wigi-arz/wigi_arz.layout.tsx @@ -16,24 +16,27 @@ import { callEvent } from '@/common/utils/call-event' import { useCurrencyStore } from '@/context/currency.context' import { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' import { WidgetContainer } from '../widget-container' -import { ArzHeader } from './components/arz-header' import { SortableCurrencyBox } from './components/sortable-currency-box' +import { CurrencyCompactSquare } from './variants/currency-compact-square' +import { CurrencyCompactRow } from './variants/currency-compact-row' +import { CurrencyWideBanner } from './variants/currency-wide-banner' +import { CurrencyWideGrid } from './variants/currency-wide-grid' import { Button } from '@/components/ui' import type { WidgetSize } from '../layout-engine/types' interface WigiArzLayoutProps { enableBackground?: boolean - inComboWidget: boolean + inComboWidget?: boolean comboClassName?: string size?: WidgetSize } export function WigiArzLayout({ enableBackground = true, - inComboWidget, + inComboWidget = false, comboClassName, - size, + size = { w: 2, h: 2 }, }: WigiArzLayoutProps) { const { selectedCurrencies, currencyColorMode, reorderCurrencies } = useCurrencyStore() @@ -71,129 +74,137 @@ export function WigiArzLayout({ callEvent('openWidgetsSettings', { tab: WidgetTabKeys.wigiArz }) } - return ( - <> - {inComboWidget ? ( -
    - {selectedCurrencies.length === 0 ? ( + if (!inComboWidget) { + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h >= 2) { + return ( + + + + ) + } + } + + if (inComboWidget) { + return ( +
    + {selectedCurrencies.length === 0 ? ( +
    +
    + 💲 +
    +

    + ارزهای مورد نظر خود را اضافه کنید +

    + +
    + ) : ( +
    -
    - 💲 -
    -

    - ارزهای مورد نظر خود را اضافه کنید -

    - + {selectedCurrencies.map((currency) => ( + + ))} +
    - ) : ( - -
    - - {selectedCurrencies.map((currency) => ( - - ))} - -
    -
    - )} +
    + )} +
    + ) + } + + return ( + + {selectedCurrencies.length === 0 ? ( +
    +
    + 💲 +
    +

    + ارزهای مورد نظر خود را اضافه کنید +

    +
    ) : ( - - onSettingClick()} - /> - - {selectedCurrencies.length === 0 ? ( -
    -
    - 💲 -
    -

    - ارزهای مورد نظر خود را اضافه کنید -

    - -
    - ) : ( - + -
    - - {selectedCurrencies.map((currency) => ( - - ))} - -
    -
    - )} -
    + {selectedCurrencies.map((currency) => ( + + ))} + +
    + )} - + ) } diff --git a/src/layouts/widgets/yadkar/yadkar.tsx b/src/layouts/widgets/yadkar/yadkar.tsx index 47efa4aca..0fb4274e6 100644 --- a/src/layouts/widgets/yadkar/yadkar.tsx +++ b/src/layouts/widgets/yadkar/yadkar.tsx @@ -70,7 +70,7 @@ export function YadkarWidget({ size }: YadkarWidgetProps = {}) {
    {tab === 'todos' ? ( - + ) : tab === 'notes' ? ( ) : ( From f20a4376f192083aa2087f743be07d550ccdcbf7 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Wed, 19 Aug 2026 21:24:14 +0330 Subject: [PATCH 005/260] change variant names --- .../components/referral-code-section.tsx | 2 +- .../setting/tabs/shortcuts/shortcuts.tsx | 5 +- .../components/wallpaper-sidebar.tsx | 2 +- src/layouts/widgets/calendar/calendar.tsx | 34 ++- .../calendar/variants/calendar-2x1.tsx | 55 ++++ .../calendar/variants/calendar-4x2.tsx | 74 ++++++ .../widgets/canvas/add-widget-modal.tsx | 2 +- .../widgets/canvas/widget-context-menu.tsx | 2 +- .../widgets/canvas/widget-size-toolbar.tsx | 2 +- src/layouts/widgets/clock/clock.widget.tsx | 240 +++--------------- .../clock/components/mini-analog-clock.tsx | 97 +++++++ .../widgets/clock/variants/clock-1x1.tsx | 51 ++++ .../widgets/clock/variants/clock-2x1.tsx | 78 ++++++ .../widgets/clock/variants/clock-4x1.tsx | 78 ++++++ .../combo-widget/combo-widget.layout.tsx | 35 ++- src/layouts/widgets/date/date.widget.tsx | 146 +---------- .../widgets/date/variants/date-1x1.tsx | 28 ++ .../widgets/date/variants/date-2x1.tsx | 52 ++++ .../widgets/date/variants/date-4x1.tsx | 82 ++++++ src/layouts/widgets/habit/habits.layout.tsx | 111 +++++--- ...habit-compact-square.tsx => habit-1x1.tsx} | 17 +- .../{habit-compact-row.tsx => habit-2x1.tsx} | 22 +- .../widgets/habit/variants/habit-4x1.tsx | 98 +++++++ .../widgets/habit/variants/habit-4x2.tsx | 116 +++++++++ .../network/components/network-ip-card.tsx | 2 +- .../widgets/network/network.layout.tsx | 55 +++- .../widgets/network/variants/network-1x1.tsx | 57 +++++ .../widgets/network/variants/network-2x1.tsx | 84 ++++++ .../widgets/network/variants/network-4x1.tsx | 90 +++++++ src/layouts/widgets/news/news.layout.tsx | 83 +++--- .../widgets/news/variants/news-2x1.tsx | 63 +++++ .../widgets/news/variants/news-4x1.tsx | 76 ++++++ src/layouts/widgets/notes/notes.layout.tsx | 37 ++- .../widgets/notes/variants/note-2x1.tsx | 41 +++ .../widgets/notes/variants/note-4x2.tsx | 62 +++++ src/layouts/widgets/pet/pet.widget.tsx | 2 +- .../{pet-compact-square.tsx => pet-1x1.tsx} | 0 src/layouts/widgets/todos/todos.tsx | 2 +- .../{todo-compact-row.tsx => todo-2x1.tsx} | 2 +- src/layouts/widgets/tools/tools.layout.tsx | 26 +- .../widgets/tools/variants/tools-2x1.tsx | 42 +++ .../widgets/tools/variants/tools-4x1.tsx | 50 ++++ .../widgets/types/widget-base.types.ts | 19 ++ ...her-compact-square.tsx => weather-1x1.tsx} | 0 ...eather-compact-row.tsx => weather-2x1.tsx} | 0 ...eather-wide-banner.tsx => weather-4x1.tsx} | 13 +- ...{weather-wide-full.tsx => weather-4x2.tsx} | 0 .../widgets/weather/weather.layout.tsx | 8 +- src/layouts/widgets/widget-registry.tsx | 29 ++- ...cy-compact-square.tsx => currency-1x1.tsx} | 4 +- ...rency-compact-row.tsx => currency-2x1.tsx} | 7 +- ...rency-wide-banner.tsx => currency-4x1.tsx} | 7 +- ...urrency-wide-grid.tsx => currency-4x2.tsx} | 0 .../widgets/wigi-arz/wigi_arz.layout.tsx | 8 +- src/layouts/widgets/yadkar/yadkar.tsx | 2 +- src/services/hooks/news/get-news.hook.ts | 6 +- 56 files changed, 1833 insertions(+), 473 deletions(-) create mode 100644 src/layouts/widgets/calendar/variants/calendar-2x1.tsx create mode 100644 src/layouts/widgets/calendar/variants/calendar-4x2.tsx create mode 100644 src/layouts/widgets/clock/components/mini-analog-clock.tsx create mode 100644 src/layouts/widgets/clock/variants/clock-1x1.tsx create mode 100644 src/layouts/widgets/clock/variants/clock-2x1.tsx create mode 100644 src/layouts/widgets/clock/variants/clock-4x1.tsx create mode 100644 src/layouts/widgets/date/variants/date-1x1.tsx create mode 100644 src/layouts/widgets/date/variants/date-2x1.tsx create mode 100644 src/layouts/widgets/date/variants/date-4x1.tsx rename src/layouts/widgets/habit/variants/{habit-compact-square.tsx => habit-1x1.tsx} (82%) rename src/layouts/widgets/habit/variants/{habit-compact-row.tsx => habit-2x1.tsx} (82%) create mode 100644 src/layouts/widgets/habit/variants/habit-4x1.tsx create mode 100644 src/layouts/widgets/habit/variants/habit-4x2.tsx create mode 100644 src/layouts/widgets/network/variants/network-1x1.tsx create mode 100644 src/layouts/widgets/network/variants/network-2x1.tsx create mode 100644 src/layouts/widgets/network/variants/network-4x1.tsx create mode 100644 src/layouts/widgets/news/variants/news-2x1.tsx create mode 100644 src/layouts/widgets/news/variants/news-4x1.tsx create mode 100644 src/layouts/widgets/notes/variants/note-2x1.tsx create mode 100644 src/layouts/widgets/notes/variants/note-4x2.tsx rename src/layouts/widgets/pet/variants/{pet-compact-square.tsx => pet-1x1.tsx} (100%) rename src/layouts/widgets/todos/variants/{todo-compact-row.tsx => todo-2x1.tsx} (98%) create mode 100644 src/layouts/widgets/tools/variants/tools-2x1.tsx create mode 100644 src/layouts/widgets/tools/variants/tools-4x1.tsx create mode 100644 src/layouts/widgets/types/widget-base.types.ts rename src/layouts/widgets/weather/variants/{weather-compact-square.tsx => weather-1x1.tsx} (100%) rename src/layouts/widgets/weather/variants/{weather-compact-row.tsx => weather-2x1.tsx} (100%) rename src/layouts/widgets/weather/variants/{weather-wide-banner.tsx => weather-4x1.tsx} (87%) rename src/layouts/widgets/weather/variants/{weather-wide-full.tsx => weather-4x2.tsx} (100%) rename src/layouts/widgets/wigi-arz/variants/{currency-compact-square.tsx => currency-1x1.tsx} (92%) rename src/layouts/widgets/wigi-arz/variants/{currency-compact-row.tsx => currency-2x1.tsx} (94%) rename src/layouts/widgets/wigi-arz/variants/{currency-wide-banner.tsx => currency-4x1.tsx} (94%) rename src/layouts/widgets/wigi-arz/variants/{currency-wide-grid.tsx => currency-4x2.tsx} (100%) diff --git a/src/layouts/setting/tabs/account/tabs/rewards/components/referral-code-section.tsx b/src/layouts/setting/tabs/account/tabs/rewards/components/referral-code-section.tsx index 2fbdd0821..e5e3a7e9c 100644 --- a/src/layouts/setting/tabs/account/tabs/rewards/components/referral-code-section.tsx +++ b/src/layouts/setting/tabs/account/tabs/rewards/components/referral-code-section.tsx @@ -33,7 +33,7 @@ export const ReferralCodeSection = ({ code, className }: ReferralCodeSectionProp

    کد دعوت

    {code} diff --git a/src/layouts/setting/tabs/shortcuts/shortcuts.tsx b/src/layouts/setting/tabs/shortcuts/shortcuts.tsx index 2a311d45a..b62a87856 100644 --- a/src/layouts/setting/tabs/shortcuts/shortcuts.tsx +++ b/src/layouts/setting/tabs/shortcuts/shortcuts.tsx @@ -99,10 +99,7 @@ export function ShortcutsTab() { {shortcut.description} -

    +
    {formatShortcut( isMac ? shortcut.macKey diff --git a/src/layouts/setting/tabs/wallpapers/components/wallpaper-sidebar.tsx b/src/layouts/setting/tabs/wallpapers/components/wallpaper-sidebar.tsx index d2858f722..344fc95f1 100644 --- a/src/layouts/setting/tabs/wallpapers/components/wallpaper-sidebar.tsx +++ b/src/layouts/setting/tabs/wallpapers/components/wallpaper-sidebar.tsx @@ -52,7 +52,7 @@ export function WallpaperSidebar({
    {totalCount !== undefined && totalCount > 0 && ( = ({ ) } -import type { WidgetSize } from '../layout-engine/types' - interface CalendarLayoutProps { size?: WidgetSize } -const CalendarLayout: React.FC = ({ size }) => { - const { currentDate, selectedDate, setCurrentDate, setSelectedDate, goToToday } = - useDate() +export const CalendarLayout: React.FC = ({ + size = { w: 2, h: 2 }, +}) => { + const { + currentDate, + selectedDate, + setCurrentDate, + setSelectedDate, + goToToday, + } = useDate() const [activeTab, setActiveTab] = useState('calendar') const onSetActiveTab = (tab: string) => { @@ -63,6 +71,22 @@ const CalendarLayout: React.FC = ({ size }) => { Analytics.event(`calendar_tab_switch_to_${tab}`) } + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h >= 2) { + return ( + + + + ) + } + return (
    diff --git a/src/layouts/widgets/calendar/variants/calendar-2x1.tsx b/src/layouts/widgets/calendar/variants/calendar-2x1.tsx new file mode 100644 index 000000000..3fa905263 --- /dev/null +++ b/src/layouts/widgets/calendar/variants/calendar-2x1.tsx @@ -0,0 +1,55 @@ +import { useDate } from '@/context/date.context' +import { toPersianDigits } from '@/common/utils/persian-digits' + +const dayNames = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] + +export function CalendarCompactRow() { + const { today, selectedDate, setSelectedDate } = useDate() + + const startOfWeek = today.clone().startOf('week') + const weekDays = Array.from({ length: 7 }, (_, i) => + startOfWeek.clone().add(i, 'days') + ) + + return ( +
    +
    + {weekDays.map((day, idx) => { + const isToday = day.isSame(today, 'day') + const isSelected = selectedDate && day.isSame(selectedDate, 'day') + const isFriday = idx === 6 + + return ( + + ) + })} +
    +
    + ) +} diff --git a/src/layouts/widgets/calendar/variants/calendar-4x2.tsx b/src/layouts/widgets/calendar/variants/calendar-4x2.tsx new file mode 100644 index 000000000..30d059557 --- /dev/null +++ b/src/layouts/widgets/calendar/variants/calendar-4x2.tsx @@ -0,0 +1,74 @@ +import { useDate } from '@/context/date.context' +import { CalendarGrid } from '../components/calendar-grid' +import { CalendarHeader } from '../components/calendar-header' +import { useGetEvents } from '@/services/hooks/date/get-events.hook' +import { combineAndSortEvents } from '@/layouts/widgets/tools/events/utils' +import { toPersianDigits } from '@/common/utils/persian-digits' + +export function CalendarWideFull() { + const { currentDate, selectedDate, setCurrentDate, setSelectedDate, goToToday } = + useDate() + + const { data: events } = useGetEvents() + const targetDate = selectedDate || currentDate + const eventsForCalendar = events || { + gregorianEvents: [], + hijriEvents: [], + shamsiEvents: [], + } + const sortedEvents = combineAndSortEvents(eventsForCalendar, targetDate.clone(), []) + + return ( +
    +
    + +
    + +
    +
    + +
    +
    + رویدادهای {targetDate.locale('fa').format('dddd D MMMM')} + + {toPersianDigits(sortedEvents.length)} مناسبت + +
    + +
    + {sortedEvents.length === 0 ? ( +
    + مناسبتی برای این روز ثبت نشده است +
    + ) : ( + sortedEvents.map((evt, idx) => ( +
    + {evt.isHoliday && ( + + )} + {evt.title} + {evt.isHoliday && ( + + تعطیل + + )} +
    + )) + )} +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/canvas/add-widget-modal.tsx b/src/layouts/widgets/canvas/add-widget-modal.tsx index 9be853c52..2756e4b9d 100644 --- a/src/layouts/widgets/canvas/add-widget-modal.tsx +++ b/src/layouts/widgets/canvas/add-widget-modal.tsx @@ -51,7 +51,7 @@ export function AddWidgetModal({ isOpen, onClose }: AddWidgetModalProps) { {def.emoji} {def.label}
    - + {def.defaultSize.w}×{def.defaultSize.h}
    diff --git a/src/layouts/widgets/canvas/widget-context-menu.tsx b/src/layouts/widgets/canvas/widget-context-menu.tsx index 90c13e4b0..86897b4e7 100644 --- a/src/layouts/widgets/canvas/widget-context-menu.tsx +++ b/src/layouts/widgets/canvas/widget-context-menu.tsx @@ -92,7 +92,7 @@ export function WidgetContextMenu({ onResize(size) onClose() }} - className={`py-1 px-1.5 rounded-lg text-center font-mono text-[11px] cursor-pointer transition-all duration-150 ${ + className={`py-1 px-1.5 rounded-lg text-center text-[11px] cursor-pointer transition-all duration-150 ${ isCurrent ? 'bg-primary text-white font-bold shadow-xs' : 'bg-base-100 hover:bg-base-300 text-content' diff --git a/src/layouts/widgets/canvas/widget-size-toolbar.tsx b/src/layouts/widgets/canvas/widget-size-toolbar.tsx index 7492d161b..a83abe951 100644 --- a/src/layouts/widgets/canvas/widget-size-toolbar.tsx +++ b/src/layouts/widgets/canvas/widget-size-toolbar.tsx @@ -36,7 +36,7 @@ export function WidgetSizeToolbar({ e.stopPropagation() onSelectSize(size) }} - className={`px-2 py-1 text-xs font-mono font-medium rounded-lg cursor-pointer transition-all duration-150 ${ + className={`px-2 py-1 text-xs font-medium rounded-lg cursor-pointer transition-all duration-150 ${ isCurrent ? 'bg-primary text-white shadow-sm scale-105' : 'bg-base-100/60 text-content hover:bg-base-100 hover:text-content' diff --git a/src/layouts/widgets/clock/clock.widget.tsx b/src/layouts/widgets/clock/clock.widget.tsx index eeddf3ede..11b57cef8 100644 --- a/src/layouts/widgets/clock/clock.widget.tsx +++ b/src/layouts/widgets/clock/clock.widget.tsx @@ -3,18 +3,21 @@ import { getFromStorage } from '@/common/storage' import { listenEvent } from '@/common/utils/call-event' import { useGeneralSetting } from '@/context/general-setting.context' import type { FetchedTimezone } from '@/services/hooks/timezone/get-timezones.hook' -import { type ClockSettings, ClockType } from '../wigi-pad/clock-display/clock-setting.interface' +import { + type ClockSettings, + ClockType, +} from '../wigi-pad/clock-display/clock-setting.interface' import { WidgetContainer } from '../widget-container' import type { WidgetSize } from '../layout-engine/types' +import { toPersianDigits } from '@/common/utils/persian-digits' +import { Clock1x1 } from './variants/clock-1x1' +import { Clock2x1 } from './variants/clock-2x1' +import { Clock4x1 } from './variants/clock-4x1' interface ClockWidgetProps { size?: WidgetSize } -function toPersianDigits(val: string | number): string { - return String(val).replace(/[0-9]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]) -} - export function ClockWidget({ size = { w: 2, h: 1 } }: ClockWidgetProps) { const [clockSettings, setClockSettings] = useState(null) const { selected_timezone: timezone, isOptimalMode } = useGeneralSetting() @@ -113,8 +116,12 @@ function ClockContent({ const rawSeconds = time.getSeconds().toString().padStart(2, '0') const hours = setting.useSelectedFont ? toPersianDigits(rawHours) : rawHours - const minutes = setting.useSelectedFont ? toPersianDigits(rawMinutes) : rawMinutes - const seconds = setting.useSelectedFont ? toPersianDigits(rawSeconds) : rawSeconds + const minutes = setting.useSelectedFont + ? toPersianDigits(rawMinutes) + : rawMinutes + const seconds = setting.useSelectedFont + ? toPersianDigits(rawSeconds) + : rawSeconds const timezoneLabel = useMemo(() => { if (!timezone?.value) return 'UTC' @@ -125,207 +132,44 @@ function ClockContent({ return timezone.value }, [timezone]) - const isAnalog = setting.clockType === ClockType.Analog - if (size.w === 1) { return ( -
    - {isAnalog ? ( - - ) : ( -
    - - {hours} - -
    - - {minutes} - - {showSeconds && ( - - :{seconds} - - )} -
    - )} -
    + ) } if (size.w >= 4) { return ( -
    -
    - {isAnalog && ( -
    - -
    - )} -
    -
    - - {hours} : {minutes} - - {showSeconds && ( - - :{seconds} - - )} -
    - {setting.showTimeZone && ( - - {timezone?.label || timezoneLabel} - - )} -
    -
    - -
    -
    - {timezoneLabel} -
    - - {time.toLocaleDateString('fa-IR', { - weekday: 'long', - month: 'short', - day: 'numeric', - })} - -
    -
    + ) } return ( -
    - {isAnalog ? ( -
    - -
    - - {hours} : {minutes} - - {setting.showTimeZone && ( - - {timezoneLabel} - - )} -
    -
    - ) : ( -
    -
    - - {hours} : {minutes} - - {showSeconds && ( - - :{seconds} - - )} -
    - {setting.showTimeZone && ( -
    - {timezoneLabel} -
    - )} -
    - )} -
    - ) -} - -function MiniAnalogClock({ - time, - showSeconds, - size = 56, -}: { - time: Date - showSeconds: boolean - size?: number -}) { - const hours = time.getHours() % 12 - const minutes = time.getMinutes() - const seconds = time.getSeconds() - - const hourAngle = hours * 30 + minutes * 0.5 - const minuteAngle = minutes * 6 - const secondAngle = seconds * 6 - - return ( -
    - - {[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map( - (angle, i) => { - const isMain = angle % 90 === 0 - const r1 = isMain ? 38 : 42 - const r2 = 46 - const rad = ((angle - 90) * Math.PI) / 180 - return ( - - ) - } - )} - - - - - - {showSeconds && ( - - )} - - - -
    + ) } diff --git a/src/layouts/widgets/clock/components/mini-analog-clock.tsx b/src/layouts/widgets/clock/components/mini-analog-clock.tsx new file mode 100644 index 000000000..4a25e610b --- /dev/null +++ b/src/layouts/widgets/clock/components/mini-analog-clock.tsx @@ -0,0 +1,97 @@ +interface MiniAnalogClockProps { + time: Date + showSeconds: boolean + size?: number +} + +export function MiniAnalogClock({ + time, + showSeconds, + size = 56, +}: MiniAnalogClockProps) { + const hours = time.getHours() % 12 + const minutes = time.getMinutes() + const seconds = time.getSeconds() + + const hourAngle = hours * 30 + minutes * 0.5 + const minuteAngle = minutes * 6 + const secondAngle = seconds * 6 + + return ( +
    + + {[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map( + (angle, i) => { + const isMain = angle % 90 === 0 + const r1 = isMain ? 38 : 42 + const r2 = 46 + const rad = ((angle - 90) * Math.PI) / 180 + return ( + + ) + } + )} + + + + + + {showSeconds && ( + + )} + + + +
    + ) +} diff --git a/src/layouts/widgets/clock/variants/clock-1x1.tsx b/src/layouts/widgets/clock/variants/clock-1x1.tsx new file mode 100644 index 000000000..75f06b45a --- /dev/null +++ b/src/layouts/widgets/clock/variants/clock-1x1.tsx @@ -0,0 +1,51 @@ +import { toPersianDigits } from '@/common/utils/persian-digits' +import { MiniAnalogClock } from '../components/mini-analog-clock' +import { + type ClockSettings, + ClockType, +} from '@/layouts/widgets/wigi-pad/clock-display/clock-setting.interface' + +interface Clock1x1Props { + time: Date + setting: ClockSettings + showSeconds: boolean + hours: string + minutes: string + seconds: string +} + +export function Clock1x1({ + time, + setting, + showSeconds, + hours, + minutes, + seconds, +}: Clock1x1Props) { + const isAnalog = setting.clockType === ClockType.Analog + + return ( +
    + {isAnalog ? ( + + ) : ( +
    + + {hours} + +
    + + {minutes} + + {showSeconds && ( + :{seconds} + )} +
    + )} +
    + ) +} diff --git a/src/layouts/widgets/clock/variants/clock-2x1.tsx b/src/layouts/widgets/clock/variants/clock-2x1.tsx new file mode 100644 index 000000000..9efcde61b --- /dev/null +++ b/src/layouts/widgets/clock/variants/clock-2x1.tsx @@ -0,0 +1,78 @@ +import { MiniAnalogClock } from '../components/mini-analog-clock' +import { + type ClockSettings, + ClockType, +} from '@/layouts/widgets/wigi-pad/clock-display/clock-setting.interface' +import type { FetchedTimezone } from '@/services/hooks/timezone/get-timezones.hook' + +interface Clock2x1Props { + time: Date + setting: ClockSettings + timezone: FetchedTimezone + timezoneLabel: string + showSeconds: boolean + hours: string + minutes: string + seconds: string +} + +export function Clock2x1({ + time, + setting, + timezone, + timezoneLabel, + showSeconds, + hours, + minutes, + seconds, +}: Clock2x1Props) { + const isAnalog = setting.clockType === ClockType.Analog + + return ( +
    +
    + {isAnalog && ( +
    + +
    + )} +
    +
    + + {hours} : {minutes} + + {showSeconds && ( + + :{seconds} + + )} +
    + {setting.showTimeZone && ( + + {timezone?.label || timezoneLabel} + + )} +
    +
    + +
    +
    + {timezoneLabel} +
    + + {time.toLocaleDateString('fa-IR', { + weekday: 'short', + month: 'short', + day: 'numeric', + })} + +
    +
    + ) +} diff --git a/src/layouts/widgets/clock/variants/clock-4x1.tsx b/src/layouts/widgets/clock/variants/clock-4x1.tsx new file mode 100644 index 000000000..3594c06e6 --- /dev/null +++ b/src/layouts/widgets/clock/variants/clock-4x1.tsx @@ -0,0 +1,78 @@ +import { MiniAnalogClock } from '../components/mini-analog-clock' +import { + type ClockSettings, + ClockType, +} from '@/layouts/widgets/wigi-pad/clock-display/clock-setting.interface' +import type { FetchedTimezone } from '@/services/hooks/timezone/get-timezones.hook' + +interface Clock4x1Props { + time: Date + setting: ClockSettings + timezone: FetchedTimezone + timezoneLabel: string + showSeconds: boolean + hours: string + minutes: string + seconds: string +} + +export function Clock4x1({ + time, + setting, + timezone, + timezoneLabel, + showSeconds, + hours, + minutes, + seconds, +}: Clock4x1Props) { + const isAnalog = setting.clockType === ClockType.Analog + + return ( +
    +
    + {isAnalog && ( +
    + +
    + )} +
    +
    + + {hours} : {minutes} + + {showSeconds && ( + + :{seconds} + + )} +
    + {setting.showTimeZone && ( + + {timezone?.label || timezoneLabel} + + )} +
    +
    + +
    +
    + {timezoneLabel} +
    + + {time.toLocaleDateString('fa-IR', { + weekday: 'long', + month: 'short', + day: 'numeric', + })} + +
    +
    + ) +} diff --git a/src/layouts/widgets/combo-widget/combo-widget.layout.tsx b/src/layouts/widgets/combo-widget/combo-widget.layout.tsx index fefcf43c4..251d5efc1 100644 --- a/src/layouts/widgets/combo-widget/combo-widget.layout.tsx +++ b/src/layouts/widgets/combo-widget/combo-widget.layout.tsx @@ -8,16 +8,15 @@ import { WidgetContainer } from '../widget-container' import { WigiArzLayout } from '../wigi-arz/wigi_arz.layout' import { Button, TabNavigation } from '@/components/ui' import { Icon } from '@/src/icons' +import type { WidgetSize } from '../layout-engine/types' export type ComboTabType = 'news' | 'currency' -import type { WidgetSize } from '../layout-engine/types' - interface ComboWidgetProps { size?: WidgetSize } -export function ComboWidget({ size }: ComboWidgetProps = {}) { +export function ComboWidget({ size = { w: 2, h: 2 } }: ComboWidgetProps = {}) { const [activeTab, setActiveTab] = useState(null) const handleSettingsClick = () => { if (activeTab === 'currency') { @@ -51,6 +50,36 @@ export function ComboWidget({ size }: ComboWidgetProps = {}) { if (!activeTab) return null + if (size.w >= 4 && size.h >= 2) { + return ( + +
    +
    + + + قیمت لحظه‌ای ارز + +
    +
    + +
    +
    + +
    +
    + + + ویجی نیوز + +
    +
    + +
    +
    +
    + ) + } + return (
    diff --git a/src/layouts/widgets/date/date.widget.tsx b/src/layouts/widgets/date/date.widget.tsx index c5546a228..710680a7f 100644 --- a/src/layouts/widgets/date/date.widget.tsx +++ b/src/layouts/widgets/date/date.widget.tsx @@ -1,154 +1,14 @@ -import { useDate } from '@/context/date.context' -import { combineAndSortEvents } from '@/layouts/widgets/tools/events/utils' -import { useGetEvents } from '@/services/hooks/date/get-events.hook' -import { hijriMonthNames } from '@/layouts/widgets/calendar/utils' import { DateDisplay } from '../wigi-pad/date-display/date.display' import { WidgetContainer } from '../widget-container' import type { WidgetSize } from '../layout-engine/types' - -function toPersianDigits(val: string | number): string { - const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] - return String(val).replace(/\d/g, (x) => farsiDigits[Number.parseInt(x, 10)]) -} +import { DateCompactSquare } from './variants/date-1x1' +import { DateCompactRow } from './variants/date-2x1' +import { DateWideBanner } from './variants/date-4x1' interface DateWidgetProps { size?: WidgetSize } -function DateCompactSquare() { - const { today, todayIsHoliday } = useDate() - const dayOfWeek = today.locale('fa').format('dddd') - const dayNumber = toPersianDigits(today.jDate()) - const monthName = today.locale('fa').format('MMMM') - - return ( -
    -
    - - {dayOfWeek} - - {todayIsHoliday && ( - - )} -
    -
    - {dayNumber} -
    -
    - {monthName} -
    -
    - ) -} - -function DateCompactRow() { - const { today, todayIsHoliday, getHijriDate } = useDate() - const dayOfWeek = today.locale('fa').format('dddd') - const dayNumber = toPersianDigits(today.jDate()) - const monthName = today.locale('fa').format('MMMM') - const yearNumber = toPersianDigits(today.jYear()) - - const gregorianShort = today.doAsGregorian().format('D MMM') - - const hijriRaw = getHijriDate(today) - const [_, hijriMonth, hijriDate] = hijriRaw.split('/') - const hijriMonthName = hijriMonthNames[Number(hijriMonth) - 1] || hijriMonth - const hijriFormatted = `${toPersianDigits(hijriDate)} ${hijriMonthName}` - - return ( -
    -
    -
    - {dayNumber} -
    -
    -
    - - {dayOfWeek} - - - {monthName} - - {todayIsHoliday && ( - - تعطیل - - )} -
    - - {yearNumber} - -
    -
    - -
    - {gregorianShort} - {hijriFormatted} -
    -
    - ) -} - -function DateWideBanner() { - const { today, todayIsHoliday, getHijriDate } = useDate() - const { data: events } = useGetEvents() - - const eventsForCalendar = events || { - gregorianEvents: [], - hijriEvents: [], - shamsiEvents: [], - } - const sortedEvents = combineAndSortEvents(eventsForCalendar, today.clone(), []) - const isHoliday = sortedEvents.some((event) => event.isHoliday) || todayIsHoliday - const mainEvent = sortedEvents.find((e) => e.title) - - const dayOfWeek = today.locale('fa').format('dddd') - const dayNumber = toPersianDigits(today.jDate()) - const monthName = today.locale('fa').format('MMMM') - const yearNumber = toPersianDigits(today.jYear()) - - const gregorianFull = today.doAsGregorian().format('D MMMM YYYY') - - const hijriRaw = getHijriDate(today) - const [_, hijriMonth, hijriDate] = hijriRaw.split('/') - const hijriMonthName = hijriMonthNames[Number(hijriMonth) - 1] || hijriMonth - const hijriFormatted = `${toPersianDigits(hijriDate)} ${hijriMonthName}` - - return ( -
    -
    -
    - {dayNumber} -
    -
    - - {dayOfWeek} - - - {monthName} {yearNumber} - - {isHoliday && ( - - تعطیل رسمی - - )} -
    -
    - -
    - {mainEvent && ( - - {mainEvent.title} - - )} - - {gregorianFull} · {hijriFormatted} - -
    -
    - ) -} - export function DateWidget({ size = { w: 2, h: 2 } }: DateWidgetProps) { if (size.w === 1 && size.h === 1) { return ( diff --git a/src/layouts/widgets/date/variants/date-1x1.tsx b/src/layouts/widgets/date/variants/date-1x1.tsx new file mode 100644 index 000000000..6026fa2cb --- /dev/null +++ b/src/layouts/widgets/date/variants/date-1x1.tsx @@ -0,0 +1,28 @@ +import { useDate } from '@/context/date.context' +import { toPersianDigits } from '@/common/utils/persian-digits' + +export function DateCompactSquare() { + const { today, todayIsHoliday } = useDate() + const dayOfWeek = today.locale('fa').format('dddd') + const dayNumber = toPersianDigits(today.jDate()) + const monthName = today.locale('fa').format('MMMM') + + return ( +
    +
    + + {dayOfWeek} + + {todayIsHoliday && ( + + )} +
    +
    + {dayNumber} +
    +
    + {monthName} +
    +
    + ) +} diff --git a/src/layouts/widgets/date/variants/date-2x1.tsx b/src/layouts/widgets/date/variants/date-2x1.tsx new file mode 100644 index 000000000..5f8e6c4f8 --- /dev/null +++ b/src/layouts/widgets/date/variants/date-2x1.tsx @@ -0,0 +1,52 @@ +import { useDate } from '@/context/date.context' +import { toPersianDigits } from '@/common/utils/persian-digits' +import { hijriMonthNames } from '@/layouts/widgets/calendar/utils' + +export function DateCompactRow() { + const { today, todayIsHoliday, getHijriDate } = useDate() + const dayOfWeek = today.locale('fa').format('dddd') + const dayNumber = toPersianDigits(today.jDate()) + const monthName = today.locale('fa').format('MMMM') + const yearNumber = toPersianDigits(today.jYear()) + + const gregorianDate = today.clone().doAsGregorian() + const gDay = gregorianDate.format('D') + const gMonth = gregorianDate.locale('en').format('MMM') + + const hijriRaw = getHijriDate(today) + const [_, hijriMonth, hijriDate] = hijriRaw ? hijriRaw.split('/') : ['', '', ''] + const hijriMonthName = hijriMonthNames[Number(hijriMonth) - 1] || hijriMonth + const hijriFormatted = hijriDate + ? `${toPersianDigits(hijriDate)} ${hijriMonthName}` + : '' + + return ( +
    +
    +
    + {dayNumber} +
    +
    +
    + + {dayOfWeek} + + {todayIsHoliday && ( + + )} +
    + + {monthName} {yearNumber} + +
    +
    + +
    + + {gDay} {gMonth} + + {hijriFormatted && {hijriFormatted}} +
    +
    + ) +} diff --git a/src/layouts/widgets/date/variants/date-4x1.tsx b/src/layouts/widgets/date/variants/date-4x1.tsx new file mode 100644 index 000000000..a7b896b5d --- /dev/null +++ b/src/layouts/widgets/date/variants/date-4x1.tsx @@ -0,0 +1,82 @@ +import { useDate } from '@/context/date.context' +import { toPersianDigits } from '@/common/utils/persian-digits' +import { combineAndSortEvents } from '@/layouts/widgets/tools/events/utils' +import { useGetEvents } from '@/services/hooks/date/get-events.hook' +import { hijriMonthNames } from '@/layouts/widgets/calendar/utils' + +export function DateWideBanner() { + const { today, todayIsHoliday, getHijriDate } = useDate() + const { data: events } = useGetEvents() + + const dayOfWeek = today.locale('fa').format('dddd') + const dayNumber = toPersianDigits(today.jDate()) + const monthName = today.locale('fa').format('MMMM') + const yearNumber = toPersianDigits(today.jYear()) + + const gregorianDate = today.clone().doAsGregorian() + const gDay = gregorianDate.format('D') + const gMonth = gregorianDate.locale('en').format('MMMM') + const gYear = gregorianDate.format('YYYY') + + const hijriRaw = getHijriDate(today) + const [hijriYear, hijriMonth, hijriDate] = hijriRaw + ? hijriRaw.split('/') + : ['', '', ''] + const hijriMonthName = hijriMonthNames[Number(hijriMonth) - 1] || hijriMonth + const hijriFormatted = hijriDate + ? `${toPersianDigits(hijriDate)} ${hijriMonthName} ${toPersianDigits(hijriYear)}` + : '' + + const eventsForCalendar = events || { + gregorianEvents: [], + hijriEvents: [], + shamsiEvents: [], + } + const sortedEvents = combineAndSortEvents(eventsForCalendar, today.clone(), []) + const topEvent = sortedEvents[0] + + return ( +
    +
    +
    + {dayNumber} +
    +
    +
    + + {dayOfWeek} + + {todayIsHoliday && ( + + تعطیل رسمی + + )} +
    + + {monthName} {yearNumber} + +
    +
    + + {topEvent && ( +
    + + {topEvent.title} + + مناسبت امروز +
    + )} + +
    + + {gDay} {gMonth} {gYear} + + {hijriFormatted && ( + + {hijriFormatted} + + )} +
    +
    + ) +} diff --git a/src/layouts/widgets/habit/habits.layout.tsx b/src/layouts/widgets/habit/habits.layout.tsx index 4d3af27d0..c317ba368 100644 --- a/src/layouts/widgets/habit/habits.layout.tsx +++ b/src/layouts/widgets/habit/habits.layout.tsx @@ -18,6 +18,11 @@ import { callEvent } from '@/common/utils/call-event' import { HabitItemSkeleton } from './components/item/habit-item.skeleton' import { Icon } from '@/src/icons' import { HabitEmpty } from './components/habit-empty' +import { HabitCompactSquare } from './variants/habit-1x1' +import { HabitCompactRow } from './variants/habit-2x1' +import { HabitWideBanner } from './variants/habit-4x1' +import { HabitWideFull } from './variants/habit-4x2' +import type { WidgetSize } from '../layout-engine/types' export function HabitsContent() { const { isAuthenticated } = useAuth() @@ -98,24 +103,30 @@ export function HabitsContent() {
    -

    عادت‌ها

    + + +
    @@ -123,27 +134,30 @@ export function HabitsContent() {
    -
    +
    {isLoading ? (
    {[...Array(3)].map((_, i) => ( - + ))}
    ) : isEmpty ? ( ) : (
    {data?.items?.map((habit) => ( setDetailHabitId(habit.id)} + onChanged={() => refetch()} + onViewDetails={() => { + setDetailHabitId(habit.id) + Analytics.event('habit_open_detail_model') + }} /> ))}
    @@ -151,17 +165,6 @@ export function HabitsContent() {
    - - - {!!detailHabitId && ( + {detailHabitId && ( onCloseDetailModal()} onEdit={(habit) => handleEditHabit(habit)} @@ -199,13 +202,63 @@ export function HabitsContent() { ) } -import type { WidgetSize } from '../layout-engine/types' - interface HabitsLayoutProps { size?: WidgetSize } -export function HabitsLayout({ size }: HabitsLayoutProps = {}) { +export function HabitsLayout({ size = { w: 2, h: 2 } }: HabitsLayoutProps = {}) { + const { isAuthenticated } = useAuth() + const { today } = useDate() + const { data, isLoading, refetch } = useGetHabits(isAuthenticated) + const [detailHabitId, setDetailHabitId] = useState(null) + + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h >= 2) { + return ( + + refetch()} + onViewDetails={(id) => setDetailHabitId(id)} + /> + {detailHabitId && ( + setDetailHabitId(null)} + onEdit={() => {}} + onArchive={() => {}} + /> + )} + + ) + } + return ( diff --git a/src/layouts/widgets/habit/variants/habit-compact-square.tsx b/src/layouts/widgets/habit/variants/habit-1x1.tsx similarity index 82% rename from src/layouts/widgets/habit/variants/habit-compact-square.tsx rename to src/layouts/widgets/habit/variants/habit-1x1.tsx index ea3dbb9f8..f25b7e7b3 100644 --- a/src/layouts/widgets/habit/variants/habit-compact-square.tsx +++ b/src/layouts/widgets/habit/variants/habit-1x1.tsx @@ -1,9 +1,18 @@ import type { Habit } from '@/services/hooks/habit/habit.interface' +import { toPersianDigits } from '@/common/utils/persian-digits' import { Icon } from '@/src/icons' -function toPersianDigits(val: string | number): string { - const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] - return String(val).replace(/\d/g, (x) => farsiDigits[Number.parseInt(x, 10)]) +function getHabitStreak(h: Habit): number { + let streak = h.today?.isDone ? 1 : 0 + if (!h.history) return streak + for (let i = h.history.length - 1; i >= 0; i--) { + if (h.history[i]?.isDone) { + streak++ + } else { + break + } + } + return streak } interface HabitCompactSquareProps { @@ -20,7 +29,7 @@ export function HabitCompactSquare({ (h) => h.today.value >= (h.target || 1) ).length const percent = total > 0 ? Math.round((completed / total) * 100) : 0 - const maxStreak = Math.max(...habits.map((h) => h.streak?.current || 0), 0) + const maxStreak = Math.max(...habits.map((h) => getHabitStreak(h)), 0) if (isLoading) { return ( diff --git a/src/layouts/widgets/habit/variants/habit-compact-row.tsx b/src/layouts/widgets/habit/variants/habit-2x1.tsx similarity index 82% rename from src/layouts/widgets/habit/variants/habit-compact-row.tsx rename to src/layouts/widgets/habit/variants/habit-2x1.tsx index 83283c89d..3467eb5ca 100644 --- a/src/layouts/widgets/habit/variants/habit-compact-row.tsx +++ b/src/layouts/widgets/habit/variants/habit-2x1.tsx @@ -1,10 +1,19 @@ import type { Habit } from '@/services/hooks/habit/habit.interface' +import { toPersianDigits } from '@/common/utils/persian-digits' import { SimpleProgressRing } from '../components/item/button.simple-progress-ring' import { Icon } from '@/src/icons' -function toPersianDigits(val: string | number): string { - const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] - return String(val).replace(/\d/g, (x) => farsiDigits[Number.parseInt(x, 10)]) +function getHabitStreak(h: Habit): number { + let streak = h.today?.isDone ? 1 : 0 + if (!h.history) return streak + for (let i = h.history.length - 1; i >= 0; i--) { + if (h.history[i]?.isDone) { + streak++ + } else { + break + } + } + return streak } interface HabitCompactRowProps { @@ -40,6 +49,7 @@ export function HabitCompactRow({ habits, isLoading }: HabitCompactRowProps) { const target = habit.target || 1 const value = habit.today.value const isCompleted = value >= target + const streak = getHabitStreak(habit) return (
    {toPersianDigits(value)} / {toPersianDigits(target)} - {habit.streak?.current ? ( - + {streak > 0 ? ( + 🔥 - {toPersianDigits(habit.streak.current)} + {toPersianDigits(streak)} ) : null}
    diff --git a/src/layouts/widgets/habit/variants/habit-4x1.tsx b/src/layouts/widgets/habit/variants/habit-4x1.tsx new file mode 100644 index 000000000..036dd7c49 --- /dev/null +++ b/src/layouts/widgets/habit/variants/habit-4x1.tsx @@ -0,0 +1,98 @@ +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { toPersianDigits } from '@/common/utils/persian-digits' +import { SimpleProgressRing } from '../components/item/button.simple-progress-ring' +import { Icon } from '@/src/icons' + +function getHabitStreak(h: Habit): number { + let streak = h.today?.isDone ? 1 : 0 + if (!h.history) return streak + for (let i = h.history.length - 1; i >= 0; i--) { + if (h.history[i]?.isDone) { + streak++ + } else { + break + } + } + return streak +} + +interface HabitWideBannerProps { + habits: Habit[] + isLoading: boolean +} + +export function HabitWideBanner({ habits, isLoading }: HabitWideBannerProps) { + const displayHabits = habits.slice(0, 4) + + if (isLoading) { + return ( +
    + {[...Array(4)].map((_, i) => ( +
    + ))} +
    + ) + } + + if (habits.length === 0) { + return ( +
    + + عادتی ثبت نشده است +
    + ) + } + + return ( +
    + {displayHabits.map((habit) => { + const color = habit.color || '#536dfe' + const target = habit.target || 1 + const value = habit.today.value + const isCompleted = value >= target + const streak = getHabitStreak(habit) + + return ( +
    +
    +
    +
    + + {isCompleted && ( + + ✓ + + )} +
    + + {habit.title} + +
    +
    + +
    + + {toPersianDigits(value)} / {toPersianDigits(target)} + + {streak > 0 ? ( + + 🔥 + {toPersianDigits(streak)} + + ) : null} +
    +
    + ) + })} +
    + ) +} diff --git a/src/layouts/widgets/habit/variants/habit-4x2.tsx b/src/layouts/widgets/habit/variants/habit-4x2.tsx new file mode 100644 index 000000000..a907d7845 --- /dev/null +++ b/src/layouts/widgets/habit/variants/habit-4x2.tsx @@ -0,0 +1,116 @@ +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { toPersianDigits } from '@/common/utils/persian-digits' +import { HabitItem } from '../components/item/habit.item' +import type { WidgetifyDate } from '@/layouts/widgets/calendar/utils' +import { Icon } from '@/src/icons' + +function getHabitStreak(h: Habit): number { + let streak = h.today?.isDone ? 1 : 0 + if (!h.history) return streak + for (let i = h.history.length - 1; i >= 0; i--) { + if (h.history[i]?.isDone) { + streak++ + } else { + break + } + } + return streak +} + +interface HabitWideFullProps { + habits: Habit[] + today: WidgetifyDate + isLoading: boolean + onChanged: () => void + onViewDetails: (habitId: string) => void +} + +export function HabitWideFull({ + habits, + today, + isLoading, + onChanged, + onViewDetails, +}: HabitWideFullProps) { + const total = habits.length + const completed = habits.filter((h) => h.today.value >= (h.target || 1)).length + const percent = total > 0 ? Math.round((completed / total) * 100) : 0 + const maxStreak = Math.max(...habits.map((h) => getHabitStreak(h)), 0) + + return ( +
    +
    +
    + + آمار عادات +
    + +
    +
    + انجام‌شده: + + {toPersianDigits(completed)} از {toPersianDigits(total)} + +
    +
    + درصد پیشرفت: + + {toPersianDigits(percent)}٪ + +
    + {maxStreak > 0 && ( +
    + + بیشترین استریک: + + + 🔥 + {toPersianDigits(maxStreak)} روز + +
    + )} +
    + +
    +
    +
    +
    + +
    +
    + عادت‌های امروز + + {toPersianDigits(total)} مورد + +
    + +
    + {isLoading ? ( +
    + {[...Array(4)].map((_, i) => ( +
    + ))} +
    + ) : habits.length === 0 ? ( +
    + عادتی برای نمایش وجود ندارد +
    + ) : ( + habits.map((habit) => ( + onViewDetails(habit.id)} + /> + )) + )} +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/network/components/network-ip-card.tsx b/src/layouts/widgets/network/components/network-ip-card.tsx index 50d994efa..56d035ff9 100644 --- a/src/layouts/widgets/network/components/network-ip-card.tsx +++ b/src/layouts/widgets/network/components/network-ip-card.tsx @@ -19,7 +19,7 @@ export function NetworkIPCard({ ip, blurMode }: NetworkIPCardProps) {
    آدرس IP
    {ip || '.........'} diff --git a/src/layouts/widgets/network/network.layout.tsx b/src/layouts/widgets/network/network.layout.tsx index 357bb01ef..334364895 100644 --- a/src/layouts/widgets/network/network.layout.tsx +++ b/src/layouts/widgets/network/network.layout.tsx @@ -9,7 +9,11 @@ import { useGeneralSetting } from '@/context/general-setting.context' import { getMainClient, safeAwait } from '@/services/api' import { WidgetContainer } from '../widget-container' import { NetworkIPCard, NetworkPingCard } from './components' +import { NetworkCompactSquare } from './variants/network-1x1' +import { NetworkCompactRow } from './variants/network-2x1' +import { NetworkWideBanner } from './variants/network-4x1' import { Icon } from '@/src/icons' +import type { WidgetSize } from '../layout-engine/types' interface NetworkInfo { status: 'online' | 'offline' @@ -22,14 +26,12 @@ interface NetworkInfo { speed: string } -import type { WidgetSize } from '../layout-engine/types' - interface Prop { inComboWidget: boolean enableBackground: boolean size?: WidgetSize } -export function NetworkLayout({ enableBackground, inComboWidget, size }: Prop) { +export function NetworkLayout({ enableBackground, inComboWidget, size = { w: 2, h: 2 } }: Prop) { const { blurMode } = useGeneralSetting() const { isAuthenticated } = useAuth() @@ -101,6 +103,7 @@ export function NetworkLayout({ enableBackground, inComboWidget, size }: Prop) { status: 'online', })) }) + if (isAuthenticated) { fetchNetworkData() } @@ -111,6 +114,52 @@ export function NetworkLayout({ enableBackground, inComboWidget, size }: Prop) { fetchNetworkData() } + if (!inComboWidget) { + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h === 1) { + return ( + + + + ) + } + } + return ( +
    +
    +
    +
    + ) + } + + return ( +
    +
    + + شبکه +
    + +
    + + {ping !== null ? `${ping}` : '--'} + + ms + + + + پینگ سرور + +
    + +
    +
    + + {isOnline ? 'متصل' : 'قطع'} + +
    +
    + ) +} diff --git a/src/layouts/widgets/network/variants/network-2x1.tsx b/src/layouts/widgets/network/variants/network-2x1.tsx new file mode 100644 index 000000000..0255b6687 --- /dev/null +++ b/src/layouts/widgets/network/variants/network-2x1.tsx @@ -0,0 +1,84 @@ +import { Icon } from '@/src/icons' + +interface NetworkCompactRowProps { + status: 'online' | 'offline' + ip: string | null + countryIcon: string | null + city: string | null + isp: string | null + ping: number | null + isLoading: boolean +} + +export function NetworkCompactRow({ + status, + ip, + countryIcon, + city, + isp, + ping, + isLoading, +}: NetworkCompactRowProps) { + const isOnline = status === 'online' + + if (isLoading) { + return ( +
    +
    +
    +
    + ) + } + + return ( +
    +
    + {countryIcon ? ( + flag + ) : ( +
    + 🌐 +
    + )} + +
    +
    + + {ip || '---'} + +
    + + {city ? `${city} · ` : ''} + {isp || 'ارائه‌دهنده نامشخص'} + +
    +
    + +
    +
    + + {ping !== null ? ping : '--'} + + ms +
    +
    +
    + + {isOnline ? 'متصل' : 'قطع'} + +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/network/variants/network-4x1.tsx b/src/layouts/widgets/network/variants/network-4x1.tsx new file mode 100644 index 000000000..850ad28e9 --- /dev/null +++ b/src/layouts/widgets/network/variants/network-4x1.tsx @@ -0,0 +1,90 @@ +import { Icon } from '@/src/icons' + +interface NetworkWideBannerProps { + status: 'online' | 'offline' + ip: string | null + countryIcon: string | null + city: string | null + isp: string | null + ping: number | null + isLoading: boolean +} + +export function NetworkWideBanner({ + status, + ip, + countryIcon, + city, + isp, + ping, + isLoading, +}: NetworkWideBannerProps) { + const isOnline = status === 'online' + + if (isLoading) { + return ( +
    +
    +
    +
    +
    + ) + } + + return ( +
    +
    +
    + + وضعیت شبکه +
    + +
    +
    + + {isOnline ? 'اینترنت متصل' : 'عدم اتصال'} + +
    +
    + +
    + {countryIcon && ( + flag + )} + {ip || '---'} + {city && ({city})} +
    + +
    + {isp && ( + + {isp} + + )} +
    + + {ping !== null ? ping : '--'} + + ms +
    +
    +
    + ) +} diff --git a/src/layouts/widgets/news/news.layout.tsx b/src/layouts/widgets/news/news.layout.tsx index 19f8388a6..f48a7778d 100644 --- a/src/layouts/widgets/news/news.layout.tsx +++ b/src/layouts/widgets/news/news.layout.tsx @@ -5,8 +5,9 @@ import { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' import { WidgetContainer } from '../widget-container' import { NewsContainer } from './components/news-container' import { NewsHeader } from './components/news-header' +import { NewsCompactRow } from './variants/news-2x1' +import { NewsWideBanner } from './variants/news-4x1' import type { WigiNewsSetting } from './rss.interface' - import type { WidgetSize } from '../layout-engine/types' interface NewsLayoutProps { @@ -18,7 +19,7 @@ interface NewsLayoutProps { export const NewsLayout: React.FC = ({ enableBackground = true, inComboWidget, - size, + size = { w: 2, h: 2 }, }) => { const [rssState, setRssState] = useState({ customFeeds: [], @@ -51,38 +52,54 @@ export const NewsLayout: React.FC = ({ } }, []) - return ( - <> - {inComboWidget ? ( -
    - -
    - ) : ( - - - callEvent('openWidgetsSettings', { - tab: WidgetTabKeys.news_settings, - }) - } - /> + if (!inComboWidget) { + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } - + if (size.w >= 4 && size.h === 1) { + return ( + + - )} - + ) + } + } + + if (inComboWidget) { + return ( +
    + +
    + ) + } + + return ( + + + callEvent('openWidgetsSettings', { + tab: WidgetTabKeys.news_settings, + }) + } + /> + + + ) } diff --git a/src/layouts/widgets/news/variants/news-2x1.tsx b/src/layouts/widgets/news/variants/news-2x1.tsx new file mode 100644 index 000000000..7d1bb077f --- /dev/null +++ b/src/layouts/widgets/news/variants/news-2x1.tsx @@ -0,0 +1,63 @@ +import { useGetNews } from '@/services/hooks/news/get-news.hook' +import { Icon } from '@/src/icons' +import Analytics from '@/analytics' + +export function NewsCompactRow() { + const { data: newsItems, isLoading } = useGetNews(true) + const topNews = newsItems?.[0] + + const handleOpen = () => { + if (topNews?.link) { + window.open(topNews.link, '_blank', 'noopener,noreferrer') + Analytics.event('rss_link_opened') + } + } + + if (isLoading) { + return ( +
    +
    +
    +
    +
    +
    +
    + ) + } + + if (!topNews) { + return ( +
    + + خبری برای نمایش وجود ندارد +
    + ) + } + + return ( +
    +
    +
    + +
    + +
    + + {topNews.title} + + + {topNews.source?.name || 'خبرگزاری'} + +
    +
    + +
    + مشاهده + +
    +
    + ) +} diff --git a/src/layouts/widgets/news/variants/news-4x1.tsx b/src/layouts/widgets/news/variants/news-4x1.tsx new file mode 100644 index 000000000..1c471a19a --- /dev/null +++ b/src/layouts/widgets/news/variants/news-4x1.tsx @@ -0,0 +1,76 @@ +import { useGetNews, type FetchedRssItem } from '@/services/hooks/news/get-news.hook' +import { Icon } from '@/src/icons' +import Analytics from '@/analytics' + +function NewsBannerCard({ news }: { news: FetchedRssItem }) { + const handleOpen = (e: React.MouseEvent) => { + e.stopPropagation() + if (news.link) { + window.open(news.link, '_blank', 'noopener,noreferrer') + Analytics.event('rss_link_opened') + } + } + + return ( +
    +
    + {news.image_url ? ( + news + ) : ( +
    + +
    + )} + +
    + + {news.title} + + + {news.source?.name || 'خبرگزاری'} + +
    +
    + + +
    + ) +} + +export function NewsWideBanner() { + const { data, isLoading } = useGetNews(true) + const topNews = data.news?.slice(0, 2) || [] + + if (isLoading) { + return ( +
    +
    +
    +
    + ) + } + + if (topNews.length === 0) { + return ( +
    + + خبری برای نمایش وجود ندارد +
    + ) + } + + return ( +
    + {topNews.map((item, idx) => ( + + ))} +
    + ) +} diff --git a/src/layouts/widgets/notes/notes.layout.tsx b/src/layouts/widgets/notes/notes.layout.tsx index 49ad2232e..2364db01a 100644 --- a/src/layouts/widgets/notes/notes.layout.tsx +++ b/src/layouts/widgets/notes/notes.layout.tsx @@ -5,11 +5,22 @@ import { NoteEditor } from './components/note-editor' import { NoteItem } from './components/note-item' import { NoteNavigation } from './components/note-navigation' import { NoteEmpty } from './components/note-empty' +import { NoteCompactRow } from './variants/note-2x1' +import { NoteWideFull } from './variants/note-4x2' +import type { WidgetSize } from '../layout-engine/types' -function NotesContent() { +function NotesContent({ size }: { size?: WidgetSize }) { const { notes, activeNoteId } = useNotes() const { blurMode } = useGeneralSetting() + if (size && size.w === 2 && size.h === 1) { + return + } + + if (size && size.w >= 4 && size.h >= 2) { + return + } + const activeNote = notes.find((note) => note.id === activeNoteId) if (!activeNote && !notes.length) { @@ -50,7 +61,27 @@ function NoteList() { ) } -export function NotesLayout() { +interface NotesLayoutProps { + size?: WidgetSize +} + +export function NotesLayout({ size = { w: 2, h: 2 } }: NotesLayoutProps = {}) { + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h >= 2) { + return ( + + + + ) + } + return (
    @@ -60,7 +91,7 @@ export function NotesLayout() {
    - +
    ) diff --git a/src/layouts/widgets/notes/variants/note-2x1.tsx b/src/layouts/widgets/notes/variants/note-2x1.tsx new file mode 100644 index 000000000..e751dd1b1 --- /dev/null +++ b/src/layouts/widgets/notes/variants/note-2x1.tsx @@ -0,0 +1,41 @@ +import { useNotes } from '@/context/notes.context' +import { Icon } from '@/src/icons' + +export function NoteCompactRow() { + const { notes, setActiveNoteId, addNote } = useNotes() + const latestNote = notes[0] + + const handleOpen = () => { + if (latestNote) { + setActiveNoteId(latestNote.id) + } else { + addNote() + } + } + + return ( +
    +
    +
    + +
    + +
    + + {latestNote?.title || 'یادداشت جدید'} + + + {latestNote?.body || 'برای نوشتن کلیک کنید...'} + +
    +
    + +
    + {notes.length} یادداشت +
    +
    + ) +} diff --git a/src/layouts/widgets/notes/variants/note-4x2.tsx b/src/layouts/widgets/notes/variants/note-4x2.tsx new file mode 100644 index 000000000..a8f1628aa --- /dev/null +++ b/src/layouts/widgets/notes/variants/note-4x2.tsx @@ -0,0 +1,62 @@ +import { useNotes } from '@/context/notes.context' +import { useGeneralSetting } from '@/context/general-setting.context' +import { NoteEditor } from '../components/note-editor' +import { NoteItem } from '../components/note-item' +import { NoteEmpty } from '../components/note-empty' +import { Button } from '@/components/ui' +import { Icon } from '@/src/icons' + +export function NoteWideFull() { + const { notes, activeNoteId, setActiveNoteId, addNote } = useNotes() + const { blurMode } = useGeneralSetting() + + const activeNote = notes.find((note) => note.id === activeNoteId) || notes[0] + + return ( +
    +
    +
    + یادداشت‌ها + +
    + +
    + {notes.length === 0 ? ( +
    + یادداشتی وجود ندارد +
    + ) : ( + notes.map((note) => ( + setActiveNoteId(id)} + /> + )) + )} +
    +
    + +
    + {activeNote ? ( +
    + +
    + ) : ( + + )} +
    +
    + ) +} diff --git a/src/layouts/widgets/pet/pet.widget.tsx b/src/layouts/widgets/pet/pet.widget.tsx index 8645d7414..8c494e409 100644 --- a/src/layouts/widgets/pet/pet.widget.tsx +++ b/src/layouts/widgets/pet/pet.widget.tsx @@ -2,7 +2,7 @@ import { PetProvider } from '@/layouts/widgetify-card/pets/pet.context' import { PetFactory } from '@/layouts/widgetify-card/pets/pet-factory' import { WidgetContainer } from '../widget-container' import type { WidgetSize } from '../layout-engine/types' -import { PetCompactSquare } from './variants/pet-compact-square' +import { PetCompactSquare } from './variants/pet-1x1' interface PetWidgetContentProps { size?: WidgetSize diff --git a/src/layouts/widgets/pet/variants/pet-compact-square.tsx b/src/layouts/widgets/pet/variants/pet-1x1.tsx similarity index 100% rename from src/layouts/widgets/pet/variants/pet-compact-square.tsx rename to src/layouts/widgets/pet/variants/pet-1x1.tsx diff --git a/src/layouts/widgets/todos/todos.tsx b/src/layouts/widgets/todos/todos.tsx index 70472637d..20df44dfc 100644 --- a/src/layouts/widgets/todos/todos.tsx +++ b/src/layouts/widgets/todos/todos.tsx @@ -13,7 +13,7 @@ import { useGetTodos } from '@/services/hooks/todo/get-todos.hook' import { TodoItem } from './todo.item' import { Icon } from '@/src/icons' import { TodosEmpty } from './components/todo-empty' -import { TodoCompactRow } from './variants/todo-compact-row' +import { TodoCompactRow } from './variants/todo-2x1' import type { WidgetSize } from '../layout-engine/types' const filterOptions = [ diff --git a/src/layouts/widgets/todos/variants/todo-compact-row.tsx b/src/layouts/widgets/todos/variants/todo-2x1.tsx similarity index 98% rename from src/layouts/widgets/todos/variants/todo-compact-row.tsx rename to src/layouts/widgets/todos/variants/todo-2x1.tsx index 6b05129ab..70fe128f6 100644 --- a/src/layouts/widgets/todos/variants/todo-compact-row.tsx +++ b/src/layouts/widgets/todos/variants/todo-2x1.tsx @@ -74,7 +74,7 @@ export function TodoCompactRow({ todos, isLoading, onRefresh }: TodoCompactRowPr
    -
    +
    {completedCount} / {totalCount} diff --git a/src/layouts/widgets/tools/tools.layout.tsx b/src/layouts/widgets/tools/tools.layout.tsx index d1a0180ad..3e84a0bed 100644 --- a/src/layouts/widgets/tools/tools.layout.tsx +++ b/src/layouts/widgets/tools/tools.layout.tsx @@ -10,6 +10,10 @@ import { MdOutlineMosque, MdOutlineTimer, } from 'react-icons/md' +import { ToolsCompactRow } from './variants/tools-2x1' +import { ToolsWideBanner } from './variants/tools-4x1' +import type { WidgetSize } from '../layout-engine/types' + const tabs = [ { id: 'pomodoro' as ToolsTabType, @@ -51,7 +55,11 @@ export enum ToolsTab { } export type ToolsTabType = keyof typeof ToolsTab -export const ToolsLayout: React.FC = () => { +interface ToolsLayoutProps { + size?: WidgetSize +} + +export const ToolsLayout: React.FC = ({ size = { w: 2, h: 2 } }) => { const [activeTab, setActiveTab] = useState(null) const { selectedDate } = useDate() @@ -77,6 +85,22 @@ export const ToolsLayout: React.FC = () => { load() }, []) + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + if (size.w >= 4 && size.h === 1) { + return ( + + + + ) + } + return ( void +} + +export function ToolsCompactRow({ onSelectTab }: ToolsCompactRowProps) { + return ( +
    + + + + + +
    + ) +} diff --git a/src/layouts/widgets/tools/variants/tools-4x1.tsx b/src/layouts/widgets/tools/variants/tools-4x1.tsx new file mode 100644 index 000000000..78c7c3a54 --- /dev/null +++ b/src/layouts/widgets/tools/variants/tools-4x1.tsx @@ -0,0 +1,50 @@ +import { + MdOutlineCurrencyExchange, + MdOutlineMosque, + MdOutlineTimer, +} from 'react-icons/md' +import { Icon } from '@/src/icons' + +interface ToolsWideBannerProps { + onSelectTab: (tab: 'pomodoro' | 'religious-time' | 'currency-converter') => void +} + +export function ToolsWideBanner({ onSelectTab }: ToolsWideBannerProps) { + return ( +
    +
    + + ابزارها +
    + +
    + + + + + +
    +
    + ) +} diff --git a/src/layouts/widgets/types/widget-base.types.ts b/src/layouts/widgets/types/widget-base.types.ts new file mode 100644 index 000000000..35a6d1672 --- /dev/null +++ b/src/layouts/widgets/types/widget-base.types.ts @@ -0,0 +1,19 @@ +import type React from 'react' +import type { WidgetSize } from '../layout-engine/types' + +export type WidgetSizeKey = `${number}x${number}` + +export interface BaseWidgetProps { + instanceId?: string + size?: WidgetSize + className?: string +} + +export type WidgetVariantComponent

    = React.ComponentType< + P & { size?: WidgetSize } +> + +export function matchWidgetSize(size?: WidgetSize): WidgetSizeKey { + if (!size) return '2x2' + return `${size.w}x${size.h}` as WidgetSizeKey +} diff --git a/src/layouts/widgets/weather/variants/weather-compact-square.tsx b/src/layouts/widgets/weather/variants/weather-1x1.tsx similarity index 100% rename from src/layouts/widgets/weather/variants/weather-compact-square.tsx rename to src/layouts/widgets/weather/variants/weather-1x1.tsx diff --git a/src/layouts/widgets/weather/variants/weather-compact-row.tsx b/src/layouts/widgets/weather/variants/weather-2x1.tsx similarity index 100% rename from src/layouts/widgets/weather/variants/weather-compact-row.tsx rename to src/layouts/widgets/weather/variants/weather-2x1.tsx diff --git a/src/layouts/widgets/weather/variants/weather-wide-banner.tsx b/src/layouts/widgets/weather/variants/weather-4x1.tsx similarity index 87% rename from src/layouts/widgets/weather/variants/weather-wide-banner.tsx rename to src/layouts/widgets/weather/variants/weather-4x1.tsx index 106e5c434..dad4c93cd 100644 --- a/src/layouts/widgets/weather/variants/weather-wide-banner.tsx +++ b/src/layouts/widgets/weather/variants/weather-4x1.tsx @@ -16,12 +16,9 @@ export function WeatherWideBanner({ const cityName = cleanCityName(fetchedWeather?.city?.fa || '') const iconUrl = fetchedWeather?.weather?.icon?.url const description = fetchedWeather?.weather?.description?.text || '' - const tempDescription = - fetchedWeather?.weather?.temperature?.temp_description || '' + const tempDescription = fetchedWeather?.weather?.temperature?.temp_description || '' const humidity = fetchedWeather?.weather?.temperature?.humidity || 0 - const windSpeed = Math.round( - fetchedWeather?.weather?.temperature?.wind_speed || 0 - ) + const windSpeed = Math.round(fetchedWeather?.weather?.temperature?.wind_speed || 0) const clouds = fetchedWeather?.weather?.temperature?.clouds || 0 return ( @@ -60,17 +57,17 @@ export function WeatherWideBanner({

    - {windSpeed} m/s + {windSpeed} m/s
    - {humidity}% + {humidity}%
    - {clouds}% + {clouds}%
    diff --git a/src/layouts/widgets/weather/variants/weather-wide-full.tsx b/src/layouts/widgets/weather/variants/weather-4x2.tsx similarity index 100% rename from src/layouts/widgets/weather/variants/weather-wide-full.tsx rename to src/layouts/widgets/weather/variants/weather-4x2.tsx diff --git a/src/layouts/widgets/weather/weather.layout.tsx b/src/layouts/widgets/weather/weather.layout.tsx index 5687db2c4..a3661b9a2 100644 --- a/src/layouts/widgets/weather/weather.layout.tsx +++ b/src/layouts/widgets/weather/weather.layout.tsx @@ -5,10 +5,10 @@ import type { WeatherSettings } from '@/layouts/widgets/weather/weather.interfac import { WidgetContainer } from '../widget-container' import { Forecast } from './forecast/forecast' import { CurrentWeatherBox } from './current/current-box.weather' -import { WeatherCompactSquare } from './variants/weather-compact-square' -import { WeatherCompactRow } from './variants/weather-compact-row' -import { WeatherWideBanner } from './variants/weather-wide-banner' -import { WeatherWideFull } from './variants/weather-wide-full' +import { WeatherCompactSquare } from './variants/weather-1x1' +import { WeatherCompactRow } from './variants/weather-2x1' +import { WeatherWideBanner } from './variants/weather-4x1' +import { WeatherWideFull } from './variants/weather-4x2' import { useGetWeatherByLatLon } from '@/services/hooks/weather/get-weather-by-lat-lon' import type { WidgetSize } from '../layout-engine/types' diff --git a/src/layouts/widgets/widget-registry.tsx b/src/layouts/widgets/widget-registry.tsx index 79ced6c13..d5a4068b0 100644 --- a/src/layouts/widgets/widget-registry.tsx +++ b/src/layouts/widgets/widget-registry.tsx @@ -178,6 +178,7 @@ export const WIDGET_DEFINITIONS: Record = { label: 'تقویم', emoji: '📆', allowedSizes: [ + { w: 2, h: 1 }, { w: 2, h: 2 }, { w: 2, h: 3 }, { w: 4, h: 2 }, @@ -274,7 +275,11 @@ export const WIDGET_DEFINITIONS: Record = { id: WidgetKeys.tools, label: 'ابزارها', emoji: '🧰', - allowedSizes: [{ w: 2, h: 2 }], + allowedSizes: [ + { w: 2, h: 1 }, + { w: 2, h: 2 }, + { w: 4, h: 1 }, + ], defaultSize: { w: 2, h: 2 }, canDuplicate: true, preview: () => ( @@ -305,11 +310,11 @@ export const WIDGET_DEFINITIONS: Record = {
    USD / IRR - 60,000 + 60,000
    EUR / IRR - 65,000 + 65,000
    ), @@ -324,7 +329,9 @@ export const WIDGET_DEFINITIONS: Record = { label: 'ویجی نیوز', emoji: '📰', allowedSizes: [ + { w: 2, h: 1 }, { w: 2, h: 2 }, + { w: 4, h: 1 }, { w: 4, h: 2 }, ], defaultSize: { w: 2, h: 2 }, @@ -344,7 +351,12 @@ export const WIDGET_DEFINITIONS: Record = { id: WidgetKeys.network, label: 'شبکه', emoji: '🌐', - allowedSizes: [{ w: 2, h: 2 }], + allowedSizes: [ + { w: 1, h: 1 }, + { w: 2, h: 1 }, + { w: 2, h: 2 }, + { w: 4, h: 1 }, + ], defaultSize: { w: 2, h: 2 }, canDuplicate: true, preview: () => ( @@ -353,7 +365,7 @@ export const WIDGET_DEFINITIONS: Record = {
    متصل
    - Ping: 32ms + Ping: 32ms
    ), node: (instanceId, size) => ( @@ -365,8 +377,12 @@ export const WIDGET_DEFINITIONS: Record = { label: 'عادات', emoji: '🎯', allowedSizes: [ + { w: 1, h: 1 }, + { w: 2, h: 1 }, { w: 2, h: 2 }, { w: 2, h: 3 }, + { w: 4, h: 1 }, + { w: 4, h: 2 }, ], defaultSize: { w: 2, h: 2 }, canDuplicate: true, @@ -421,6 +437,7 @@ export const WIDGET_DEFINITIONS: Record = { label: 'یادداشت', emoji: '📝', allowedSizes: [ + { w: 2, h: 1 }, { w: 2, h: 2 }, { w: 2, h: 3 }, { w: 4, h: 2 }, @@ -439,7 +456,7 @@ export const WIDGET_DEFINITIONS: Record = { ), node: (instanceId, size) => ( - + ), }, diff --git a/src/layouts/widgets/wigi-arz/variants/currency-compact-square.tsx b/src/layouts/widgets/wigi-arz/variants/currency-1x1.tsx similarity index 92% rename from src/layouts/widgets/wigi-arz/variants/currency-compact-square.tsx rename to src/layouts/widgets/wigi-arz/variants/currency-1x1.tsx index f00f85330..14c3574ef 100644 --- a/src/layouts/widgets/wigi-arz/variants/currency-compact-square.tsx +++ b/src/layouts/widgets/wigi-arz/variants/currency-1x1.tsx @@ -64,7 +64,7 @@ export function CurrencyCompactSquare({ code }: CurrencyCompactSquareProps) {
    - + {priceResult.label}
    @@ -72,7 +72,7 @@ export function CurrencyCompactSquare({ code }: CurrencyCompactSquareProps) {
    {change !== undefined && (
    - + {priceResult.label}
    @@ -77,7 +80,7 @@ function CurrencyItemCompact({ code }: CurrencyItemCompactProps) { {change !== undefined && (
    - + {priceResult.label}
    @@ -77,7 +80,7 @@ function CurrencyItemBanner({ code }: CurrencyItemBannerProps) { {change !== undefined && ( ) : tab === 'notes' ? ( - + ) : ( )} diff --git a/src/services/hooks/news/get-news.hook.ts b/src/services/hooks/news/get-news.hook.ts index 41621aa55..0a7a22271 100644 --- a/src/services/hooks/news/get-news.hook.ts +++ b/src/services/hooks/news/get-news.hook.ts @@ -19,7 +19,7 @@ export interface FetchedRssItem { } export const useGetNews = (enabled: boolean) => { - return useQuery({ + return useQuery({ queryKey: ['getNews'], queryFn: async () => getNews(), retry: 1, @@ -43,8 +43,8 @@ export async function getRss(url: string, sourceName: string): Promise { +export async function getNews() { const client = getMainClient() - const { data } = await client.get('/news') + const { data } = await client.get('/news') return data } From 46dd3f5896ae09616b766b795851cb4866255c02 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Wed, 19 Aug 2026 22:10:37 +0330 Subject: [PATCH 006/260] fix --- src/context/free-widget.context.tsx | 27 ++----- src/context/widget-visibility.context.tsx | 4 +- .../widgets/canvas/add-widget-modal.tsx | 5 +- .../widgets/layout-engine/constants.ts | 26 +++---- src/layouts/widgets/layout-engine/types.ts | 1 - src/layouts/widgets/migration.ts | 77 +++---------------- .../widgets/news/variants/news-2x1.tsx | 9 ++- .../widgets/news/variants/news-4x1.tsx | 7 +- src/layouts/widgets/widget-registry.tsx | 48 +++++------- 9 files changed, 65 insertions(+), 139 deletions(-) diff --git a/src/context/free-widget.context.tsx b/src/context/free-widget.context.tsx index 7449af975..8f2cb4cf1 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 { getFromStorage, setToStorage } from '@/common/storage' +import { setToStorage } from '@/common/storage' import { showToast } from '@/common/toast' import { DEFAULT_CELL_HEIGHT, @@ -54,19 +54,12 @@ interface FreeWidgetContextType { const FreeWidgetContext = createContext(null) export function FreeWidgetProvider({ children }: { children: React.ReactNode }) { - const { - canvasMode, - setCanvasMode, - selectedInstanceId, - setSelectedInstanceId, - } = useAppearance() - - const [savedLayout, setSavedLayout] = useState( - DEFAULT_WIDGET_LAYOUT - ) - const [runtimeLayout, setRuntimeLayout] = useState( - DEFAULT_WIDGET_LAYOUT - ) + const { canvasMode, setCanvasMode, selectedInstanceId, setSelectedInstanceId } = + useAppearance() + + const [savedLayout, setSavedLayout] = useState(DEFAULT_WIDGET_LAYOUT) + const [runtimeLayout, setRuntimeLayout] = + useState(DEFAULT_WIDGET_LAYOUT) const [cols, setCols] = useState(DEFAULT_COLS) const [cellHeight, setCellHeight] = useState(DEFAULT_CELL_HEIGHT) const [gap, setGap] = useState(DEFAULT_GAP) @@ -153,11 +146,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) }, []) const commitMutation = useCallback( - ( - operation: string, - nextRuntime: StoredWidget[], - targetInstanceId?: string - ) => { + (operation: string, nextRuntime: StoredWidget[], targetInstanceId?: string) => { if (!validateLayout(nextRuntime, cols)) { return false } diff --git a/src/context/widget-visibility.context.tsx b/src/context/widget-visibility.context.tsx index 8abe1c530..66d8c3afc 100644 --- a/src/context/widget-visibility.context.tsx +++ b/src/context/widget-visibility.context.tsx @@ -156,9 +156,9 @@ interface WidgetVisibilityContextType { const defaultVisibility: WidgetKeys[] = [ WidgetKeys.calendar, - WidgetKeys.tools, - WidgetKeys.yadKar, WidgetKeys.comboWidget, + WidgetKeys.yadKar, + WidgetKeys.tools, ] export const MAX_VISIBLE_WIDGETS = 5 diff --git a/src/layouts/widgets/canvas/add-widget-modal.tsx b/src/layouts/widgets/canvas/add-widget-modal.tsx index 2756e4b9d..4022c7148 100644 --- a/src/layouts/widgets/canvas/add-widget-modal.tsx +++ b/src/layouts/widgets/canvas/add-widget-modal.tsx @@ -1,7 +1,6 @@ import { Modal } from '@/components/ui' import { useFreeWidgets } from '@/context/free-widget.context' import { WIDGET_DEFINITIONS } from '../widget-registry' -import { WidgetKeys } from '../layout-engine/types' interface AddWidgetModalProps { isOpen: boolean @@ -11,9 +10,7 @@ interface AddWidgetModalProps { export function AddWidgetModal({ isOpen, onClose }: AddWidgetModalProps) { const { runtimeLayout, addWidget } = useFreeWidgets() - const allDefinitions = Object.values(WIDGET_DEFINITIONS).filter( - (def) => def.id !== WidgetKeys.youtube - ) + const allDefinitions = Object.values(WIDGET_DEFINITIONS) const handleAdd = (id: string) => { const success = addWidget(id) diff --git a/src/layouts/widgets/layout-engine/constants.ts b/src/layouts/widgets/layout-engine/constants.ts index 468d0c436..d75bb442d 100644 --- a/src/layouts/widgets/layout-engine/constants.ts +++ b/src/layouts/widgets/layout-engine/constants.ts @@ -38,8 +38,8 @@ export const GRID_BREAKPOINTS: GridBreakpoint[] = [ export const DEFAULT_WIDGET_LAYOUT: StoredWidget[] = [ { - id: WidgetKeys.widgetify, - instanceId: 'widgetify-default', + id: WidgetKeys.wigiPad, + instanceId: 'wigipad-default', position: { col: 0, row: 0 }, size: { w: 2, h: 3 }, }, @@ -56,33 +56,33 @@ export const DEFAULT_WIDGET_LAYOUT: StoredWidget[] = [ size: { w: 4, h: 2 }, }, { - id: WidgetKeys.wigiPad, - instanceId: 'wigipad-default', + id: WidgetKeys.widgetify, + instanceId: 'widgetify-default', position: { col: 6, row: 0 }, size: { w: 2, h: 3 }, }, { - id: WidgetKeys.calendar, - instanceId: 'calendar-default', + id: WidgetKeys.comboWidget, + instanceId: 'comboWidget-default', position: { col: 0, row: 3 }, - size: { w: 2, h: 2 }, + size: { w: 2, h: 3 }, }, { id: WidgetKeys.tools, instanceId: 'tools-default', position: { col: 2, row: 3 }, - size: { w: 2, h: 2 }, + size: { w: 2, h: 3 }, }, { id: WidgetKeys.yadKar, - instanceId: 'yadkar-default', + instanceId: 'yadKar-default', position: { col: 4, row: 3 }, - size: { w: 2, h: 2 }, + size: { w: 2, h: 3 }, }, { - id: WidgetKeys.comboWidget, - instanceId: 'combo-default', + id: WidgetKeys.calendar, + instanceId: 'calendar-default', position: { col: 6, row: 3 }, - size: { w: 2, h: 2 }, + size: { w: 2, h: 3 }, }, ] diff --git a/src/layouts/widgets/layout-engine/types.ts b/src/layouts/widgets/layout-engine/types.ts index 0518f1445..a91dae937 100644 --- a/src/layouts/widgets/layout-engine/types.ts +++ b/src/layouts/widgets/layout-engine/types.ts @@ -7,7 +7,6 @@ export enum WidgetKeys { todos = 'todos', tools = 'tools', notes = 'notes', - youtube = 'youtube', wigiPad = 'wigiPad', network = 'network', yadKar = 'yadKar', diff --git a/src/layouts/widgets/migration.ts b/src/layouts/widgets/migration.ts index 8ef636d8d..8f6c27a7c 100644 --- a/src/layouts/widgets/migration.ts +++ b/src/layouts/widgets/migration.ts @@ -2,88 +2,29 @@ import { getFromStorage, setToStorage } from '@/common/storage' import { compactLayout, DEFAULT_WIDGET_LAYOUT, - findAvailableSlot, validateLayout, } from './layout-engine' -import { type StoredWidget, WidgetKeys } from './layout-engine/types' -import { WIDGET_DEFINITIONS } from './widget-registry' +import type { StoredWidget } from './layout-engine/types' export const MIGRATION_VERSION_KEY = 'widgetLayoutMigrationVersion' -export const CURRENT_MIGRATION_VERSION = 1 +export const CURRENT_MIGRATION_VERSION = 5 export async function migrateWidgetLayoutIfNeeded(): Promise { const version = await getFromStorage('widgetLayoutMigrationVersion') const existingStored = await getFromStorage('storedWidgets') - if (version && version >= CURRENT_MIGRATION_VERSION && existingStored && existingStored.length > 0) { + if ( + version && + version >= CURRENT_MIGRATION_VERSION && + existingStored && + existingStored.length > 0 + ) { if (validateLayout(existingStored, 8)) { return existingStored } } - const legacyActive = await getFromStorage('activeWidgets') - - let candidateLayout: StoredWidget[] = [] - - if (legacyActive && Array.isArray(legacyActive) && legacyActive.length > 0) { - candidateLayout = [ - { - id: WidgetKeys.widgetify, - instanceId: 'widgetify-default', - position: { col: 0, row: 0 }, - size: { w: 2, h: 3 }, - }, - { - id: WidgetKeys.search, - instanceId: 'search-default', - position: { col: 2, row: 0 }, - size: { w: 4, h: 1 }, - }, - { - id: WidgetKeys.bookmarks, - instanceId: 'bookmarks-default', - position: { col: 2, row: 1 }, - size: { w: 4, h: 2 }, - }, - { - id: WidgetKeys.wigiPad, - instanceId: 'wigipad-default', - position: { col: 6, row: 0 }, - size: { w: 2, h: 3 }, - }, - ] - - const sortedLegacy = [...legacyActive].sort( - (a, b) => (a.order ?? 0) - (b.order ?? 0) - ) - - for (const item of sortedLegacy) { - const id = item.id as WidgetKeys - if ( - id === WidgetKeys.widgetify || - id === WidgetKeys.search || - id === WidgetKeys.bookmarks || - id === WidgetKeys.wigiPad - ) { - continue - } - - const def = WIDGET_DEFINITIONS[id] - const size = def?.defaultSize || { w: 2, h: 2 } - const slot = findAvailableSlot(candidateLayout, size, 8) - - candidateLayout.push({ - id, - instanceId: `${id}-default`, - position: slot, - size, - }) - } - } else { - candidateLayout = [...DEFAULT_WIDGET_LAYOUT] - } - - const finalLayout = compactLayout(candidateLayout, 8) + const finalLayout = compactLayout([...DEFAULT_WIDGET_LAYOUT], 8) await Promise.all([ setToStorage('storedWidgets', finalLayout), diff --git a/src/layouts/widgets/news/variants/news-2x1.tsx b/src/layouts/widgets/news/variants/news-2x1.tsx index 7d1bb077f..9093234f1 100644 --- a/src/layouts/widgets/news/variants/news-2x1.tsx +++ b/src/layouts/widgets/news/variants/news-2x1.tsx @@ -1,10 +1,13 @@ -import { useGetNews } from '@/services/hooks/news/get-news.hook' +import { useGetNews, type FetchedRssItem } from '@/services/hooks/news/get-news.hook' import { Icon } from '@/src/icons' import Analytics from '@/analytics' export function NewsCompactRow() { - const { data: newsItems, isLoading } = useGetNews(true) - const topNews = newsItems?.[0] + const { data, isLoading } = useGetNews(true) + const newsList: FetchedRssItem[] = Array.isArray(data) + ? data + : (data as any)?.news || [] + const topNews = newsList[0] const handleOpen = () => { if (topNews?.link) { diff --git a/src/layouts/widgets/news/variants/news-4x1.tsx b/src/layouts/widgets/news/variants/news-4x1.tsx index 1c471a19a..a0e2a43ea 100644 --- a/src/layouts/widgets/news/variants/news-4x1.tsx +++ b/src/layouts/widgets/news/variants/news-4x1.tsx @@ -46,7 +46,10 @@ function NewsBannerCard({ news }: { news: FetchedRssItem }) { export function NewsWideBanner() { const { data, isLoading } = useGetNews(true) - const topNews = data.news?.slice(0, 2) || [] + const newsList: FetchedRssItem[] = Array.isArray(data) + ? data + : (data as any)?.news || [] + const topNews = newsList.slice(0, 2) if (isLoading) { return ( @@ -68,7 +71,7 @@ export function NewsWideBanner() { return (
    - {topNews.map((item, idx) => ( + {topNews.map((item: FetchedRssItem, idx: number) => ( ))}
    diff --git a/src/layouts/widgets/widget-registry.tsx b/src/layouts/widgets/widget-registry.tsx index d5a4068b0..f93bff869 100644 --- a/src/layouts/widgets/widget-registry.tsx +++ b/src/layouts/widgets/widget-registry.tsx @@ -1,4 +1,3 @@ -import type React from 'react' import { BookmarkProvider } from '@/layouts/bookmark/context/bookmark.context' import { BookmarksList } from '@/layouts/bookmark/bookmarks' import { SearchLayout } from '@/layouts/search/search' @@ -21,7 +20,7 @@ import { TodosLayout } from './todos/todos' import { NotesLayout } from './notes/notes.layout' import { WidgetContainer } from './widget-container' import { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' -import { type WidgetDefinition, WidgetKeys, type WidgetSize } from './layout-engine/types' +import { type WidgetDefinition, WidgetKeys } from './layout-engine/types' export const WIDGET_DEFINITIONS: Record = { [WidgetKeys.search]: { @@ -102,6 +101,7 @@ export const WIDGET_DEFINITIONS: Record = { allowedSizes: [ { w: 1, h: 1 }, { w: 2, h: 1 }, + { w: 2, h: 3 }, ], defaultSize: { w: 2, h: 1 }, settingsTab: WidgetTabKeys.Pet, @@ -140,6 +140,8 @@ export const WIDGET_DEFINITIONS: Record = { allowedSizes: [ { w: 1, h: 1 }, { w: 2, h: 1 }, + { w: 2, h: 2 }, + { w: 2, h: 3 }, { w: 4, h: 1 }, ], defaultSize: { w: 2, h: 1 }, @@ -160,6 +162,7 @@ export const WIDGET_DEFINITIONS: Record = { { w: 1, h: 1 }, { w: 2, h: 1 }, { w: 2, h: 2 }, + { w: 2, h: 3 }, { w: 4, h: 1 }, ], defaultSize: { w: 2, h: 2 }, @@ -183,7 +186,7 @@ export const WIDGET_DEFINITIONS: Record = { { w: 2, h: 3 }, { w: 4, h: 2 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, canDuplicate: true, preview: () => (
    @@ -207,10 +210,11 @@ export const WIDGET_DEFINITIONS: Record = { { w: 1, h: 1 }, { w: 2, h: 1 }, { w: 2, h: 2 }, + { w: 2, h: 3 }, { w: 4, h: 1 }, { w: 4, h: 2 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, settingsTab: WidgetTabKeys.weather_settings, canDuplicate: true, preview: () => ( @@ -227,9 +231,10 @@ export const WIDGET_DEFINITIONS: Record = { emoji: '🔗', allowedSizes: [ { w: 2, h: 2 }, + { w: 2, h: 3 }, { w: 4, h: 2 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, canDuplicate: true, preview: () => (
    @@ -258,7 +263,7 @@ export const WIDGET_DEFINITIONS: Record = { { w: 2, h: 3 }, { w: 4, h: 2 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, canDuplicate: true, preview: () => (
    @@ -278,9 +283,10 @@ export const WIDGET_DEFINITIONS: Record = { allowedSizes: [ { w: 2, h: 1 }, { w: 2, h: 2 }, + { w: 2, h: 3 }, { w: 4, h: 1 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, canDuplicate: true, preview: () => (
    @@ -303,7 +309,7 @@ export const WIDGET_DEFINITIONS: Record = { { w: 4, h: 1 }, { w: 4, h: 2 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, settingsTab: WidgetTabKeys.wigiArz, canDuplicate: true, preview: () => ( @@ -331,10 +337,11 @@ export const WIDGET_DEFINITIONS: Record = { allowedSizes: [ { w: 2, h: 1 }, { w: 2, h: 2 }, + { w: 2, h: 3 }, { w: 4, h: 1 }, { w: 4, h: 2 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, settingsTab: WidgetTabKeys.news_settings, canDuplicate: true, preview: () => ( @@ -355,9 +362,10 @@ export const WIDGET_DEFINITIONS: Record = { { w: 1, h: 1 }, { w: 2, h: 1 }, { w: 2, h: 2 }, + { w: 2, h: 3 }, { w: 4, h: 1 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, canDuplicate: true, preview: () => (
    @@ -384,7 +392,7 @@ export const WIDGET_DEFINITIONS: Record = { { w: 4, h: 1 }, { w: 4, h: 2 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, canDuplicate: true, preview: () => (
    @@ -412,7 +420,7 @@ export const WIDGET_DEFINITIONS: Record = { { w: 2, h: 2 }, { w: 2, h: 3 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, canDuplicate: true, preview: () => (
    @@ -442,7 +450,7 @@ export const WIDGET_DEFINITIONS: Record = { { w: 2, h: 3 }, { w: 4, h: 2 }, ], - defaultSize: { w: 2, h: 2 }, + defaultSize: { w: 2, h: 3 }, canDuplicate: true, preview: () => (
    @@ -460,18 +468,4 @@ export const WIDGET_DEFINITIONS: Record = { ), }, - [WidgetKeys.youtube]: { - id: WidgetKeys.youtube, - label: 'یوتیوب', - emoji: '▶️', - allowedSizes: [{ w: 2, h: 2 }], - defaultSize: { w: 2, h: 2 }, - canDuplicate: false, - preview: () => ( -
    - ▶️ -
    - ), - node: () => null, - }, } From 4d7698500d7e802556dfa20e21b3127f21c96d58 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Wed, 19 Aug 2026 23:00:02 +0330 Subject: [PATCH 007/260] fix --- src/context/appearance.context.tsx | 11 +- src/context/widget-visibility.context.tsx | 59 +++-- src/layouts/search/search.tsx | 12 +- .../appearance/components/ui-selector.tsx | 27 ++- src/layouts/simplify/wigipad-simplify.tsx | 4 +- .../widgetify-card/widgetify.layout.tsx | 2 +- .../widgets/wigi-pad/wigi-pad.layout.tsx | 2 +- src/pages/home/home.page.tsx | 18 +- src/pages/home/ui/content-section.tsx | 222 ++++++++++++++++-- src/pages/home/ui/home-content-custom.tsx | 31 +++ src/pages/home/ui/home-content-simplify.tsx | 8 +- 11 files changed, 333 insertions(+), 63 deletions(-) create mode 100644 src/pages/home/ui/home-content-custom.tsx diff --git a/src/context/appearance.context.tsx b/src/context/appearance.context.tsx index e21b29508..7eb863e7a 100644 --- a/src/context/appearance.context.tsx +++ b/src/context/appearance.context.tsx @@ -2,7 +2,10 @@ import type React from 'react' import { createContext, useContext, useEffect, useState } from 'react' import Analytics from '@/analytics' import { getMultipleFromStorage, setToStorage } from '@/common/storage' -import { useChangeFont, useChangeUI } from '@/services/hooks/extension/update-setting.hook' +import { + useChangeFont, + useChangeUI, +} from '@/services/hooks/extension/update-setting.hook' import { useAuth } from './auth.context' import { safeAwait } from '@/services/api' import { showToast } from '@/common/toast' @@ -10,8 +13,10 @@ import { translateError } from '@/common/utils/translate-error' import { listenEvent } from '@/common/utils/call-event' export enum UI { - SIMPLE = 'SIMPLE', + DEFAULT = 'ADVANCED', ADVANCED = 'ADVANCED', + SIMPLE = 'SIMPLE', + CUSTOM = 'CUSTOM', } export interface AppearanceData { fontFamily: string @@ -39,7 +44,7 @@ interface AppearanceContextContextType extends AppearanceData { const DEFAULT_SETTINGS: AppearanceData = { fontFamily: 'Vazir', - ui: UI.ADVANCED, + ui: UI.DEFAULT, contentAlignment: 'top', } diff --git a/src/context/widget-visibility.context.tsx b/src/context/widget-visibility.context.tsx index 66d8c3afc..0d054f83c 100644 --- a/src/context/widget-visibility.context.tsx +++ b/src/context/widget-visibility.context.tsx @@ -62,7 +62,7 @@ export const widgetItems: WidgetItem[] = [ emoji: '📅', label: 'تقویم', order: 0, - node: , + node: , canToggle: true, popular: true, }, @@ -70,8 +70,8 @@ export const widgetItems: WidgetItem[] = [ id: WidgetKeys.yadKar, emoji: '📒', label: 'یادکار (وظایف/یادداشت/عادت‌ها)', - order: 0, - node: , + order: 1, + node: , canToggle: true, isNew: false, }, @@ -79,17 +79,16 @@ export const widgetItems: WidgetItem[] = [ id: WidgetKeys.tools, emoji: '🧰', label: 'ابزارها', - order: 1, - node: , + order: 2, + node: , canToggle: true, }, - { id: WidgetKeys.weather, emoji: '🌤️', label: 'آب و هوا', order: 3, - node: , + node: , canToggle: true, }, { @@ -99,7 +98,7 @@ export const widgetItems: WidgetItem[] = [ order: 4, node: ( - + ), canToggle: true, @@ -112,7 +111,7 @@ export const widgetItems: WidgetItem[] = [ order: 5, node: ( - + ), canToggle: true, @@ -122,16 +121,15 @@ export const widgetItems: WidgetItem[] = [ emoji: '📰', label: 'ویجی نیوز', order: 6, - node: , + node: , canToggle: true, }, - { id: WidgetKeys.network, emoji: '🌐', label: 'شبکه', - order: 9, - node: , + order: 7, + node: , canToggle: true, isNew: false, }, @@ -139,8 +137,8 @@ export const widgetItems: WidgetItem[] = [ id: WidgetKeys.HabitTracker, emoji: '🎯', label: 'عادات', - order: 10, - node: , + order: 8, + node: , canToggle: true, isNew: true, isBeta: true, @@ -156,9 +154,9 @@ interface WidgetVisibilityContextType { const defaultVisibility: WidgetKeys[] = [ WidgetKeys.calendar, - WidgetKeys.comboWidget, WidgetKeys.yadKar, WidgetKeys.tools, + WidgetKeys.comboWidget, ] export const MAX_VISIBLE_WIDGETS = 5 @@ -194,7 +192,11 @@ export function WidgetVisibilityProvider({ children }: { children: ReactNode }) useEffect(() => { async function loadSettings() { const storedVisibility = await getFromStorage('activeWidgets') - if (storedVisibility) { + if ( + storedVisibility && + Array.isArray(storedVisibility) && + storedVisibility.length > 0 + ) { const visibilityIds = storedVisibility .filter((item) => widgetItems.some((w) => w.id === item.id)) .map((item: any) => item.id as WidgetKeys) @@ -212,17 +214,22 @@ export function WidgetVisibilityProvider({ children }: { children: ReactNode }) saveActiveWidgets() } - setVisibility(visibilityIds) + if (visibilityIds.length > 0) { + setVisibility(visibilityIds) - const orders: Record = {} as Record< - WidgetKeys, - number - > - for (const item of storedVisibility) { - orders[item.id as WidgetKeys] = - item.order ?? getDefaultWidgetOrders()[item.id as WidgetKeys] + const orders: Record = {} as Record< + WidgetKeys, + number + > + for (const item of storedVisibility) { + orders[item.id as WidgetKeys] = + item.order ?? getDefaultWidgetOrders()[item.id as WidgetKeys] + } + setWidgetOrders(orders) + } else { + setVisibility(defaultVisibility) + setWidgetOrders(getDefaultWidgetOrders()) } - setWidgetOrders(orders) } else { setVisibility(defaultVisibility) setWidgetOrders(getDefaultWidgetOrders()) diff --git a/src/layouts/search/search.tsx b/src/layouts/search/search.tsx index 4664f75e4..b1dd1de1b 100644 --- a/src/layouts/search/search.tsx +++ b/src/layouts/search/search.tsx @@ -163,7 +163,9 @@ export function SearchLayout({ size }: SearchLayoutProps = {}) { } return ( -
    +
    - setActivePortal('image')} /> - setActivePortal('voice')} /> + setActivePortal('image')} + /> + setActivePortal('voice')} + />
    )}
    -
    +
    onClick(UI.ADVANCED)} + isActive={ui === UI.DEFAULT || (ui as string) === 'ADVANCED'} + onClick={() => onClick(UI.DEFAULT)} label={
    - پیشفرض + پیش‌فرض
    } - description="همه‌چیز جلوی چشمته؛ ابزارها، ویجت‌ها و آزادی کامل برای چیدمان." + description="چیدمان استاندارد، منظم و یکپارچه ویجت‌ها." /> + onClick(UI.CUSTOM)} + label={ +
    + + شخصی‌سازی +
    + } + description="چیدمان آزادانه روی بوم با تغییر سایز و جابجایی ویجت‌ها." + />
    diff --git a/src/layouts/simplify/wigipad-simplify.tsx b/src/layouts/simplify/wigipad-simplify.tsx index 283ea68d8..eca6589f5 100644 --- a/src/layouts/simplify/wigipad-simplify.tsx +++ b/src/layouts/simplify/wigipad-simplify.tsx @@ -5,13 +5,13 @@ import { RenderWigiPadItem } from '../widgets/wigi-pad/info-panel/components/ann import { DateDisplay } from '../widgets/wigi-pad/date-display/date.display' import { ClockDisplay } from '../widgets/wigi-pad/clock-display/clock-display' -export function SimplifyYadkar() { +export function SimplifyWigipad() { const { data: fetchedData } = useGetNotifications() const hasBanner = !!fetchedData?.wigipadBanner return ( - +
    {hasBanner ? ( <> diff --git a/src/layouts/widgetify-card/widgetify.layout.tsx b/src/layouts/widgetify-card/widgetify.layout.tsx index fa12732ae..9c93f8878 100644 --- a/src/layouts/widgetify-card/widgetify.layout.tsx +++ b/src/layouts/widgetify-card/widgetify.layout.tsx @@ -11,7 +11,7 @@ interface WidgetifyLayoutProps { export const WidgetifyLayout: React.FC = ({ size }) => { return ( - +
    { diff --git a/src/layouts/widgets/wigi-pad/wigi-pad.layout.tsx b/src/layouts/widgets/wigi-pad/wigi-pad.layout.tsx index 55043e76a..c19980244 100644 --- a/src/layouts/widgets/wigi-pad/wigi-pad.layout.tsx +++ b/src/layouts/widgets/wigi-pad/wigi-pad.layout.tsx @@ -14,7 +14,7 @@ export function WigiPadWidget({ size }: WigiPadWidgetProps = {}) { const { data: fetchedData } = useGetNotifications() return ( - +
    diff --git a/src/pages/home/home.page.tsx b/src/pages/home/home.page.tsx index 790f780d0..67cf934a1 100644 --- a/src/pages/home/home.page.tsx +++ b/src/pages/home/home.page.tsx @@ -1,6 +1,7 @@ -import { useAppearanceSetting } from '@/context/appearance.context' +import { UI, useAppearanceSetting } from '@/context/appearance.context' import { ContentSection } from './ui/content-section' import { HomeContentSimplify } from './ui/home-content-simplify' +import { HomeContentCustom } from './ui/home-content-custom' import { getFromStorage, setToStorage } from '@/common/storage' import { ConfigKey } from '@/common/constant/config.key' import { ExtensionInstalledModal } from '@/components/extension-installed-modal' @@ -122,9 +123,22 @@ export function HomePage() { displayModalIfNeeded() }, []) + const renderContent = () => { + switch (ui) { + case UI.SIMPLE: + return + case UI.CUSTOM: + return + case UI.DEFAULT: + case 'ADVANCED' as any: + default: + return + } + } + return ( <> - {ui === 'ADVANCED' ? : } + {renderContent()} {showWelcomeModal && ( { + const target = event.target as HTMLElement + const isInput = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.contentEditable === 'true' || + target.closest('input, textarea, [contenteditable="true"]') || + target.closest('button, select, a') + + if (isInput) { + return + } + + if (listeners?.onPointerDown) { + listeners.onPointerDown(event) + } + }, + } + + return ( +
    + {widget.node} +
    + ) +} + export function ContentSection() { - const { contentAlignment } = useAppearanceSetting() + const { canReOrderWidget, contentAlignment, ui } = useAppearanceSetting() + + const { getSortedWidgets, reorderWidgets } = useWidgetVisibility() + const sortedWidgets = getSortedWidgets().filter((widget) => !widget.disabled) + + const totalWidgetCount = sortedWidgets.length + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ) + + const handleDragEnd = (event: DragEndEvent) => { + if (!canReOrderWidget) return + const { active, over } = event + + if (!over || active.id === over.id) { + return + } + + const oldIndex = sortedWidgets.findIndex((item) => item.id === active.id) + const newIndex = sortedWidgets.findIndex((item) => item.id === over.id) + + if (oldIndex !== -1 && newIndex !== -1) { + reorderWidgets(oldIndex, newIndex) + } + + Analytics.event('widget_reorder') + } + + let layoutClasses = + 'grid w-full grid-cols-1 gap-2 transition-all duration-300 md:grid-cols-2 lg:grid-cols-4 md:gap-4' + if (totalWidgetCount === 2) { + layoutClasses = + 'flex flex-col flex-wrap w-full gap-2 lg:flex-nowrap md:flex-row md:gap-4 justify-between transition-all duration-300 items-center' + } + + if (ui === UI.CUSTOM) { + return ( + + +
    +
    +
    + +
    +
    +
    +
    +
    + ) + } return ( - +
    -
    -
    - +
    +
    + +
    + +
    + + + + +
    + +
    +
    + + {ui !== UI.SIMPLE && sortedWidgets.length > 0 && ( +
    + + widget.id)} + strategy={rectSortingStrategy} + > +
    + {sortedWidgets.map((widget) => { + if (totalWidgetCount === 2) { + return ( +
    + {canReOrderWidget ? ( + + ) : ( + widget.node + )} +
    + ) + } + return ( +
    + {canReOrderWidget ? ( + + ) : ( + widget.node + )} +
    + ) + })} +
    +
    +
    +
    + )}
    - +
    ) } diff --git a/src/pages/home/ui/home-content-custom.tsx b/src/pages/home/ui/home-content-custom.tsx new file mode 100644 index 000000000..101330710 --- /dev/null +++ b/src/pages/home/ui/home-content-custom.tsx @@ -0,0 +1,31 @@ +import { DateProvider } from '@/context/date.context' +import { FreeWidgetProvider } from '@/context/free-widget.context' +import { useAppearanceSetting } from '@/context/appearance.context' +import { FreeWidgetCanvas } from '@/layouts/widgets/canvas/free-widget-canvas' + +export function HomeContentCustom() { + const { contentAlignment } = useAppearanceSetting() + + return ( + + +
    +
    +
    + +
    +
    +
    +
    +
    + ) +} diff --git a/src/pages/home/ui/home-content-simplify.tsx b/src/pages/home/ui/home-content-simplify.tsx index fbacf38e3..4f4b2e6e6 100644 --- a/src/pages/home/ui/home-content-simplify.tsx +++ b/src/pages/home/ui/home-content-simplify.tsx @@ -4,7 +4,7 @@ import { BookmarksList } from '@/layouts/bookmark/bookmarks' import { BookmarkProvider } from '@/layouts/bookmark/context/bookmark.context' import { SearchLayout } from '@/layouts/search/search' import { SimpleTools } from '@/layouts/simplify/tools-simplify' -import { SimplifyYadkar } from '@/layouts/simplify/wigipad-simplify' +import { SimplifyWigipad } from '@/layouts/simplify/wigipad-simplify' export function HomeContentSimplify() { return (
    @@ -14,7 +14,7 @@ export function HomeContentSimplify() {
    -
    +
    @@ -27,9 +27,9 @@ export function HomeContentSimplify() {
    -
    +
    - +
    From fb7534d7edbb21549bafce4e888d7e2cc99ace04 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Wed, 19 Aug 2026 23:01:17 +0330 Subject: [PATCH 008/260] remove drag/drop from advanced --- .../navbar/components/settings-dropdown.tsx | 32 +-- src/layouts/navbar/navbar.layout.tsx | 22 -- src/pages/home/ui/content-section.tsx | 193 +++--------------- 3 files changed, 29 insertions(+), 218 deletions(-) diff --git a/src/layouts/navbar/components/settings-dropdown.tsx b/src/layouts/navbar/components/settings-dropdown.tsx index f487d3061..d119d07e5 100644 --- a/src/layouts/navbar/components/settings-dropdown.tsx +++ b/src/layouts/navbar/components/settings-dropdown.tsx @@ -1,14 +1,13 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { callEvent } from '@/common/utils/call-event' import { UI, useAppearanceSetting } from '@/context/appearance.context' -import { showToast } from '@/common/toast' import { Icon } from '@/src/icons' import { useAuth } from '@/context/auth.context' import { Dropdown } from '@/components/ui' export const SettingsDropdown = () => { const { isAuthenticated } = useAuth() - const { canReOrderWidget, toggleCanReOrderWidget, ui, setUI } = useAppearanceSetting() + const { ui, setUI } = useAppearanceSetting() const triggerRef = useRef(null) const handleWidgetSettingsClick = useCallback(() => { @@ -21,15 +20,6 @@ export const SettingsDropdown = () => { callEvent('closeAllDropdowns') }, []) - const onClick = () => { - if (ui === UI.SIMPLE) { - showToast('در حالت ظاهری ساده، امکان تغییر و جابجایی ویجت ها نیست!', 'error') - return - } - toggleCanReOrderWidget() - callEvent('closeAllDropdowns') - } - const onClickToChangeUI = () => { setUI(ui === 'SIMPLE' ? UI.ADVANCED : UI.SIMPLE) } @@ -115,24 +105,6 @@ export const SettingsDropdown = () => {
    ) : null} - -
    onClick()} - > -
    - - {canReOrderWidget ? ( - غیرفعال‌سازی حالت جابجایی - ) : ( - حالت جابجایی ویجت ها - )} -
    -
    ) diff --git a/src/layouts/navbar/navbar.layout.tsx b/src/layouts/navbar/navbar.layout.tsx index 61d16eb2e..4baa62ad4 100644 --- a/src/layouts/navbar/navbar.layout.tsx +++ b/src/layouts/navbar/navbar.layout.tsx @@ -88,7 +88,6 @@ export function NavbarTabs() { } export function NavbarLayout(): JSX.Element { - const { canReOrderWidget, toggleCanReOrderWidget } = useAppearanceSetting() const [showSettings, setShowSettings] = useState(false) const [isVisible, setIsVisible] = useState(false) const { user } = useAuth() @@ -127,27 +126,6 @@ export function NavbarLayout(): JSX.Element { useBirthdayConfetti(user?.isBirthdayToday || false) return ( <> - {canReOrderWidget && ( -
    -
    -
    - - حالت جابجایی فعال، ویجت هارو جابجا کنید - -
    -
    -
    - )} - {!isVisible && (
    From 72171950784cffeb62dc46c1262bc4d4d44e2fc8 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Thu, 20 Aug 2026 02:07:27 +0330 Subject: [PATCH 009/260] sync with server --- .../ui/item-selector/item-selector.tsx | 13 +- src/components/ui/modal/modal.tsx | 2 + src/context/free-widget.context.tsx | 346 +++++++++++++----- src/context/widget-visibility.context.tsx | 174 ++++++--- src/index.css | 28 +- .../components/bookmark-icon.picker.tsx | 52 ++- .../components/bookmark-suggestions.tsx | 45 ++- .../components/modal/add-bookmark.modal.tsx | 67 ++-- src/layouts/bookmark/components/shared.tsx | 14 +- .../widgets/canvas/add-widget-modal.tsx | 4 +- .../widgets/canvas/canvas-context-menu.tsx | 16 - .../widgets/canvas/canvas-widget-outer.tsx | 19 +- .../widgets/canvas/free-widget-canvas.tsx | 115 +++--- src/layouts/widgets/layout-engine/types.ts | 2 + src/layouts/widgets/migration.ts | 31 +- .../hooks/widgets/widget-sync.hook.ts | 226 +++++++++--- 16 files changed, 753 insertions(+), 401 deletions(-) diff --git a/src/components/ui/item-selector/item-selector.tsx b/src/components/ui/item-selector/item-selector.tsx index 6621e0468..3a95f04b3 100644 --- a/src/components/ui/item-selector/item-selector.tsx +++ b/src/components/ui/item-selector/item-selector.tsx @@ -26,10 +26,11 @@ export function ItemSelector({ } return ( -
    {isActive && ( )}
    - {label} + {label}
    {description && ( -
    {description}
    +
    {description}
    )} -
    + ) } diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx index 8c9563ed9..3bc5c52ab 100644 --- a/src/components/ui/modal/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -81,11 +81,13 @@ export function Modal({ aria-labelledby={typeof title === 'string' ? title : 'modal-title'} aria-modal="true" onClick={() => closeOnBackdropClick && onClose()} + onContextMenu={(e) => e.stopPropagation()} className="flex items-center justify-center p-2 transition-opacity duration-200 opacity-100 modal modal-middle md:p-4" >
    e.stopPropagation()} + onContextMenu={(e) => e.stopPropagation()} className={cn(modalBoxClasses, 'animate-modal-in')} > {(title || showCloseButton) && ( diff --git a/src/context/free-widget.context.tsx b/src/context/free-widget.context.tsx index 8f2cb4cf1..91a1413c6 100644 --- a/src/context/free-widget.context.tsx +++ b/src/context/free-widget.context.tsx @@ -27,8 +27,15 @@ import type { } from '@/layouts/widgets/layout-engine/types' import { migrateWidgetLayoutIfNeeded } from '@/layouts/widgets/migration' import { WIDGET_DEFINITIONS } from '@/layouts/widgets/widget-registry' -import { createWidgetApi } from '@/services/hooks/widgets/widget-sync.hook' +import { + createUserWidgetApi, + deleteUserWidgetApi, + getUserWidgetsApi, + syncUserWidgetsApi, + updateUserWidgetApi, +} from '@/services/hooks/widgets/widget-sync.hook' import { useAppearance } from './appearance.context' +import { useAuth } from './auth.context' interface FreeWidgetContextType { savedLayout: StoredWidget[] @@ -38,35 +45,39 @@ interface FreeWidgetContextType { cellHeight: number gap: number isListFallback: boolean + isLoaded: boolean canvasMode: 'normal' | 'edit' selectedInstanceId: string | null setCanvasMode: (mode: 'normal' | 'edit') => void setSelectedInstanceId: (id: string | null) => void resizeWidget: (instanceId: string, newSize: WidgetSize) => boolean moveWidget: (instanceId: string, targetPosition: WidgetPosition) => boolean - addWidget: (id: string, targetPosition?: WidgetPosition) => boolean - duplicateWidget: (instanceId: string) => boolean + addWidget: (id: string, targetPosition?: WidgetPosition) => Promise + duplicateWidget: (instanceId: string) => Promise removeWidget: (instanceId: string) => boolean - resetToDefaultLayout: () => void + updateWidgetSettings: (instanceId: string, meta: any) => void updateContainerWidth: (containerWidth: number) => void } const FreeWidgetContext = createContext(null) export function FreeWidgetProvider({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuth() const { canvasMode, setCanvasMode, selectedInstanceId, setSelectedInstanceId } = useAppearance() - const [savedLayout, setSavedLayout] = useState(DEFAULT_WIDGET_LAYOUT) - const [runtimeLayout, setRuntimeLayout] = - useState(DEFAULT_WIDGET_LAYOUT) + const [isLoaded, setIsLoaded] = useState(false) + const [savedLayout, setSavedLayout] = useState([]) + const [runtimeLayout, setRuntimeLayout] = useState([]) const [cols, setCols] = useState(DEFAULT_COLS) const [cellHeight, setCellHeight] = useState(DEFAULT_CELL_HEIGHT) const [gap, setGap] = useState(DEFAULT_GAP) const [cellWidth, setCellWidth] = useState(100) const [isListFallback, setIsListFallback] = useState(false) - const isInitialized = useRef(false) + const colsRef = useRef(DEFAULT_COLS) const containerWidthRef = useRef(1200) + const syncTimerRef = useRef(null) + const hasFetchedServerRef = useRef(false) const persistLayout = useCallback((layoutToPersist: StoredWidget[]) => { setToStorage('storedWidgets', layoutToPersist) @@ -107,6 +118,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) const fallback = computedCellWidth < MIN_CELL_WIDTH + colsRef.current = matched.cols setCols(matched.cols) setCellHeight(matched.cellHeight) setGap(matched.gap) @@ -127,23 +139,133 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) ) useEffect(() => { - async function init() { + async function loadFromLocalStorage() { try { - const migrated = await migrateWidgetLayoutIfNeeded() - setSavedLayout(migrated) - const reflowed = reflowForColumns(migrated, cols) + const localLayout = await migrateWidgetLayoutIfNeeded() + setSavedLayout(localLayout) + const reflowed = reflowForColumns(localLayout, colsRef.current) setRuntimeLayout(reflowed) } catch (err) { - console.error('Failed to load widget layout', err) + console.error('Failed to load local widget layout', err) setSavedLayout(DEFAULT_WIDGET_LAYOUT) setRuntimeLayout(DEFAULT_WIDGET_LAYOUT) } finally { - isInitialized.current = true + setIsLoaded(true) } } - init() - }, []) + loadFromLocalStorage() + }, [reflowForColumns]) + + useEffect(() => { + if (!isAuthenticated || hasFetchedServerRef.current) return + hasFetchedServerRef.current = true + + async function fetchAndReconcileWithServer() { + try { + const serverWidgets = await getUserWidgetsApi('CUSTOM', 'HOME') + + if (serverWidgets && 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, + })) + + setSavedLayout(fromSrv) + const reflowed = reflowForColumns(fromSrv, colsRef.current) + setRuntimeLayout(reflowed) + persistLayout(fromSrv) + } else { + const localLayout = await migrateWidgetLayoutIfNeeded() + if (localLayout && localLayout.length > 0) { + const synced = await syncUserWidgetsApi({ + ui: 'CUSTOM', + workspace: 'HOME', + widgets: localLayout.map((w) => ({ + instanceId: + typeof w.instanceId === 'string' && + /^[0-9a-fA-F]{24}$/.test(w.instanceId) + ? w.instanceId + : undefined, + widgetKey: w.id, + col: w.position.col, + row: w.position.row, + width: w.size.w, + height: w.size.h, + meta: w.meta, + disabled: w.disabled ?? false, + })), + }) + + if (synced && synced.length > 0) { + setSavedLayout((prev) => { + const updated = prev.map((w, index) => { + const matching = + synced.find((s) => s.instanceId === w.instanceId) || + synced.find((s) => s.widgetKey === w.id) || + synced[index] + if ( + matching?.instanceId && + matching.instanceId !== w.instanceId + ) { + return { + ...w, + instanceId: matching.instanceId, + widgetId: matching.instanceId, + } + } + return w + }) + persistLayout(updated) + return updated + }) + } + } + } + } catch (err) { + console.error('Background widget fetch error', err) + } + } + + fetchAndReconcileWithServer() + }, [isAuthenticated, reflowForColumns, persistLayout]) + + const triggerServerSync = useCallback( + (currentLayout: StoredWidget[]) => { + if (!isAuthenticated) return + + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current) + } + + syncTimerRef.current = setTimeout(() => { + syncUserWidgetsApi({ + ui: 'CUSTOM', + workspace: 'HOME', + widgets: currentLayout.map((w) => ({ + instanceId: + typeof w.instanceId === 'string' && + /^[0-9a-fA-F]{24}$/.test(w.instanceId) + ? w.instanceId + : undefined, + widgetKey: w.id, + col: w.position.col, + row: w.position.row, + width: w.size.w, + height: w.size.h, + meta: w.meta, + disabled: w.disabled ?? false, + })), + }).catch(() => {}) + }, 1000) + }, + [isAuthenticated] + ) const commitMutation = useCallback( (operation: string, nextRuntime: StoredWidget[], targetInstanceId?: string) => { @@ -156,6 +278,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) if (cols >= DEFAULT_COLS) { setSavedLayout(nextRuntime) persistLayout(nextRuntime) + triggerServerSync(nextRuntime) } else { setSavedLayout((prevSaved) => { const updated = prevSaved.map((savedW) => { @@ -178,6 +301,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) const finalSaved = [...updated, ...newItems] persistLayout(finalSaved) + triggerServerSync(finalSaved) return finalSaved }) } @@ -187,7 +311,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) }) return true }, - [cols, persistLayout] + [cols, persistLayout, triggerServerSync] ) const resizeWidget = useCallback( @@ -230,7 +354,7 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) ) const addWidget = useCallback( - (id: string, targetPosition?: WidgetPosition): boolean => { + async (id: string, targetPosition?: WidgetPosition): Promise => { const def = WIDGET_DEFINITIONS[id as keyof typeof WIDGET_DEFINITIONS] if (!def) return false @@ -240,13 +364,29 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) return false } - const instanceId = `${id}-${Date.now().toString(36)}` + let finalInstanceId = `${id}-${Date.now().toString(36)}` + + if (isAuthenticated) { + const serverWidget = await createUserWidgetApi({ + widgetKey: def.id, + ui: 'CUSTOM', + workspace: 'HOME', + col: targetPosition?.col ?? 0, + row: targetPosition?.row ?? 0, + width: def.defaultSize.w, + height: def.defaultSize.h, + }) + if (serverWidget?.instanceId) { + finalInstanceId = serverWidget.instanceId + } + } + const newWidget: StoredWidget = { id: def.id, - instanceId, + instanceId: finalInstanceId, position: targetPosition || { col: 0, row: 0 }, size: def.defaultSize, - widgetId: `widget-${instanceId}`, + widgetId: finalInstanceId, } const result = resolveLayoutChange({ @@ -258,46 +398,25 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) }) if (!result) { + if ( + isAuthenticated && + typeof finalInstanceId === 'string' && + /^[0-9a-fA-F]{24}$/.test(finalInstanceId) + ) { + deleteUserWidgetApi(finalInstanceId).catch(() => {}) + } showToast('فضای کافی برای افزودن ویجت وجود ندارد', 'error') return false } - createWidgetApi({ - type: def.id, - position: newWidget.position, - size: newWidget.size, - clientInstanceId: instanceId, - }) - .then((serverWidget) => { - if (serverWidget?.id) { - setSavedLayout((prev) => { - const updated = prev.map((w) => - w.instanceId === instanceId - ? { ...w, widgetId: serverWidget.id } - : w - ) - persistLayout(updated) - return updated - }) - setRuntimeLayout((prev) => - prev.map((w) => - w.instanceId === instanceId - ? { ...w, widgetId: serverWidget.id } - : w - ) - ) - } - }) - .catch(() => {}) - showToast(`ویجت ${def.label} افزوده شد`, 'success') - return commitMutation('add', result, instanceId) + return commitMutation('add', result, finalInstanceId) }, - [runtimeLayout, cols, commitMutation, persistLayout] + [runtimeLayout, cols, commitMutation, isAuthenticated] ) const duplicateWidget = useCallback( - (instanceId: string): boolean => { + async (instanceId: string): Promise => { const original = runtimeLayout.find((w) => w.instanceId === instanceId) if (!original) return false @@ -307,59 +426,79 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) return false } + let newInstanceId = `${original.id}-${Date.now().toString(36)}` + + if (isAuthenticated) { + const serverWidget = await createUserWidgetApi({ + widgetKey: def.id, + ui: 'CUSTOM', + workspace: 'HOME', + col: original.position.col, + row: original.position.row, + width: original.size.w, + height: original.size.h, + }) + if (serverWidget?.instanceId) { + newInstanceId = serverWidget.instanceId + } + } + + const newWidget: StoredWidget = { + id: original.id, + instanceId: newInstanceId, + position: original.position, + size: original.size, + widgetId: newInstanceId, + } + const result = resolveLayoutChange({ layout: runtimeLayout, operation: 'duplicate', instanceId, + newWidget, cols, }) if (!result) { + if ( + isAuthenticated && + typeof newInstanceId === 'string' && + /^[0-9a-fA-F]{24}$/.test(newInstanceId) + ) { + deleteUserWidgetApi(newInstanceId).catch(() => {}) + } showToast('فضای کافی برای تکرار ویجت وجود ندارد', 'error') return false } - const duplicated = result.find( - (w) => - w.id === original.id && - w.instanceId !== original.instanceId && - !runtimeLayout.some((r) => r.instanceId === w.instanceId) + showToast('ویجت با موفقیت تکرار شد', 'success') + return commitMutation('duplicate', result, newInstanceId) + }, + [runtimeLayout, cols, commitMutation, isAuthenticated] + ) + + const updateWidgetSettings = useCallback( + (instanceId: string, meta: any) => { + setSavedLayout((prev) => { + const updated = prev.map((w) => + w.instanceId === instanceId ? { ...w, meta } : w + ) + persistLayout(updated) + return updated + }) + setRuntimeLayout((prev) => + prev.map((w) => (w.instanceId === instanceId ? { ...w, meta } : w)) ) - if (duplicated) { - createWidgetApi({ - type: duplicated.id, - position: duplicated.position, - size: duplicated.size, - clientInstanceId: duplicated.instanceId, - }) - .then((serverWidget) => { - if (serverWidget?.id) { - setSavedLayout((prev) => { - const updated = prev.map((w) => - w.instanceId === duplicated.instanceId - ? { ...w, widgetId: serverWidget.id } - : w - ) - persistLayout(updated) - return updated - }) - setRuntimeLayout((prev) => - prev.map((w) => - w.instanceId === duplicated.instanceId - ? { ...w, widgetId: serverWidget.id } - : w - ) - ) - } - }) - .catch(() => {}) + if ( + isAuthenticated && + typeof instanceId === 'string' && + /^[0-9a-fA-F]{24}$/.test(instanceId) + ) { + updateUserWidgetApi(instanceId, { meta }).catch(() => {}) } - - showToast('ویجت با موفقیت تکرار شد', 'success') - return commitMutation('duplicate', result, instanceId) }, - [runtimeLayout, cols, commitMutation, persistLayout] + [isAuthenticated, persistLayout] ) const removeWidget = useCallback( @@ -379,21 +518,27 @@ export function FreeWidgetProvider({ children }: { children: React.ReactNode }) setSelectedInstanceId(null) } + if ( + isAuthenticated && + typeof instanceId === 'string' && + /^[0-9a-fA-F]{24}$/.test(instanceId) + ) { + deleteUserWidgetApi(instanceId).catch(() => {}) + } + showToast('ویجت حذف شد', 'success') return commitMutation('remove', result, instanceId) }, - [runtimeLayout, cols, selectedInstanceId, setSelectedInstanceId, commitMutation] + [ + runtimeLayout, + cols, + selectedInstanceId, + setSelectedInstanceId, + commitMutation, + isAuthenticated, + ] ) - const resetToDefaultLayout = useCallback(() => { - setSavedLayout(DEFAULT_WIDGET_LAYOUT) - const reflowed = reflowForColumns(DEFAULT_WIDGET_LAYOUT, cols) - setRuntimeLayout(reflowed) - persistLayout(DEFAULT_WIDGET_LAYOUT) - setSelectedInstanceId(null) - showToast('چیدمان پیش‌فرض بازیابی شد', 'success') - }, [cols, reflowForColumns, persistLayout, setSelectedInstanceId]) - return ( diff --git a/src/context/widget-visibility.context.tsx b/src/context/widget-visibility.context.tsx index 0d054f83c..093c0ed4e 100644 --- a/src/context/widget-visibility.context.tsx +++ b/src/context/widget-visibility.context.tsx @@ -8,18 +8,22 @@ import { } from 'react' import Analytics from '@/analytics' import { getFromStorage, setToStorage } from '@/common/storage' +import { showToast } from '@/common/toast' import CalendarLayout from '@/layouts/widgets/calendar/calendar' import { ComboWidget } from '@/layouts/widgets/combo-widget/combo-widget.layout' +import { HabitsLayout } from '@/layouts/widgets/habit/habits.layout' import { NetworkLayout } from '@/layouts/widgets/network/network.layout' import { NewsLayout } from '@/layouts/widgets/news/news.layout' import { ToolsLayout } from '@/layouts/widgets/tools/tools.layout' import { WeatherLayout } from '@/layouts/widgets/weather/weather.layout' import { WigiArzLayout } from '@/layouts/widgets/wigi-arz/wigi_arz.layout' +import { YadkarWidget } from '@/layouts/widgets/yadkar/yadkar' +import { + getUserWidgetsApi, + syncUserWidgetsApi, +} from '@/services/hooks/widgets/widget-sync.hook' import { useAuth } from './auth.context' import { CurrencyProvider } from './currency.context' -import { showToast } from '@/common/toast' -import { YadkarWidget } from '@/layouts/widgets/yadkar/yadkar' -import { HabitsLayout } from '@/layouts/widgets/habit/habits.layout' export enum WidgetKeys { comboWidget = 'comboWidget', @@ -129,7 +133,13 @@ export const widgetItems: WidgetItem[] = [ emoji: '🌐', label: 'شبکه', order: 7, - node: , + node: ( + + ), canToggle: true, isNew: false, }, @@ -160,9 +170,9 @@ const defaultVisibility: WidgetKeys[] = [ ] export const MAX_VISIBLE_WIDGETS = 5 -const WidgetVisibilityContext = createContext( - undefined -) +const WidgetVisibilityContext = createContext< + WidgetVisibilityContextType | undefined +>(undefined) const getDefaultWidgetOrders = (): Record => { const orders: Record = {} as Record @@ -172,79 +182,136 @@ const getDefaultWidgetOrders = (): Record => { return orders } -export function WidgetVisibilityProvider({ children }: { children: ReactNode }) { - const [visibility, setVisibility] = useState([]) +export function WidgetVisibilityProvider({ + children, +}: { children: ReactNode }) { + const [visibility, setVisibility] = useState(defaultVisibility) const [widgetOrders, setWidgetOrders] = useState>(getDefaultWidgetOrders) - const firstRender = useRef(true) + const syncTimerRef = useRef(null) + const hasFetchedServerRef = useRef(false) const { isAuthenticated } = useAuth() - const saveActiveWidgets = () => { + const saveActiveWidgets = (currentVisibility = visibility, currentOrders = widgetOrders) => { const activeWidgets = widgetItems - .filter((item) => visibility.includes(item.id)) + .filter((item) => currentVisibility.includes(item.id)) .map((item) => ({ ...item, - order: widgetOrders[item.id] ?? item.order, + order: currentOrders[item.id] ?? item.order, })) setToStorage('activeWidgets', activeWidgets) + + if (isAuthenticated) { + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current) + } + syncTimerRef.current = setTimeout(() => { + syncUserWidgetsApi({ + ui: 'ADVANCED', + workspace: 'HOME', + widgets: activeWidgets.map((w, index) => ({ + widgetKey: w.id, + order: w.order ?? index, + col: 0, + row: 0, + width: 2, + height: 3, + })), + }).catch(() => {}) + }, 1000) + } } useEffect(() => { - async function loadSettings() { - const storedVisibility = await getFromStorage('activeWidgets') - if ( - storedVisibility && - Array.isArray(storedVisibility) && - storedVisibility.length > 0 - ) { - const visibilityIds = storedVisibility - .filter((item) => widgetItems.some((w) => w.id === item.id)) - .map((item: any) => item.id as WidgetKeys) - + async function initActiveWidgets() { + try { + const storedVisibility = await getFromStorage('activeWidgets') if ( - visibilityIds.includes(WidgetKeys.todos) || - visibilityIds.includes(WidgetKeys.notes) + storedVisibility && + Array.isArray(storedVisibility) && + storedVisibility.length > 0 ) { - Analytics.event('yadkar_merged') - - visibilityIds.splice(visibilityIds.indexOf(WidgetKeys.todos), 1) - visibilityIds.splice(visibilityIds.indexOf(WidgetKeys.notes), 1) - - visibilityIds.push(WidgetKeys.yadKar) - saveActiveWidgets() - } + let visibilityIds = storedVisibility + .filter((item) => widgetItems.some((w) => w.id === item.id)) + .map((item: any) => item.id as WidgetKeys) - if (visibilityIds.length > 0) { - setVisibility(visibilityIds) + if ( + visibilityIds.includes(WidgetKeys.todos) || + visibilityIds.includes(WidgetKeys.notes) + ) { + Analytics.event('yadkar_merged') + visibilityIds = visibilityIds.filter( + (id) => id !== WidgetKeys.todos && id !== WidgetKeys.notes + ) + if (!visibilityIds.includes(WidgetKeys.yadKar)) { + visibilityIds.push(WidgetKeys.yadKar) + } + } - const orders: Record = {} as Record< - WidgetKeys, - number - > + const orders: Record = getDefaultWidgetOrders() for (const item of storedVisibility) { orders[item.id as WidgetKeys] = item.order ?? getDefaultWidgetOrders()[item.id as WidgetKeys] } - setWidgetOrders(orders) - } else { - setVisibility(defaultVisibility) - setWidgetOrders(getDefaultWidgetOrders()) + + if (visibilityIds.length > 0) { + setVisibility(visibilityIds) + setWidgetOrders(orders) + } } - } else { - setVisibility(defaultVisibility) - setWidgetOrders(getDefaultWidgetOrders()) + } catch (err) { + console.error('Failed to load local active widgets', err) } - firstRender.current = false } - loadSettings() + initActiveWidgets() }, []) useEffect(() => { - if (!firstRender.current) { - saveActiveWidgets() + if (!isAuthenticated || hasFetchedServerRef.current) return + hasFetchedServerRef.current = true + + let isCancelled = false + + async function fetchAndReconcileVisibility() { + try { + const serverWidgets = await getUserWidgetsApi('ADVANCED', 'HOME') + + if (isCancelled) return + + if (serverWidgets && serverWidgets.length > 0) { + const visibilityIds = serverWidgets + .map((sw) => sw.widgetKey as WidgetKeys) + .filter((k) => widgetItems.some((w) => w.id === k)) + + const orders: Record = getDefaultWidgetOrders() + for (const sw of serverWidgets) { + orders[sw.widgetKey as WidgetKeys] = sw.order ?? 0 + } + + if (visibilityIds.length > 0) { + setVisibility(visibilityIds) + setWidgetOrders(orders) + const activeWidgets = widgetItems + .filter((item) => visibilityIds.includes(item.id)) + .map((item) => ({ + ...item, + order: orders[item.id] ?? item.order, + })) + setToStorage('activeWidgets', activeWidgets) + } + } + } catch (err) { + console.error('Background visibility fetch error', err) + } } - }, [visibility, widgetOrders]) + + fetchAndReconcileVisibility() + + return () => { + isCancelled = true + } + }, [isAuthenticated]) const toggleWidget = (widgetId: WidgetKeys) => { setVisibility((prev) => { @@ -269,6 +336,8 @@ export function WidgetVisibilityProvider({ children }: { children: ReactNode }) } else { Analytics.event(`widget_add_${widgetId}`) } + + saveActiveWidgets(newVisibility, widgetOrders) return newVisibility }) } @@ -289,6 +358,7 @@ export function WidgetVisibilityProvider({ children }: { children: ReactNode }) newOrders[widget.id] = index }) + saveActiveWidgets(visibility, newOrders) return newOrders }) } diff --git a/src/index.css b/src/index.css index ded09205c..668302dbb 100644 --- a/src/index.css +++ b/src/index.css @@ -258,21 +258,35 @@ img { @keyframes widget-wiggle { 0% { - transform: rotate(0deg); + transform: rotate(-0.35deg); } - 25% { - transform: rotate(-1.2deg); + 100% { + transform: rotate(0.35deg); } - 75% { - transform: rotate(1.2deg); +} + +@keyframes widget-wiggle-alt { + 0% { + transform: rotate(0.3deg); } 100% { - transform: rotate(0deg); + transform: rotate(-0.3deg); } } .animate-widget-wiggle { - animation: widget-wiggle 0.28s ease-in-out infinite; + animation: widget-wiggle 0.46s ease-in-out infinite alternate; + transform-origin: 50% 10%; +} + +.animate-widget-wiggle:nth-child(2n) { + animation: widget-wiggle-alt 0.48s ease-in-out infinite alternate; + animation-delay: -0.15s; +} + +.animate-widget-wiggle:nth-child(3n) { + animation: widget-wiggle 0.52s ease-in-out infinite alternate; + animation-delay: -0.3s; } .widget-canvas-item-transition { diff --git a/src/layouts/bookmark/components/bookmark-icon.picker.tsx b/src/layouts/bookmark/components/bookmark-icon.picker.tsx index 0a3468a61..0e02e2028 100644 --- a/src/layouts/bookmark/components/bookmark-icon.picker.tsx +++ b/src/layouts/bookmark/components/bookmark-icon.picker.tsx @@ -15,18 +15,25 @@ export function BookmarkIconPicker({ value, url, onChange }: Props) { const [isDragging, setIsDragging] = useState(false) const [error, setError] = useState(false) - const openPicker = () => { - fileInputRef.current?.click() + const openPicker = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (fileInputRef.current) { + fileInputRef.current.value = '' + fileInputRef.current.click() + } } const handleFile = (file?: File) => { if (!file || !file.type.startsWith('image/')) - return showToast('فرمت نامعتبر', 'error') + return showToast('فرمت نامعتبر است', 'error') + setError(false) onChange(file) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() + e.stopPropagation() setIsDragging(false) handleFile(e.dataTransfer.files?.[0]) } @@ -36,9 +43,13 @@ export function BookmarkIconPicker({ value, url, onChange }: Props) { } const handleRemove = (e: React.MouseEvent) => { + e.preventDefault() e.stopPropagation() onChange(null) setError(false) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } } const isFile = value instanceof File @@ -46,7 +57,7 @@ export function BookmarkIconPicker({ value, url, onChange }: Props) { if (value && isFile) { iconSrc = URL.createObjectURL(value) } else { - if (value) { + if (typeof value === 'string' && value) { iconSrc = value } else if (url && url !== 'null') { iconSrc = getFaviconFromUrl(url || '') @@ -58,12 +69,13 @@ export function BookmarkIconPicker({ value, url, onChange }: Props) { -
    { e.preventDefault() @@ -71,35 +83,41 @@ export function BookmarkIconPicker({ value, url, onChange }: Props) { }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} - className={`relative w-12 h-10 flex items-center justify-center cursor-pointer border-2 rounded-xl transition - ${isDragging ? 'border-primary' : 'border-base-300'} - `} + className={`relative w-12 h-11 shrink-0 flex items-center justify-center cursor-pointer border-2 rounded-xl transition-all ${ + isDragging + ? 'border-primary bg-primary/10' + : 'border-base-content/15 hover:border-primary/40 bg-base-200/80' + }`} + title="انتخاب آیکون" > {iconSrc && !error ? ( icon setError(true)} /> ) : ( - + )} -
    - +
    +
    {isFile && ( )} -
    + ) } diff --git a/src/layouts/bookmark/components/bookmark-suggestions.tsx b/src/layouts/bookmark/components/bookmark-suggestions.tsx index 4206b1b85..d1072eed1 100644 --- a/src/layouts/bookmark/components/bookmark-suggestions.tsx +++ b/src/layouts/bookmark/components/bookmark-suggestions.tsx @@ -3,6 +3,7 @@ import { type BookmarkSuggestion, useGetSuggestedBookmarks, } from '@/services/hooks/bookmark/get-bookmarks.hook' +import { Icon } from '@/src/icons' interface BookmarkSuggestionsProps { onSelect: (suggestion: BookmarkSuggestion) => void @@ -18,31 +19,37 @@ export function BookmarkSuggestions({ onSelect }: BookmarkSuggestionsProps) { return (
    -
    +
    {suggestions.map((suggestion, index) => ( -
    onSelect(suggestion)} - className="p-1 flex flex-col items-center justify-center text-center transition-colors duration-200 bg-content hover:!bg-base-300/75 h-12 border border-base-300/40 rounded-xl cursor-pointer" + type="button" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + onSelect(suggestion) + }} + className="p-1.5 flex flex-col items-center justify-center text-center transition-all duration-200 bg-base-200 hover:bg-primary/10 hover:border-primary/30 h-14 border border-base-content/10 rounded-xl cursor-pointer" > -
    - {suggestion.title} { - const target = e.target as HTMLImageElement - target.style.display = 'none' - target.nextElementSibling?.classList.remove( - 'hidden' - ) - }} - /> +
    + {suggestion.icon ? ( + {suggestion.title} { + const target = e.target as HTMLImageElement + target.style.display = 'none' + }} + /> + ) : ( + + )}
    -

    +

    {suggestion.title}

    -
    + ))}
    diff --git a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx index ccc8ab4f4..d8dba8e88 100644 --- a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx @@ -8,6 +8,7 @@ import { ShowAdvancedButton, TypeSelector } from '../shared' import { AdvancedModal } from './advanced.modal' import { useIsMutating } from '@tanstack/react-query' import { BookmarkIconPicker } from '../bookmark-icon.picker' +import type { BookmarkSuggestion } from '@/services/hooks/bookmark/get-bookmarks.hook' interface AddBookmarkModalProps { isOpen: boolean @@ -75,25 +76,31 @@ export function AddBookmarkModal({ setFormData((prev) => ({ ...prev, [key]: value })) } + const handleTypeChange = (newType: BookmarkType) => { + setType(newType) + updateFormData('type', newType) + } + const handleUrlChange = (value: string) => { const newUrl = value.trim() - updateFormData('url', newUrl) - - updateFormData('icon', null) - + let hostName = '' if (formData.title.trim() === '' && newUrl !== '') { - let hostName = '' try { - hostName = new URL(newUrl).hostname + hostName = new URL(newUrl.startsWith('http') ? newUrl : `https://${newUrl}`).hostname if (hostName && hostName.split('.').length > 2) { hostName = hostName.split('.')[1] } } catch { hostName = newUrl } - - updateFormData('title', hostName) } + + setFormData((prev) => ({ + ...prev, + url: newUrl, + icon: null, + title: prev.title.trim() === '' && hostName ? hostName : prev.title, + })) } const handleAdd = (e: React.FormEvent) => { @@ -132,13 +139,13 @@ export function AddBookmarkModal({ onClose() } - const handleSuggestionSelect = (suggestion: { - title: string - url: string - icon: string | null - }) => { - updateFormData('title', suggestion.title) - updateFormData('url', suggestion.url) + const handleSuggestionSelect = (suggestion: BookmarkSuggestion) => { + setFormData((prev) => ({ + ...prev, + title: suggestion.title, + url: suggestion.url, + icon: null, + })) } const handleAdvancedModalClose = ( @@ -183,10 +190,11 @@ export function AddBookmarkModal({ >
    e.stopPropagation()} className="flex flex-col justify-between gap-2 overflow-y-auto h-96" >
    - + {onOpenImport && (
    -
    - {type === 'BOOKMARK' && ( + + {type === 'BOOKMARK' && ( +
    handleUrlChange(v)} - className={ - 'mt-2 w-full px-4 py-3 text-right absolute rounded-lg transition-all duration-300' - } + className="w-full px-4 py-3 text-right rounded-lg transition-all duration-200" /> - )} -
    +
    + )} + {type === 'BOOKMARK' && ( )} @@ -237,7 +243,7 @@ export function AddBookmarkModal({ bookmark={formData} isOpen={showAdvanced} onClose={handleAdvancedModalClose} - title={'تنظیمات پیشرفته'} + title="تنظیمات پیشرفته" />
    @@ -249,6 +255,7 @@ export function AddBookmarkModal({
    diff --git a/src/layouts/bookmark/components/shared.tsx b/src/layouts/bookmark/components/shared.tsx index 8e2474fbd..7528cc13f 100644 --- a/src/layouts/bookmark/components/shared.tsx +++ b/src/layouts/bookmark/components/shared.tsx @@ -24,8 +24,8 @@ export function TypeSelector({ بوکمارک
    } - className="p-2! h-14!" - description={'ذخیره یک لینک یا وب‌سایت'} + className="p-2! h-auto min-h-[56px]" + description="ذخیره یک لینک یا وب‌سایت" /> } - className="p-2! h-14!" - description={'دسته‌بندی و مرتب‌سازی بوکمارک‌ها'} + className="p-2! h-auto min-h-[56px]" + description="دسته‌بندی و مرتب‌سازی بوکمارک‌ها" />
    ) @@ -57,9 +57,9 @@ export function ShowAdvancedButton({ - -
    - -
    ) } diff --git a/src/layouts/widgets/canvas/canvas-widget-outer.tsx b/src/layouts/widgets/canvas/canvas-widget-outer.tsx index 7ca16a2ce..616868980 100644 --- a/src/layouts/widgets/canvas/canvas-widget-outer.tsx +++ b/src/layouts/widgets/canvas/canvas-widget-outer.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { callEvent } from '@/common/utils/call-event' import { useFreeWidgets } from '@/context/free-widget.context' import { getWidgetPixelRect } from '../grid-geometry' @@ -10,7 +10,6 @@ import type { WidgetSize, } from '../layout-engine/types' import { WidgetContextMenu } from './widget-context-menu' -import { WidgetSizeToolbar } from './widget-size-toolbar' interface CanvasWidgetOuterProps { widget: StoredWidget @@ -81,9 +80,7 @@ export function CanvasWidgetOuter({ target.closest('a') || target.closest('.no-drag') ) { - if (canvasMode !== 'edit') { - return - } + return } clearLongPress() @@ -140,11 +137,12 @@ export function CanvasWidgetOuter({ ) const targetRow = Math.max(0, dragStartPosRef.current.row + deltaRow) - if (targetCol !== widget.position.col || targetRow !== widget.position.row) { + if ( + targetCol !== widget.position.col || + targetRow !== widget.position.row + ) { moveWidget(widget.instanceId, { col: targetCol, row: targetRow }) } - } else if (canvasMode === 'edit') { - setSelectedInstanceId(widget.instanceId) } pointerStartRef.current = null @@ -227,11 +225,14 @@ export function CanvasWidgetOuter({ {canvasMode === 'edit' && ( - )} - -
    +
    -
    +
    {def.node(widget.instanceId, widget.size)}
    @@ -145,51 +132,56 @@ export function FreeWidgetCanvas() { } return ( -
    - {canvasMode === 'edit' && ( -
    - - حالت ویرایش ویجت‌ها فعال است - - - -
    - )} - +
    + {canvasMode === 'edit' && ( +
    + + + حالت ویرایش چیدمان ویجت‌ها + + + +
    + )} + {canvasMode === 'edit' && (