Add search pagination and improve avatar and document overlays
This commit is contained in:
parent
9462788220
commit
63ed6f6e38
@ -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)
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* Контролы масштабирования */}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
17
frontend/src/lib/image.ts
Normal 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 });
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user