From 0fbdcd04c94d7a3ca54373a1d20085e0db7f1994 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 25 Sep 2025 18:47:29 +0300 Subject: [PATCH] Implement file drag & drop --- backend/routes/messaging.py | 8 +-- frontend/src/resources/css/_chat.scss | 41 ++++++++++++ .../ui/components/chat/ChatInputWrapper.tsx | 16 ++++- .../components/chat/MessagePanelRenderer.tsx | 65 ++++++++++++++++++- .../core/animations/AnimatedHeight.tsx | 20 ++---- .../core/animations/AnimatedOpacity.tsx | 41 ++++++++++++ .../ui/components/core/animations/types.d.ts | 10 +++ 7 files changed, 181 insertions(+), 20 deletions(-) create mode 100644 frontend/src/ui/components/core/animations/AnimatedOpacity.tsx create mode 100644 frontend/src/ui/components/core/animations/types.d.ts diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index e17e0eb..b03c0e4 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -41,11 +41,9 @@ def convert_message(msg: Message) -> dict: "reply_to": convert_message(msg.reply_to) if msg.reply_to else None, "files": [ { - "path": f"/api/files/{'encrypted' if f.encrypted else 'normal'}/{Path(f.path).name}", - "encrypted": f.encrypted, - "filename": f.filename, - "content_type": f.content_type, - "size": f.size, + "path": f"/api/uploads/files/normal/{Path(f.path).name}", + "id": f.id, + "message_id": f.message_id } for f in (msg.files or []) ] diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index 6d5087b..ad46b9e 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -65,6 +65,7 @@ display: flex; flex-direction: column; height: 100%; + position: relative; .chat-header { padding: 16px; @@ -277,6 +278,46 @@ } } + .file-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + background: rgba(0, 0, 0, 0.5); + + z-index: 100; + + backdrop-filter: blur(20px); + + .file-overlay-wrapper { + border-radius: 30px; + outline: 3px dashed $color-dark-primary; + outline-offset: -20px; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + + .file-overlay-inner { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 16px; + background: rgba(18, 18, 18, 0.8); + border: 1px solid $color-dark-surface-container-high; + border-radius: 12px; + color: $color-dark-on-surface; + + mdui-icon { + color: $color-dark-primary; + } + } + } + } + .chat-input-wrapper { position: relative; margin: 0 20px 20px 20px; diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index e7b419d..9f5cc5e 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -16,6 +16,7 @@ interface ChatInputWrapperProps { editVisible?: boolean; onClearEdit?: () => void; onCloseEdit?: () => void; + onProvideFileAdder?: (adder: (files: File[]) => void) => void; } export function ChatInputWrapper( @@ -29,7 +30,8 @@ export function ChatInputWrapper( editingMessage, editVisible = false, onClearEdit, - onCloseEdit + onCloseEdit, + onProvideFileAdder }: ChatInputWrapperProps ) { const [message, setMessage] = useState(""); @@ -37,6 +39,18 @@ export function ChatInputWrapper( const [attachmentsVisible, setAttachmentsVisible] = useState(false); const [errorOpen, setErrorOpen] = useState(false); + // Expose a way for parent to programmatically add files + useEffect(() => { + if (onProvideFileAdder) { + const addFiles = (files: File[]) => { + if (!files || files.length === 0) return; + setSelectedFiles(prev => [...prev, ...files]); + setAttachmentsVisible(true); + }; + onProvideFileAdder(addFiles); + } + }, [onProvideFileAdder]); + // When entering edit mode, preload the message content useEffect(() => { if (editingMessage) { diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index 76939f0..7febc76 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -5,6 +5,7 @@ import { ChatInputWrapper } from "./ChatInputWrapper"; import { setGlobalMessageHandler } from "../../../core/websocket"; import type { Message } from "../../../core/types"; import defaultAvatar from "../../../resources/images/default-avatar.png"; +import AnimatedOpacity from "../core/animations/AnimatedOpacity"; interface MessagePanelRendererProps { panel: MessagePanel | null; @@ -22,6 +23,20 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen const [editVisible, setEditVisible] = useState(Boolean(editMessage)); const [pendingAction, setPendingAction] = useState(null); + // Drag & drop + const [isDragging, setIsDragging] = useState(false); + const dragCounterRef = useRef(0); + const addFilesRef = useRef void)>(null); + + useEffect(() => { + if (!panel || !panelState) return; + + return () => { + dragCounterRef.current = 0; + setIsDragging(false); + }; + }, [panel, panelState]); + useEffect(() => { if (replyTo) { setReplyToVisible(true); @@ -116,7 +131,41 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen return (
-
+
{ + if (!e.dataTransfer) return; + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current += 1; + // Only show overlay when actual files are dragged + const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files"); + if (hasFiles) setIsDragging(true); + }} + onDragOver={(e) => { + if (!e.dataTransfer) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) setIsDragging(false); + }} + onDrop={(e) => { + if (!e.dataTransfer) return; + e.preventDefault(); + e.stopPropagation(); + const files = Array.from(e.dataTransfer.files || []); + if (files.length > 0 && addFilesRef.current) { + addFilesRef.current(files); + } + setIsDragging(false); + dragCounterRef.current = 0; + }}>
)} + + e.preventDefault()} + onDrop={(e) => e.preventDefault()}> +
+
+ + Отпустите файл(ы) для добавления +
+
+
{ @@ -213,6 +275,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen setPendingAction(null); } }} + onProvideFileAdder={(adder) => { addFilesRef.current = adder; }} />
diff --git a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx index b4436bc..4c735f5 100644 --- a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx +++ b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx @@ -1,17 +1,10 @@ -import { useEffect, useState, useRef, type ReactNode } from "react"; +import { useEffect, useState, useRef } from "react"; +import type { AnimatedPropertyProps } from "./types"; -export interface AnimatedHeightProps { - visible: any; - duration?: number; - onFinish?: () => void - children?: ReactNode; -} - -export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children }: AnimatedHeightProps) { +export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) { const [height, setHeight] = useState("0px"); const [shouldRender, setShouldRender] = useState(visible); const [isAnimating, setIsAnimating] = useState(false); - const contentRef = useRef(null); const measureRef = useRef(null); useEffect(() => { @@ -50,7 +43,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi }, duration * 1000); } } - }, [visible, duration, shouldRender]); + }, [visible, shouldRender]); // Don't render if not visible and not animating if (!visible && !shouldRender && !isAnimating) { @@ -59,11 +52,12 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi return (
diff --git a/frontend/src/ui/components/core/animations/AnimatedOpacity.tsx b/frontend/src/ui/components/core/animations/AnimatedOpacity.tsx new file mode 100644 index 0000000..2ce6ad4 --- /dev/null +++ b/frontend/src/ui/components/core/animations/AnimatedOpacity.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import type { AnimatedPropertyProps } from "./types"; + +export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) { + const [opacity, setOpacity] = useState(visible ? 1 : 0); + const [shouldRender, setShouldRender] = useState(visible); + + useEffect(() => { + if (visible) { + setShouldRender(true); + setOpacity(0); + + // Wait for content to render, then animate in + const id = setTimeout(() => { + setOpacity(1); + }, 10); + return () => clearTimeout(id); + } else { + setOpacity(0); + + const id = setTimeout(() => { + setShouldRender(false); + if (onFinish) { + onFinish(); + } + }, duration * 1000); + return () => clearTimeout(id); + } + }, [visible, duration, onFinish]); + + return shouldRender && ( +
{children}
+ ); +} \ No newline at end of file diff --git a/frontend/src/ui/components/core/animations/types.d.ts b/frontend/src/ui/components/core/animations/types.d.ts new file mode 100644 index 0000000..f431740 --- /dev/null +++ b/frontend/src/ui/components/core/animations/types.d.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export interface BaseAnimatedPropertyProps { + visible: any; + duration?: number; + onFinish?: () => void + children?: ReactNode; +} + +export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div"> \ No newline at end of file