improve task board controls and media previews
This commit is contained in:
parent
2fec931bda
commit
fad399cd47
@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
@ -44,6 +46,7 @@ import {
|
||||
ListChecks,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Maximize2,
|
||||
Newspaper,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
@ -79,6 +82,8 @@ import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator";
|
||||
import TaskCoverPreview from "@/components/tasks/TaskCoverPreview";
|
||||
import TaskCommentAttachment from "@/components/tasks/TaskCommentAttachment";
|
||||
import TaskAutomationManager from "@/components/tasks/TaskAutomationManager";
|
||||
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
|
||||
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import { Modal } from "@/components/ui";
|
||||
@ -319,6 +324,7 @@ const priorityOptions: {
|
||||
className: string;
|
||||
textClassName: string;
|
||||
dotClassName: string;
|
||||
selectedClassName: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "low",
|
||||
@ -327,6 +333,7 @@ const priorityOptions: {
|
||||
className: "app-badge",
|
||||
textClassName: "app-text-muted",
|
||||
dotClassName: "bg-slate-400",
|
||||
selectedClassName: "bg-slate-500",
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
@ -335,6 +342,7 @@ const priorityOptions: {
|
||||
className: "app-selected",
|
||||
textClassName: "text-[var(--accent-primary-strong)]",
|
||||
dotClassName: "bg-sky-500",
|
||||
selectedClassName: "bg-sky-500",
|
||||
},
|
||||
{
|
||||
value: "high",
|
||||
@ -343,6 +351,7 @@ const priorityOptions: {
|
||||
className: "app-feedback-warning",
|
||||
textClassName: "text-[var(--warning-foreground)]",
|
||||
dotClassName: "bg-amber-500",
|
||||
selectedClassName: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
value: "critical",
|
||||
@ -351,6 +360,7 @@ const priorityOptions: {
|
||||
className: "app-feedback-danger",
|
||||
textClassName: "text-[var(--danger-foreground)]",
|
||||
dotClassName: "bg-red-500",
|
||||
selectedClassName: "bg-red-500",
|
||||
},
|
||||
];
|
||||
|
||||
@ -445,6 +455,116 @@ function isMediaAttachment(attachment: TaskAttachment): boolean {
|
||||
return isImageAttachment(attachment) || isVideoAttachment(attachment);
|
||||
}
|
||||
|
||||
function TaskAttachmentThumbnail({
|
||||
attachment,
|
||||
onPreview,
|
||||
}: {
|
||||
attachment: TaskAttachment;
|
||||
onPreview: (preview: MediaPreview) => void;
|
||||
}) {
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const image = isImageAttachment(attachment);
|
||||
const video = isVideoAttachment(attachment);
|
||||
const attachmentId = attachment.id;
|
||||
const taskId = attachment.task;
|
||||
const downloadUrl = attachment.download_url;
|
||||
const fileName = attachment.file_name;
|
||||
|
||||
useEffect(() => {
|
||||
if (!image && !video) return;
|
||||
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
void apiClient.downloadTaskAttachment(taskId, {
|
||||
id: attachmentId,
|
||||
download_url: downloadUrl,
|
||||
file_name: fileName,
|
||||
})
|
||||
.then(({ blob }) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSource(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attachmentId, downloadUrl, fileName, image, taskId, video]);
|
||||
|
||||
if (!image && !video) {
|
||||
return (
|
||||
<span className="app-selected flex h-9 w-11 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!source || failed}
|
||||
onClick={() => {
|
||||
if (!source || failed) return;
|
||||
onPreview({
|
||||
type: video ? "video" : "image",
|
||||
src: source,
|
||||
name: fileName,
|
||||
});
|
||||
}}
|
||||
className="group relative flex h-9 w-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-[var(--surface-secondary)] disabled:cursor-default"
|
||||
title={source && !failed ? `Просмотреть ${fileName}` : undefined}
|
||||
aria-label={`Предпросмотр ${fileName}`}
|
||||
>
|
||||
{failed ? (
|
||||
<FileText size={15} className="app-text-muted" />
|
||||
) : source && image ? (
|
||||
<Image
|
||||
src={source}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
sizes="44px"
|
||||
className="object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : source && video ? (
|
||||
<>
|
||||
<video
|
||||
src={source}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
onLoadedMetadata={(event) => {
|
||||
const media = event.currentTarget;
|
||||
if (Number.isFinite(media.duration) && media.duration > 0.2) {
|
||||
media.currentTime = Math.min(0.25, media.duration / 10);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20 text-white transition-opacity group-hover:opacity-0">
|
||||
<Play size={13} className="translate-x-px drop-shadow" fill="currentColor" />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Loader2 size={14} className="app-text-muted animate-spin" />
|
||||
)}
|
||||
{source && !failed ? (
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<Eye size={15} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
if (file.type.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(file.name);
|
||||
@ -837,16 +957,26 @@ function QuickTaskComposer({
|
||||
type="button"
|
||||
onClick={() => onPriorityChange(option.value)}
|
||||
disabled={saving}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full transition disabled:opacity-50 ${
|
||||
className={`flex h-6 min-w-6 items-center overflow-hidden rounded-full transition-all duration-200 ease-out disabled:opacity-50 ${
|
||||
selected
|
||||
? "bg-[var(--surface-tertiary)] ring-2 ring-[var(--accent-primary)]"
|
||||
: "hover:bg-[var(--surface-tertiary)]"
|
||||
? `max-w-28 px-2 shadow-sm ${option.selectedClassName}`
|
||||
: "w-6 max-w-6 justify-center px-0 hover:bg-[var(--surface-tertiary)]"
|
||||
}`}
|
||||
title={`Приоритет: ${option.label}`}
|
||||
aria-label={`Приоритет: ${option.label}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className={`h-3 w-3 rounded-full ${option.dotClassName}`} aria-hidden="true" />
|
||||
{!selected ? (
|
||||
<span
|
||||
className={`h-3 w-3 shrink-0 rounded-full ${option.dotClassName}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{selected ? (
|
||||
<span className="whitespace-nowrap text-[10px] font-semibold leading-none text-white">
|
||||
{option.label}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -877,6 +1007,90 @@ function QuickTaskComposer({
|
||||
);
|
||||
}
|
||||
|
||||
function TaskAddMenuPopover({
|
||||
anchorRef,
|
||||
menuRef,
|
||||
placement,
|
||||
children,
|
||||
}: {
|
||||
anchorRef: RefObject<HTMLDivElement | null>;
|
||||
menuRef: RefObject<HTMLDivElement | null>;
|
||||
placement: "top" | "bottom";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [position, setPosition] = useState<{
|
||||
left: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const menuWidth = 240;
|
||||
const viewportGap = 8;
|
||||
const menuGap = 8;
|
||||
const preferredLeft = placement === "bottom"
|
||||
? rect.right - menuWidth
|
||||
: rect.left;
|
||||
const left = Math.min(
|
||||
Math.max(viewportGap, preferredLeft),
|
||||
Math.max(viewportGap, viewportWidth - menuWidth - viewportGap),
|
||||
);
|
||||
|
||||
if (placement === "bottom") {
|
||||
setPosition({
|
||||
left,
|
||||
bottom: viewportHeight - rect.top + menuGap,
|
||||
maxHeight: Math.max(96, rect.top - viewportGap - menuGap),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition({
|
||||
left,
|
||||
top: rect.bottom + menuGap,
|
||||
maxHeight: Math.max(
|
||||
96,
|
||||
viewportHeight - rect.bottom - viewportGap - menuGap,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [anchorRef, placement]);
|
||||
|
||||
if (!position || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="app-menu fixed z-[220] w-60 overflow-y-auto overscroll-contain rounded-xl p-1.5 shadow-xl"
|
||||
style={{
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
bottom: position.bottom,
|
||||
maxHeight: position.maxHeight,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type TaskColumnCardsProps = {
|
||||
tasks: TaskCard[];
|
||||
onOpenTask: (task: TaskCard) => void;
|
||||
@ -3411,6 +3625,7 @@ function TasksPageContent() {
|
||||
const [taskAttachments, setTaskAttachments] = useState<TaskAttachment[]>([]);
|
||||
const [taskAttachmentsLoading, setTaskAttachmentsLoading] = useState(false);
|
||||
const [taskAttachmentsUploading, setTaskAttachmentsUploading] = useState(false);
|
||||
const [taskMediaPreview, setTaskMediaPreview] = useState<MediaPreview | null>(null);
|
||||
const [checklistOpen, setChecklistOpen] = useState(false);
|
||||
const [taskChecklist, setTaskChecklist] = useState<TaskChecklistItem[]>([]);
|
||||
const [taskChecklistLoaded, setTaskChecklistLoaded] = useState(false);
|
||||
@ -3428,6 +3643,7 @@ function TasksPageContent() {
|
||||
const taskMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const taskAddMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const taskAddMenuBottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const taskAddMenuPopoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewColumnMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const columnMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -4036,6 +4252,7 @@ function TasksPageContent() {
|
||||
if (
|
||||
activeMenuRef.current
|
||||
&& !activeMenuRef.current.contains(event.target as Node)
|
||||
&& !taskAddMenuPopoverRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setTaskAddMenuOpen(false);
|
||||
}
|
||||
@ -4555,6 +4772,7 @@ function TasksPageContent() {
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft("");
|
||||
setTaskAttachments([]);
|
||||
setTaskMediaPreview(null);
|
||||
setTaskChecklist([]);
|
||||
setTaskChecklistLoaded(false);
|
||||
setTaskChecklistDraft("");
|
||||
@ -4599,6 +4817,7 @@ function TasksPageContent() {
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft("");
|
||||
setTaskAttachments([]);
|
||||
setTaskMediaPreview(null);
|
||||
setTaskChecklist([]);
|
||||
setTaskChecklistLoaded(false);
|
||||
setTaskActivities([]);
|
||||
@ -6831,6 +7050,18 @@ function TasksPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!desktopWideMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeDesktopWideMode(true)}
|
||||
className="app-action-ghost hidden h-8 w-8 items-center justify-center rounded-md lg:flex"
|
||||
title="Развернуть доску"
|
||||
aria-label="Развернуть доску"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCreateTask()}
|
||||
@ -7715,11 +7946,8 @@ function TasksPageContent() {
|
||||
onClose={closeTaskView}
|
||||
title="Задача"
|
||||
size="md"
|
||||
className={taskAddMenuOpen
|
||||
? "min-h-[min(34rem,95vh)] sm:min-h-[min(34rem,90vh)]"
|
||||
: ""
|
||||
}
|
||||
closeOnClickOutside
|
||||
closeOnEsc={!taskMediaPreview}
|
||||
footer={viewTask ? (
|
||||
<div ref={taskAddMenuBottomRef} className="relative flex justify-end">
|
||||
<button
|
||||
@ -7740,9 +7968,13 @@ function TasksPageContent() {
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
{taskAddMenuOpen && taskAddMenuPlacement === "bottom" ? (
|
||||
<div className="app-menu absolute bottom-full right-0 z-40 mb-2 w-60 rounded-xl p-1.5">
|
||||
<TaskAddMenuPopover
|
||||
anchorRef={taskAddMenuBottomRef}
|
||||
menuRef={taskAddMenuPopoverRef}
|
||||
placement="bottom"
|
||||
>
|
||||
{taskAddMenuItems}
|
||||
</div>
|
||||
</TaskAddMenuPopover>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined}
|
||||
@ -7778,9 +8010,13 @@ function TasksPageContent() {
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
{taskAddMenuOpen && taskAddMenuPlacement === "top" ? (
|
||||
<div className="app-menu absolute left-0 top-full z-40 mt-2 w-60 rounded-xl p-1.5">
|
||||
<TaskAddMenuPopover
|
||||
anchorRef={taskAddMenuRef}
|
||||
menuRef={taskAddMenuPopoverRef}
|
||||
placement="top"
|
||||
>
|
||||
{taskAddMenuItems}
|
||||
</div>
|
||||
</TaskAddMenuPopover>
|
||||
) : null}
|
||||
</div>
|
||||
{!viewTask.assignee && !viewTask.completed_at ? (
|
||||
@ -8364,15 +8600,16 @@ function TasksPageContent() {
|
||||
key={attachment.id}
|
||||
className="flex items-center gap-2 border-b border-[var(--border-subtle)] px-3 py-2.5 last:border-b-0"
|
||||
>
|
||||
<TaskAttachmentThumbnail
|
||||
attachment={attachment}
|
||||
onPreview={setTaskMediaPreview}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void downloadViewedTaskAttachment(attachment)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
title={`Скачать ${attachment.file_name}`}
|
||||
>
|
||||
<span className="app-selected flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{attachment.file_name}
|
||||
@ -8951,6 +9188,13 @@ function TasksPageContent() {
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{taskMediaPreview ? (
|
||||
<ChatMediaPreviewModal
|
||||
preview={taskMediaPreview}
|
||||
onClose={() => setTaskMediaPreview(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
isOpen={coverPickerOpen}
|
||||
onClose={() => {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { MediaPreview } from "@/components/messages/ChatMessageItem";
|
||||
|
||||
@ -11,8 +12,19 @@ type ChatMediaPreviewModalProps = {
|
||||
};
|
||||
|
||||
export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPreviewModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape, true);
|
||||
return () => document.removeEventListener("keydown", handleEscape, true);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div data-overlay-root="true" className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
|
||||
<div data-overlay-root="true" className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 p-4" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@ -38,4 +50,4 @@ export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPre
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Folder,
|
||||
FolderInput,
|
||||
Kanban,
|
||||
LockKeyhole,
|
||||
@ -92,6 +93,7 @@ function BoardRow({
|
||||
onToggleNotifications,
|
||||
onMove,
|
||||
onViewParticipants,
|
||||
groupName,
|
||||
compact = false,
|
||||
}: {
|
||||
board: TaskBoardSummary;
|
||||
@ -107,6 +109,7 @@ function BoardRow({
|
||||
onToggleNotifications: () => void;
|
||||
onMove: () => void;
|
||||
onViewParticipants: () => void;
|
||||
groupName?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (compact) {
|
||||
@ -179,6 +182,12 @@ function BoardRow({
|
||||
{board.access_scope === "private" ? <LockKeyhole size={11} /> : <Users size={11} />}
|
||||
{board.access_scope === "private" ? "Личная" : "Общая"}
|
||||
</span>
|
||||
{groupName ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1" title={`Группа: ${groupName}`}>
|
||||
<Folder size={11} className="shrink-0" />
|
||||
<span className="max-w-32 truncate">{groupName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -344,6 +353,10 @@ export default function TaskBoardNavigator({
|
||||
])),
|
||||
};
|
||||
}, [filteredBoards, groups, unreadCounts]);
|
||||
const groupNames = useMemo(
|
||||
() => new Map(groups.map((group) => [group.id, group.name])),
|
||||
[groups],
|
||||
);
|
||||
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
|
||||
const compactNavigator = wideMode && navigatorCollapsed;
|
||||
const filteredMoveGroups = useMemo(() => {
|
||||
@ -691,6 +704,7 @@ export default function TaskBoardNavigator({
|
||||
<BoardRow
|
||||
key={board.id}
|
||||
board={board}
|
||||
groupName={board.group_id ? groupNames.get(board.group_id) : undefined}
|
||||
active={selectedBoardId === board.id}
|
||||
unread={unreadCounts[board.id] || 0}
|
||||
compact={compactNavigator}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user