Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/common/utils/translate-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const errorTranslations: Record<string, string> = {
WIDGET_DUPLICATED: 'ویجت با موفقیت تکرار شد',
WIDGET_ALREADY_EXISTS: 'این ویجت قبلا به صفحه اضافه شده',
INVALID_WIDGET_POSITION: 'موقعیت قرارگیری ویجت درست نیست',
STORAGE_QUOTA_EXCEEDED: 'فضای مجاز ذخیره‌سازی عکس‌های ویجت پر شده',
WIDGET_LIMIT_EXCEEDED: 'تعداد ویجت‌های مجاز پر شده',
MAX_WIDGETS_REACHED:
'به سقف تعداد ویجت‌ها رسیدی! یکی از قبلی‌ها رو حذف کن تا جا باز بشه',
NO_SPACE_FOR_WIDGET: 'روی صفحه جا نیست! چندتا ویجت رو جابه‌جا یا حذف کن تا جا باز بشه',
Expand Down
2 changes: 2 additions & 0 deletions src/context/free-widget/use-widget-operations.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
Expand Down
1 change: 1 addition & 0 deletions src/layouts/widgets/layout-engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum WidgetKeys {
transparentClock = 'transparentClock',
moodTracker = 'moodTracker',
googleCalendar = 'googleCalendar',
photo = 'photo',
}

export interface WidgetPosition {
Expand Down
1 change: 1 addition & 0 deletions src/layouts/widgets/photo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './photo.widget'
135 changes: 135 additions & 0 deletions src/layouts/widgets/photo/photo.widget.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)

const is1x1 = size.w === 1 && size.h === 1
const imageSrc = meta?.imageSrc

const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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<AxiosError, { url: string }>(
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 (
<WidgetContainer padding={false} className="cursor-pointer">
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
/>

{imageSrc ? (
<div className="relative w-full h-full overflow-hidden">
<img
src={imageSrc}
alt="عکس"
onClick={handleClick}
className="w-full h-full object-cover"
/>
{isUploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<span className="loading loading-spinner text-content" />
</div>
)}
</div>
) : (
<button
type="button"
onClick={handleClick}
className="group relative w-full h-full flex flex-col items-center justify-center gap-2.5 p-3 text-content/70 hover:text-content transition-all cursor-pointer select-none"
>
{isUploading ? (
<span className="loading loading-spinner text-content" />
) : (
<>
<div className="w-10 h-10 rounded-full bg-base-300/60 group-hover:bg-base-300 flex items-center justify-center transition-colors duration-200">
<Icon
name="image"
size={is1x1 ? 16 : 18}
className="text-content/70 group-hover:text-content transition-colors"
/>
</div>

<span
className={`text-center font-medium transition-colors ${
is1x1
? 'text-[10px] text-content/60'
: 'text-xs text-content/75 group-hover:text-content'
}`}
>
{is1x1 ? 'افزودن عکس' : 'یه عکس بذار که حالت رو خوب کنه'}
</span>
</>
)}
</button>
)}
</WidgetContainer>
)
}
18 changes: 18 additions & 0 deletions src/layouts/widgets/widget-registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -477,6 +478,23 @@ export const WIDGET_DEFINITIONS: Record<WidgetKeys, WidgetDefinition> = {
canDuplicate: false,
node: (_instanceId, size) => <MoodTrackerWidget size={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) => (
<PhotoWidget size={size} meta={meta} instanceId={instanceId} />
),
},
}

export const widgetItems: WidgetItem[] = Object.values(WIDGET_DEFINITIONS)
Expand Down
35 changes: 35 additions & 0 deletions src/services/hooks/widgets/widget-media.hook.ts
Original file line number Diff line number Diff line change
@@ -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<UploadWidgetMediaResponse> {
const client = getMainClient()
const formData = new FormData()
formData.append('file', file)

const response = await client.post<UploadWidgetMediaResponse>(
`/user-widgets/${instanceId}/media`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)

return response.data
}

export async function removeWidgetMediaApi(instanceId: string): Promise<boolean> {
const client = getMainClient()
const [error] = await safeAwait<AxiosError, { success: boolean }>(
client.delete(`/user-widgets/${instanceId}/media`)
)
return !error
}
Loading