Add search pagination and improve avatar and document overlays

This commit is contained in:
lizerk 2026-08-19 16:24:18 +03:00
parent 9462788220
commit 63ed6f6e38
12 changed files with 328 additions and 44 deletions

View File

@ -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)

View File

@ -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."""

View File

@ -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<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMoreCategory, setLoadingMoreCategory] = useState<SearchModelType | null>(null);
const [loadMoreErrors, setLoadMoreErrors] = useState<Partial<Record<SearchModelType, string>>>({});
const [error, setError] = useState<string | null>(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 (
<AppShell>
<section className="app-surface rounded-2xl p-4">
@ -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 (
<section key={modelType} aria-labelledby={`search-category-${modelType}`}>
<div className="mb-2 flex items-center gap-2 px-1">
@ -307,9 +354,25 @@ function SearchPageContent() {
);
})}
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center justify-between gap-2 px-1 text-[11px]">
<span>{results.length < visibleCount ? `Показано ${results.length} из ${visibleCount}` : `${visibleCount} доступно`}</span>
<span className="inline-flex items-center gap-1"><LockKeyhole size={12} aria-hidden="true" /> Нет доступа: {hiddenCount}</span>
<div className="mt-2 space-y-2 px-1">
<div className="app-text-muted flex flex-wrap items-center justify-between gap-2 text-[11px]">
<span>{hasMore ? `Показано ${results.length} из ${visibleCount}` : `${visibleCount} доступно`}</span>
<span className="inline-flex items-center gap-1"><LockKeyhole size={12} aria-hidden="true" /> Нет доступа: {hiddenCount}</span>
</div>
{hasMore ? (
<button
type="button"
onClick={() => void loadMore(modelType)}
disabled={Boolean(loadingMoreCategory)}
className="app-action-secondary inline-flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-xs font-medium disabled:cursor-wait disabled:opacity-60"
>
{isLoadingMore ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : null}
{isLoadingMore ? "Загрузка..." : "Показать ещё"}
</button>
) : null}
{loadMoreErrors[modelType] ? (
<p className="text-xs text-red-400" role="alert">{loadMoreErrors[modelType]}</p>
) : null}
</div>
</section>
);

View File

@ -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<number | null>(null);
const [columnMenuId, setColumnMenuId] = useState<number | null>(null);
const [boardModalOpen, setBoardModalOpen] = useState(false);
const [boardAvatarCropperImage, setBoardAvatarCropperImage] = useState<string | null>(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={(
<div className="flex justify-end gap-2">
<button
@ -8393,6 +8421,16 @@ function TasksPageContent() {
</div>
</Modal>
{boardAvatarCropperImage ? (
<AvatarCropper
initialImage={boardAvatarCropperImage}
mode="avatar"
title="Аватар доски"
onCropComplete={handleBoardAvatarCropComplete}
onCancel={() => setBoardAvatarCropperImage(null)}
/>
) : null}
<Modal
isOpen={boardGroupModalOpen}
onClose={closeBoardGroupModal}

View File

@ -9,15 +9,24 @@ interface AvatarCropperProps {
onCropComplete: (croppedImage: string) => void;
onCancel: () => void;
initialImage: string;
mode?: 'profile' | 'avatar';
title?: string;
}
export default function AvatarCropper({ onCropComplete, onCancel, initialImage }: AvatarCropperProps) {
export default function AvatarCropper({
onCropComplete,
onCancel,
initialImage,
mode = 'profile',
title,
}: AvatarCropperProps) {
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
// Фиксированный размер области обрезки 3:4 (240x320px)
const cropSize = { width: 240, height: 320 };
const isProfile = mode === 'profile';
const cropSize = isProfile
? { width: 240, height: 320 }
: { width: 260, height: 260 };
const onCropAreaChange = useCallback((croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
@ -64,10 +73,12 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
{/* Заголовок */}
<div className="app-divider border-b px-6 py-4">
<h3 className="text-lg font-semibold text-[var(--foreground)]">
Настройка фото профиля
{title || (isProfile ? 'Настройка фото профиля' : 'Настройка аватара')}
</h3>
<p className="app-text-muted mt-1 text-sm">
Разместите лицо в центре овала. Фото будет использоваться для распознавания.
{isProfile
? 'Разместите лицо в центре овала. Фото будет использоваться для распознавания.'
: 'Выберите область изображения, которая будет видна в круглом аватаре.'}
</p>
</div>
@ -77,9 +88,9 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
image={initialImage}
crop={crop}
zoom={zoom}
aspect={3 / 4}
aspect={isProfile ? 3 / 4 : 1}
cropSize={cropSize}
cropShape="rect"
cropShape={isProfile ? 'rect' : 'round'}
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
@ -88,8 +99,8 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
restrictPosition={false}
/>
{/* Направляющие рамки */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
{isProfile ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<svg
width={cropSize.width}
height={cropSize.height}
@ -128,7 +139,8 @@ export default function AvatarCropper({ onCropComplete, onCancel, initialImage }
opacity="0.5"
/>
</svg>
</div>
</div>
) : null}
</div>
{/* Контролы масштабирования */}

View File

@ -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 (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-900">
return createPortal(
<div
data-overlay-root="true"
className="fixed inset-0 z-[300] flex flex-col bg-gray-900"
role="dialog"
aria-modal="true"
aria-label={fileName ? `Просмотр документа: ${fileName}` : "Просмотр документа"}
>
{/* Шапка */}
<div className="flex items-center justify-between border-b border-gray-700 bg-gray-800 px-2 sm:px-4 py-2 sm:py-3 text-white">
<div className="flex items-center gap-2 sm:gap-4">
@ -361,6 +368,7 @@ export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFVie
</div>
</div>
</div>
</div>
</div>,
document.body,
);
}

View File

@ -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<File | null>(null);
const [editAvatarPreview, setEditAvatarPreview] = useState<string | null>(null);
const [editAvatarCropperImage, setEditAvatarCropperImage] = useState<string | null>(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<HTMLInputElement>) => {
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 ? (
<div className="app-text-muted flex min-h-48 items-center justify-center text-sm">
@ -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 ? (
<AvatarCropper
initialImage={editAvatarCropperImage}
mode="avatar"
title="Аватар чата"
onCropComplete={handleAvatarCropComplete}
onCancel={() => setEditAvatarCropperImage(null)}
/>
) : null}
</>
);
}

View File

@ -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<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarCropperImage, setAvatarCropperImage] = useState<string | null>(null);
const [avatarCropperFileName, setAvatarCropperFileName] = useState("chat-avatar.jpg");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal
<>
<Modal
isOpen={isOpen}
onClose={close}
title="Создать чат"
size="md"
closeOnEsc={!creating}
closeOnEsc={!creating && !avatarCropperImage}
closeOnClickOutside={!avatarCropperImage}
className="h-[100dvh] max-w-full rounded-none sm:h-auto sm:rounded-2xl"
footer={(
<div className="flex flex-wrap items-center justify-end gap-2">
@ -252,6 +279,16 @@ export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProp
</div>
</section>
</form>
</Modal>
</Modal>
{avatarCropperImage ? (
<AvatarCropper
initialImage={avatarCropperImage}
mode="avatar"
title="Аватар чата"
onCropComplete={handleAvatarCropComplete}
onCancel={() => setAvatarCropperImage(null)}
/>
) : null}
</>
);
}

View File

@ -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 (
<Modal isOpen={open && !!chat} onClose={onClose} title="Редактировать чат" size="sm" closeOnEsc={actionLoading !== "edit"} footer={footerContent}>
<Modal
isOpen={open && !!chat}
onClose={onClose}
title="Редактировать чат"
size="sm"
closeOnEsc={actionLoading !== "edit" && !avatarCropperOpen}
closeOnClickOutside={!avatarCropperOpen}
footer={footerContent}
>
{chat && (
<div className="space-y-4">
<div>

View File

@ -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<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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<HTMLElement>(
'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(
<div
ref={modalRef}
onClick={handleBackdropClick}
@ -190,6 +199,7 @@ export function Modal({
{/* Bottom padding */}
{!noPadding && <div className="pb-4 sm:pb-6" />}
</div>
</div>
</div>,
document.body,
);
}

View File

@ -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<SearchResponse> {
async search(query: string, options: {
limit?: number;
offset?: number;
modelType?: SearchModelType;
} = {}): Promise<SearchResponse> {
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()}`);
}

17
frontend/src/lib/image.ts Normal file
View File

@ -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 });
}