From 63ed6f6e389bd4f757e563a447ff502a843af0c8 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 19 Aug 2026 16:24:18 +0300 Subject: [PATCH] Add search pagination and improve avatar and document overlays --- backend/api/v1/search/views.py | 17 ++++- backend/tests/api/v1/test_search.py | 32 +++++++++ frontend/src/app/search/page.tsx | 71 +++++++++++++++++-- frontend/src/app/tasks/page.tsx | 52 ++++++++++++-- frontend/src/components/AvatarCropper.tsx | 34 ++++++--- .../documents/viewer/EnhancedPDFViewer.tsx | 14 +++- .../components/messages/ChatSettingsModal.tsx | 43 ++++++++++- .../components/messages/CreateChatModal.tsx | 47 ++++++++++-- .../src/components/messages/EditChatModal.tsx | 12 +++- frontend/src/components/ui/Modal.tsx | 22 ++++-- frontend/src/lib/api/client.ts | 11 ++- frontend/src/lib/image.ts | 17 +++++ 12 files changed, 328 insertions(+), 44 deletions(-) create mode 100644 frontend/src/lib/image.ts diff --git a/backend/api/v1/search/views.py b/backend/api/v1/search/views.py index 7450d9b7..c03178e3 100644 --- a/backend/api/v1/search/views.py +++ b/backend/api/v1/search/views.py @@ -510,6 +510,8 @@ def _can_view_search_result( parameters=[ OpenApiParameter("q", str, OpenApiParameter.QUERY, required=True), OpenApiParameter("limit", int, OpenApiParameter.QUERY), + OpenApiParameter("offset", int, OpenApiParameter.QUERY), + OpenApiParameter("type", str, OpenApiParameter.QUERY), ], responses=SearchResponseSerializer, ) @@ -522,6 +524,8 @@ def search_api_view(request: DRFRequest) -> Response: Query Parameters: - q (str): Поисковый запрос - limit (int): Максимальное количество результатов на тип (default: 10) + - offset (int): Смещение внутри каждого типа результатов (default: 0) + - type (str): Вернуть записи только указанного типа Returns: { @@ -559,6 +563,12 @@ def search_api_view(request: DRFRequest) -> Response: except (ValueError, TypeError): limit = 10 limit = max(1, min(limit, 50)) + try: + offset = int(request.query_params.get("offset", 0)) + except (ValueError, TypeError): + offset = 0 + offset = max(0, offset) + requested_model_type = (request.query_params.get("type", "") or "").strip() if not query: return Response( @@ -600,8 +610,11 @@ def search_api_view(request: DRFRequest) -> Response: # Подсчитываем model_counts[model_name] = model_counts.get(model_name, 0) + 1 - # Ограничиваем количество на тип - if model_counts[model_name] <= limit: + # Возвращаем нужную страницу конкретного типа. Счётчики при этом + # продолжают описывать весь доступный пользователю набор результатов. + result_position = model_counts[model_name] + type_matches = not requested_model_type or model_name == requested_model_type + if type_matches and offset < result_position <= offset + limit: formatted = _format_result(obj, model_name) results.append(formatted) diff --git a/backend/tests/api/v1/test_search.py b/backend/tests/api/v1/test_search.py index 079cd9ec..c2bf60a4 100644 --- a/backend/tests/api/v1/test_search.py +++ b/backend/tests/api/v1/test_search.py @@ -247,6 +247,38 @@ class TestSearchAPI: posts = [r for r in response.data['results'] if r['model_name'] == 'post'] assert len(posts) <= 2 + + def test_search_supports_category_pagination(self, api_client, sample_data): + """Следующая страница возвращает новые записи только выбранного типа.""" + from django.core.management import call_command + + emp = sample_data['employees'][0] + for i in range(5): + Post.objects.create( + author=emp, + title=f"Пагинация поиска {i}", + body="Маркер пагинации поиска", + ) + call_command('buildwatson', verbosity=0) + + first = api_client.get( + '/api/v1/search/?q=Маркер пагинации поиска&limit=2&type=post' + ) + second = api_client.get( + '/api/v1/search/?q=Маркер пагинации поиска&limit=2&offset=2&type=post' + ) + + assert first.status_code == status.HTTP_200_OK + assert second.status_code == status.HTTP_200_OK + assert len(first.data['results']) == 2 + assert len(second.data['results']) == 2 + assert all(result['model_name'] == 'post' for result in second.data['results']) + assert { + result['object_id'] for result in first.data['results'] + }.isdisjoint({ + result['object_id'] for result in second.data['results'] + }) + assert second.data['counts']['post'] == 5 def test_search_response_structure(self, api_client, sample_data): """Проверка структуры ответа API.""" diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx index b966a400..8241d954 100644 --- a/frontend/src/app/search/page.tsx +++ b/frontend/src/app/search/page.tsx @@ -11,6 +11,7 @@ import { FileText, Inbox, LockKeyhole, + LoaderCircle, MessageCircle, MessageSquare, Package, @@ -180,6 +181,8 @@ function SearchPageContent() { const requestedCategory = searchParams.get("type"); const [searchResponse, setSearchResponse] = useState(null); const [loading, setLoading] = useState(false); + const [loadingMoreCategory, setLoadingMoreCategory] = useState(null); + const [loadMoreErrors, setLoadMoreErrors] = useState>>({}); const [error, setError] = useState(null); useEffect(() => { @@ -188,8 +191,10 @@ function SearchPageContent() { async function runSearch() { try { setLoading(true); + setLoadingMoreCategory(null); + setLoadMoreErrors({}); setError(null); - const response = await apiClient.search(queryRaw, 50); + const response = await apiClient.search(queryRaw, { limit: 50 }); if (!cancelled) setSearchResponse(response); } catch (err) { if (!cancelled) { @@ -229,6 +234,46 @@ function SearchPageContent() { router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }; + const loadMore = async (modelType: SearchModelType) => { + if (loadingMoreCategory || !searchResponse) return; + const offset = resultsByType[modelType]?.length || 0; + const requestQuery = query; + setLoadingMoreCategory(modelType); + setLoadMoreErrors((previous) => ({ ...previous, [modelType]: undefined })); + try { + const response = await apiClient.search(queryRaw, { + limit: 50, + offset, + modelType, + }); + setSearchResponse((previous) => { + if (!previous || previous.query.trim() !== requestQuery) return previous; + const existingKeys = new Set( + previous.results.map((result) => `${result.model_name}:${result.object_id}`), + ); + const nextResults = response.results.filter( + (result) => !existingKeys.has(`${result.model_name}:${result.object_id}`), + ); + return { + ...previous, + results: [...previous.results, ...nextResults], + counts: response.counts, + hidden_counts: response.hidden_counts, + total: response.total, + hidden_total: response.hidden_total, + }; + }); + } catch (loadError) { + console.error(`Не удалось загрузить дополнительные результаты ${modelType}:`, loadError); + setLoadMoreErrors((previous) => ({ + ...previous, + [modelType]: "Не удалось загрузить следующие результаты", + })); + } finally { + setLoadingMoreCategory(null); + } + }; + return (
@@ -279,6 +324,8 @@ function SearchPageContent() { const Icon = modelIcons[modelType]; const visibleCount = searchResponse?.counts[modelType] || 0; const hiddenCount = searchResponse?.hidden_counts[modelType] || 0; + const hasMore = results.length < visibleCount; + const isLoadingMore = loadingMoreCategory === modelType; return (
@@ -307,9 +354,25 @@ function SearchPageContent() { ); })}
-
- {results.length < visibleCount ? `Показано ${results.length} из ${visibleCount}` : `${visibleCount} доступно`} - +
+
+ {hasMore ? `Показано ${results.length} из ${visibleCount}` : `${visibleCount} доступно`} + +
+ {hasMore ? ( + + ) : null} + {loadMoreErrors[modelType] ? ( +

{loadMoreErrors[modelType]}

+ ) : null}
); diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 8ee6657e..5daa4c2a 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { createPortal, flushSync } from "react-dom"; @@ -97,6 +98,7 @@ import { useUser } from "@/contexts/UserContext"; import { useTaskBoardScroll } from "@/hooks/useTaskBoardScroll"; import { useTaskBoardRealtime } from "@/hooks/useTaskBoardRealtime"; import { apiClient } from "@/lib/api"; +import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image"; import type { MediaPreview } from "@/lib/media"; import { displayUserName, formatDate, formatDateTime, formatMoney } from "@/lib/shared"; import { buildTaskBoardLayout, taskPlacementKey } from "@/lib/tasks/boardLayout"; @@ -135,6 +137,10 @@ import type { User, } from "@/types/api"; +const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { + ssr: false, +}); + type TaskFormState = { id: number | null; title: string; @@ -3741,6 +3747,8 @@ function TasksPageContent() { const [cardMenuTaskId, setCardMenuTaskId] = useState(null); const [columnMenuId, setColumnMenuId] = useState(null); const [boardModalOpen, setBoardModalOpen] = useState(false); + const [boardAvatarCropperImage, setBoardAvatarCropperImage] = useState(null); + const [boardAvatarFileName, setBoardAvatarFileName] = useState("board-avatar.jpg"); const [boardGroupModalOpen, setBoardGroupModalOpen] = useState(false); const [boardActionsMenuOpen, setBoardActionsMenuOpen] = useState(false); const [automationModalOpen, setAutomationModalOpen] = useState(false); @@ -5226,6 +5234,8 @@ function TasksPageContent() { const openCreateBoard = useCallback(() => { setBoardForm(emptyBoardForm); + setBoardAvatarCropperImage(null); + setBoardAvatarFileName("board-avatar.jpg"); setBoardMemberSearch(""); setBoardDepartmentSearch(""); setBoardActionsMenuOpen(false); @@ -5278,6 +5288,8 @@ function TasksPageContent() { setBoardMemberSearch(""); setBoardDepartmentSearch(""); setBoardActionsMenuOpen(false); + setBoardAvatarCropperImage(null); + setBoardAvatarFileName("board-avatar.jpg"); setBoardModalOpen(true); }, [board]); @@ -5285,6 +5297,8 @@ function TasksPageContent() { if (saving) return; setBoardModalOpen(false); setBoardForm(emptyBoardForm); + setBoardAvatarCropperImage(null); + setBoardAvatarFileName("board-avatar.jpg"); setBoardMemberSearch(""); setBoardDepartmentSearch(""); }, [saving]); @@ -5376,16 +5390,29 @@ function TasksPageContent() { const reader = new FileReader(); reader.onloadend = () => { - setBoardForm((current) => ({ - ...current, - avatar_file: file, - avatar_preview: typeof reader.result === "string" ? reader.result : null, - avatar_removed: false, - })); + if (typeof reader.result !== "string") return; + setBoardAvatarFileName(buildCroppedImageFileName(file.name, "board-avatar")); + setBoardAvatarCropperImage(reader.result); }; reader.readAsDataURL(file); }, []); + const handleBoardAvatarCropComplete = useCallback((croppedImage: string) => { + try { + const croppedFile = imageDataUrlToFile(croppedImage, boardAvatarFileName); + setBoardForm((current) => ({ + ...current, + avatar_file: croppedFile, + avatar_preview: croppedImage, + avatar_removed: false, + })); + setBoardAvatarCropperImage(null); + } catch (cropError) { + console.error("Не удалось обработать аватар доски:", cropError); + setError("Не удалось обработать изображение"); + } + }, [boardAvatarFileName]); + const removeBoardAvatar = useCallback(() => { setBoardForm((current) => ({ ...current, @@ -8155,7 +8182,8 @@ function TasksPageContent() { onClose={closeBoardModal} title={boardForm.id ? "Редактировать доску" : "Новая доска"} size="lg" - closeOnClickOutside + closeOnEsc={!boardAvatarCropperImage} + closeOnClickOutside={!boardAvatarCropperImage} footer={(
{/* Контролы масштабирования */} diff --git a/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx b/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx index 2cb299f6..b9eee220 100644 --- a/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx +++ b/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, useEffect } from "react"; +import { createPortal } from "react-dom"; import { Document, Page, pdfjs } from "react-pdf"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; @@ -152,8 +153,14 @@ export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFVie return undefined; }, [fitMode, containerWidth]); - return ( -
+ return createPortal( +
{/* Шапка */}
@@ -361,6 +368,7 @@ export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFVie
-
+ , + document.body, ); } diff --git a/frontend/src/components/messages/ChatSettingsModal.tsx b/frontend/src/components/messages/ChatSettingsModal.tsx index 3473c59a..661e4dae 100644 --- a/frontend/src/components/messages/ChatSettingsModal.tsx +++ b/frontend/src/components/messages/ChatSettingsModal.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import dynamic from "next/dynamic"; import { useEffect, useState, type ChangeEvent } from "react"; import { Pencil, Pin } from "lucide-react"; @@ -8,11 +9,16 @@ import EditChatModal from "@/components/messages/EditChatModal"; import ChatNotificationPreferences from "@/components/messages/ChatNotificationPreferences"; import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; +import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image"; import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils"; import { getChatTypeLabel } from "@/lib/messages/chatSettingsUtils"; import { resolveMediaUrl } from "@/lib/url"; import type { Chat, ChatUserSettings } from "@/types/api"; +const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { + ssr: false, +}); + type ChatSettingsModalProps = { chatId: number; currentUserId?: number; @@ -37,6 +43,8 @@ export default function ChatSettingsModal({ const [editDescription, setEditDescription] = useState(""); const [editAvatarFile, setEditAvatarFile] = useState(null); const [editAvatarPreview, setEditAvatarPreview] = useState(null); + const [editAvatarCropperImage, setEditAvatarCropperImage] = useState(null); + const [editAvatarFileName, setEditAvatarFileName] = useState("chat-avatar.jpg"); useEffect(() => { if (!open) return; @@ -94,6 +102,8 @@ export default function ChatSettingsModal({ setEditDescription(chat.description || ""); setEditAvatarFile(null); setEditAvatarPreview(null); + setEditAvatarCropperImage(null); + setEditAvatarFileName("chat-avatar.jpg"); setEditOpen(true); }; @@ -101,15 +111,31 @@ export default function ChatSettingsModal({ setEditOpen(false); setEditAvatarFile(null); setEditAvatarPreview(null); + setEditAvatarCropperImage(null); }; const handleAvatarChange = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file || !file.type.startsWith("image/")) return; - setEditAvatarFile(file); const reader = new FileReader(); - reader.onloadend = () => setEditAvatarPreview(reader.result as string); + reader.onloadend = () => { + if (typeof reader.result !== "string") return; + setEditAvatarFileName(buildCroppedImageFileName(file.name, "chat-avatar")); + setEditAvatarCropperImage(reader.result); + }; reader.readAsDataURL(file); + event.target.value = ""; + }; + + const handleAvatarCropComplete = (croppedImage: string) => { + try { + setEditAvatarFile(imageDataUrlToFile(croppedImage, editAvatarFileName)); + setEditAvatarPreview(croppedImage); + setEditAvatarCropperImage(null); + } catch (cropError) { + console.error("Не удалось обработать аватар чата:", cropError); + setError("Не удалось обработать изображение"); + } }; const saveEdit = async () => { @@ -146,7 +172,8 @@ export default function ChatSettingsModal({ onClose={onClose} title="Настройки чата" size="md" - closeOnClickOutside + closeOnEsc={!editOpen && !editAvatarCropperImage} + closeOnClickOutside={!editOpen && !editAvatarCropperImage} > {loading ? (
@@ -220,6 +247,7 @@ export default function ChatSettingsModal({ editName={editName} editDescription={editDescription} editAvatarPreview={editAvatarPreview} + avatarCropperOpen={Boolean(editAvatarCropperImage)} actionLoading={actionLoading} onClose={closeEdit} onNameChange={setEditName} @@ -227,6 +255,15 @@ export default function ChatSettingsModal({ onAvatarChange={handleAvatarChange} onSave={() => void saveEdit()} /> + {editAvatarCropperImage ? ( + setEditAvatarCropperImage(null)} + /> + ) : null} ); } diff --git a/frontend/src/components/messages/CreateChatModal.tsx b/frontend/src/components/messages/CreateChatModal.tsx index 0925a99d..0c2258ff 100644 --- a/frontend/src/components/messages/CreateChatModal.tsx +++ b/frontend/src/components/messages/CreateChatModal.tsx @@ -1,13 +1,19 @@ "use client"; import Image from "next/image"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { useState, type ChangeEvent } from "react"; import { Globe, ImagePlus, Radio, Trash2, Users } from "lucide-react"; import { apiClient } from "@/lib/api"; +import { buildCroppedImageFileName, imageDataUrlToFile } from "@/lib/image"; import { Modal } from "@/components/ui"; +const AvatarCropper = dynamic(() => import("@/components/AvatarCropper"), { + ssr: false, +}); + type CreateChatModalProps = { isOpen: boolean; onClose: () => void; @@ -48,6 +54,8 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp const [description, setDescription] = useState(""); const [avatar, setAvatar] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarCropperImage, setAvatarCropperImage] = useState(null); + const [avatarCropperFileName, setAvatarCropperFileName] = useState("chat-avatar.jpg"); const [creating, setCreating] = useState(false); const [error, setError] = useState(null); @@ -57,6 +65,8 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp setDescription(""); setAvatar(null); setAvatarPreview(null); + setAvatarCropperImage(null); + setAvatarCropperFileName("chat-avatar.jpg"); setError(null); }; @@ -106,19 +116,36 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp } setError(null); - setAvatar(file); const reader = new FileReader(); - reader.onloadend = () => setAvatarPreview(reader.result as string); + reader.onloadend = () => { + if (typeof reader.result !== "string") return; + setAvatarCropperFileName(buildCroppedImageFileName(file.name, "chat-avatar")); + setAvatarCropperImage(reader.result); + }; reader.readAsDataURL(file); + event.target.value = ""; + }; + + const handleAvatarCropComplete = (croppedImage: string) => { + try { + setAvatar(imageDataUrlToFile(croppedImage, avatarCropperFileName)); + setAvatarPreview(croppedImage); + setAvatarCropperImage(null); + } catch (cropError) { + console.error("Не удалось обработать аватар чата:", cropError); + setError("Не удалось обработать изображение"); + } }; return ( - + @@ -252,6 +279,16 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
- + + {avatarCropperImage ? ( + setAvatarCropperImage(null)} + /> + ) : null} + ); } diff --git a/frontend/src/components/messages/EditChatModal.tsx b/frontend/src/components/messages/EditChatModal.tsx index af10e008..1d17c75a 100644 --- a/frontend/src/components/messages/EditChatModal.tsx +++ b/frontend/src/components/messages/EditChatModal.tsx @@ -15,6 +15,7 @@ type EditChatModalProps = { editName: string; editDescription: string; editAvatarPreview: string | null; + avatarCropperOpen?: boolean; actionLoading: string | null; onClose: () => void; onNameChange: (value: string) => void; @@ -30,6 +31,7 @@ export default function EditChatModal({ editName, editDescription, editAvatarPreview, + avatarCropperOpen = false, actionLoading, onClose, onNameChange, @@ -59,7 +61,15 @@ export default function EditChatModal({ ); return ( - + {chat && (
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 2720c093..c6aad18b 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -1,10 +1,14 @@ "use client"; -import { useEffect, useId, useRef, type ReactNode } from "react"; +import { useEffect, useId, useRef, useSyncExternalStore, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { X } from "lucide-react"; let openModalCount = 0; const openModalStack: string[] = []; +const subscribeToBrowserReady = () => () => {}; +const getBrowserSnapshot = () => true; +const getServerSnapshot = () => false; export interface ModalProps { isOpen: boolean; @@ -43,6 +47,11 @@ export function Modal({ const modalRef = useRef(null); const contentRef = useRef(null); const titleId = useId(); + const browserReady = useSyncExternalStore( + subscribeToBrowserReady, + getBrowserSnapshot, + getServerSnapshot, + ); useEffect(() => { if (!isOpen) return; @@ -75,7 +84,7 @@ export function Modal({ }, [isOpen, closeOnEsc, onClose, titleId]); useEffect(() => { - if (!isOpen || !contentRef.current) return; + if (!isOpen || !browserReady || !contentRef.current) return; const focusableElements = contentRef.current.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' @@ -106,9 +115,9 @@ export function Modal({ document.addEventListener("keydown", handleTab); return () => document.removeEventListener("keydown", handleTab); - }, [isOpen, titleId]); + }, [isOpen, titleId, browserReady]); - if (!isOpen) return null; + if (!isOpen || !browserReady) return null; const handleBackdropClick = (e: React.MouseEvent) => { if ( @@ -132,7 +141,7 @@ export function Modal({ const showHeader = !noHeader && (title || showCloseButton); - return ( + return createPortal(
}
-
+
, + document.body, ); } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 10c26a72..aba895b2 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -6,6 +6,7 @@ import type { LoginResponse, QrLoginRequestCreateResult, QrLoginRequestStatusResult, + SearchModelType, SearchResponse, } from "@/types/api"; @@ -350,10 +351,16 @@ export class ApiClientBase { return this.request('/api/v1/employees/me/'); } - async search(query: string, limit?: number): Promise { + async search(query: string, options: { + limit?: number; + offset?: number; + modelType?: SearchModelType; + } = {}): Promise { const queryParams = new URLSearchParams(); queryParams.append('q', query); - if (limit) queryParams.append('limit', limit.toString()); + if (options.limit) queryParams.append('limit', options.limit.toString()); + if (options.offset) queryParams.append('offset', options.offset.toString()); + if (options.modelType) queryParams.append('type', options.modelType); return this.request(`/api/v1/search/?${queryParams.toString()}`); } diff --git a/frontend/src/lib/image.ts b/frontend/src/lib/image.ts new file mode 100644 index 00000000..779eed07 --- /dev/null +++ b/frontend/src/lib/image.ts @@ -0,0 +1,17 @@ +export function buildCroppedImageFileName(fileName: string, fallback = "avatar") { + const baseName = fileName.trim().replace(/\.[^.]+$/, "") || fallback; + return `${baseName}.jpg`; +} + +export function imageDataUrlToFile(dataUrl: string, fileName: string) { + const [header, base64Data] = dataUrl.split(","); + if (!base64Data) throw new Error("Invalid image data URL"); + + const mime = header.match(/^data:(.*?);base64$/)?.[1] || "image/jpeg"; + const binary = window.atob(base64Data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], fileName, { type: mime }); +}