From 3bafc9f34d97a2b704f6d8454f12ace10b768484 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Thu, 3 Sep 2026 10:40:01 +0330 Subject: [PATCH] feat(widgets): add photo widget with media upload support --- src/common/utils/translate-error.ts | 2 + .../free-widget/use-widget-operations.hook.ts | 2 + src/layouts/widgets/layout-engine/types.ts | 1 + src/layouts/widgets/photo/index.ts | 1 + src/layouts/widgets/photo/photo.widget.tsx | 135 ++++++++++++++++++ src/layouts/widgets/widget-registry.tsx | 18 +++ .../hooks/widgets/widget-media.hook.ts | 35 +++++ 7 files changed, 194 insertions(+) create mode 100644 src/layouts/widgets/photo/index.ts create mode 100644 src/layouts/widgets/photo/photo.widget.tsx create mode 100644 src/services/hooks/widgets/widget-media.hook.ts diff --git a/src/common/utils/translate-error.ts b/src/common/utils/translate-error.ts index 5905c6213..dcd504eb2 100644 --- a/src/common/utils/translate-error.ts +++ b/src/common/utils/translate-error.ts @@ -64,6 +64,8 @@ const errorTranslations: Record = { WIDGET_DUPLICATED: 'ویجت با موفقیت تکرار شد', WIDGET_ALREADY_EXISTS: 'این ویجت قبلا به صفحه اضافه شده', INVALID_WIDGET_POSITION: 'موقعیت قرارگیری ویجت درست نیست', + STORAGE_QUOTA_EXCEEDED: 'فضای مجاز ذخیره‌سازی عکس‌های ویجت پر شده', + WIDGET_LIMIT_EXCEEDED: 'تعداد ویجت‌های مجاز پر شده', MAX_WIDGETS_REACHED: 'به سقف تعداد ویجت‌ها رسیدی! یکی از قبلی‌ها رو حذف کن تا جا باز بشه', NO_SPACE_FOR_WIDGET: 'روی صفحه جا نیست! چندتا ویجت رو جابه‌جا یا حذف کن تا جا باز بشه', diff --git a/src/context/free-widget/use-widget-operations.hook.ts b/src/context/free-widget/use-widget-operations.hook.ts index f3ec17742..ae39c4e0a 100644 --- a/src/context/free-widget/use-widget-operations.hook.ts +++ b/src/context/free-widget/use-widget-operations.hook.ts @@ -319,6 +319,8 @@ export function useWidgetOperations({ if (original.id === WidgetKeys.notes) { const { noteId } = await createNoteForDuplicatedWidget(isAuthenticated) duplicatedMeta = { ...duplicatedMeta, activeNoteId: noteId } + } else if (original.id === WidgetKeys.photo) { + delete duplicatedMeta.imageSrc } let newInstanceId = `${original.id}-${Date.now().toString(36)}` diff --git a/src/layouts/widgets/layout-engine/types.ts b/src/layouts/widgets/layout-engine/types.ts index 149a82110..06c4f8239 100644 --- a/src/layouts/widgets/layout-engine/types.ts +++ b/src/layouts/widgets/layout-engine/types.ts @@ -19,6 +19,7 @@ export enum WidgetKeys { transparentClock = 'transparentClock', moodTracker = 'moodTracker', googleCalendar = 'googleCalendar', + photo = 'photo', } export interface WidgetPosition { diff --git a/src/layouts/widgets/photo/index.ts b/src/layouts/widgets/photo/index.ts new file mode 100644 index 000000000..4826da71f --- /dev/null +++ b/src/layouts/widgets/photo/index.ts @@ -0,0 +1 @@ +export * from './photo.widget' diff --git a/src/layouts/widgets/photo/photo.widget.tsx b/src/layouts/widgets/photo/photo.widget.tsx new file mode 100644 index 000000000..203391578 --- /dev/null +++ b/src/layouts/widgets/photo/photo.widget.tsx @@ -0,0 +1,135 @@ +import { useRef, useState } from 'react' +import { WidgetContainer } from '../widget-container' +import type { WidgetSize } from '../layout-engine/types' +import { useFreeWidgets } from '@/context/free-widget/free-widget.context' +import { useAppearance } from '@/context/appearance.context' +import { useAuth } from '@/context/auth.context' +import { Icon } from '@/src/icons' +import { showToast } from '@/common/toast' +import { translateError } from '@/common/utils/translate-error' +import { safeAwait } from '@/services/api' +import { uploadWidgetMediaApi } from '@/services/hooks/widgets/widget-media.hook' +import { callEvent } from '@/common/utils/call-event' +import type { AxiosError } from 'axios' + +interface PhotoWidgetProps { + size?: WidgetSize + meta?: { imageSrc?: string } + instanceId?: string +} + +export function PhotoWidget({ size = { w: 2, h: 2 }, meta, instanceId }: PhotoWidgetProps) { + const { updateWidgetSettings } = useFreeWidgets() + const { canvasMode } = useAppearance() + const { isVip } = useAuth() + const inputRef = useRef(null) + const [isUploading, setIsUploading] = useState(false) + + const is1x1 = size.w === 1 && size.h === 1 + const imageSrc = meta?.imageSrc + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + if (!isVip) { + callEvent('openSettings', 'vip') + return + } + + if (!file.type.startsWith('image/')) { + showToast('لطفا یک فایل تصویری انتخاب کن', 'error') + return + } + + if (file.size > 1024 * 1024) { + showToast('حجم عکس نباید بیشتر از ۱ مگابایت باشه', 'error') + return + } + + if (!instanceId) return + + setIsUploading(true) + const [err, res] = await safeAwait( + uploadWidgetMediaApi(instanceId, file) + ) + setIsUploading(false) + + if (err || !res?.url) { + showToast(translateError(err) as string, 'error') + return + } + + updateWidgetSettings(instanceId, { imageSrc: res.url }) + showToast('عکس با موفقیت ذخیره شد', 'success') + e.target.value = '' + } + + const handleClick = () => { + if (canvasMode !== 'edit' && !isUploading) { + if (!isVip) { + callEvent('openSettings', 'vip') + return + } + inputRef.current?.click() + } + } + + return ( + + + + {imageSrc ? ( +
+ عکس + {isUploading && ( +
+ +
+ )} +
+ ) : ( + + )} +
+ ) +} diff --git a/src/layouts/widgets/widget-registry.tsx b/src/layouts/widgets/widget-registry.tsx index 39a70e5a7..3faa50024 100644 --- a/src/layouts/widgets/widget-registry.tsx +++ b/src/layouts/widgets/widget-registry.tsx @@ -18,6 +18,7 @@ import { ClockWidget } from './clock/clock.widget' import { PetWidget } from './pet/pet.widget' import { TransparentClockWidget } from './transparent-clock/transparent-clock.widget' import { MoodTrackerWidget } from './mood-tracker/mood-tracker.widget' +import { PhotoWidget } from './photo' import { GoogleCalendarWidget } from './google-calendar/google-calendar.widget' import { TodosLayout } from './todos/todos' import { NotesLayout } from './notes/notes.layout' @@ -477,6 +478,23 @@ export const WIDGET_DEFINITIONS: Record = { canDuplicate: false, node: (_instanceId, size) => , }, + [WidgetKeys.photo]: { + id: WidgetKeys.photo, + label: 'قاب عکس', + emoji: '🖼️', + category: 'lifestyle', + isVipOnly: true, + allowedSizes: [ + { w: 1, h: 1 }, + { w: 2, h: 1 }, + { w: 2, h: 2 }, + ], + defaultSize: { w: 2, h: 2 }, + canDuplicate: true, + node: (instanceId, size, meta) => ( + + ), + }, } export const widgetItems: WidgetItem[] = Object.values(WIDGET_DEFINITIONS) diff --git a/src/services/hooks/widgets/widget-media.hook.ts b/src/services/hooks/widgets/widget-media.hook.ts new file mode 100644 index 000000000..3e9903b20 --- /dev/null +++ b/src/services/hooks/widgets/widget-media.hook.ts @@ -0,0 +1,35 @@ +import { getMainClient, safeAwait } from '@/services/api' +import type { AxiosError } from 'axios' + +export interface UploadWidgetMediaResponse { + url: string +} + +export async function uploadWidgetMediaApi( + instanceId: string, + file: File +): Promise { + const client = getMainClient() + const formData = new FormData() + formData.append('file', file) + + const response = await client.post( + `/user-widgets/${instanceId}/media`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ) + + return response.data +} + +export async function removeWidgetMediaApi(instanceId: string): Promise { + const client = getMainClient() + const [error] = await safeAwait( + client.delete(`/user-widgets/${instanceId}/media`) + ) + return !error +}