diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index d342d08..0731741 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -192,15 +192,11 @@ white-space: pre-wrap; } - .message-reply { - background-color: rgba(255, 255, 255, 0.1); - border-radius: 8px; - padding: 0.5rem; - margin-bottom: 0.5rem; - border-left: 3px solid $color-dark-primary; + .quote.message-reply { user-select: none; + margin-bottom: 10px; - .reply-content { + .quote-inner { display: flex; flex-direction: column; gap: 0.2rem; @@ -289,10 +285,11 @@ .input-group { display: flex; + background-color: $color-dark-surface-container; + border-radius: 30px; + flex-direction: column; .chat-input { - background-color: $color-dark-surface-container; - border-radius: 30px; flex: 1; display: flex; flex-direction: row; @@ -313,24 +310,24 @@ height: 100%; width: 100%; } - } - .send-btn { - margin: 10px; - width: 50px; - height: 50px; - border-radius: 50%; - background-color: $color-dark-primary; - color: $color-dark-on-primary; - border: none; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.25s ease; - align-self: flex-end; - - @include hoverStateLayer($background: $color-dark-primary); + .send-btn { + margin: 10px; + width: 50px; + height: 50px; + border-radius: 50%; + background-color: $color-dark-primary; + color: $color-dark-on-primary; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.25s ease; + align-self: flex-end; + + @include hoverStateLayer($background: $color-dark-primary); + } } } } diff --git a/frontend/src/resources/css/common/_components.scss b/frontend/src/resources/css/common/_components.scss index cecb551..d2581f4 100644 --- a/frontend/src/resources/css/common/_components.scss +++ b/frontend/src/resources/css/common/_components.scss @@ -1,4 +1,5 @@ @use "material" as *; +@use "sass:color"; .text-center { text-align: center; @@ -123,4 +124,23 @@ button, input { overflow-y: hidden; background-color: transparent; display: block; +} + +.quote { + background-color: $color-dark-surface-primary-container-lightened; + border-radius: 8px; + overflow: hidden; + + &.bg-surfaceContainer { + background-color: $color-dark-secondary-container; + + .quote-inner { + border-left: 3px solid $color-dark-secondary; + } + } + + .quote-inner { + border-left: 3px solid $color-dark-primary; + padding: 0.5rem; + } } \ No newline at end of file diff --git a/frontend/src/resources/css/common/_material.scss b/frontend/src/resources/css/common/_material.scss index d2a5079..5317104 100644 --- a/frontend/src/resources/css/common/_material.scss +++ b/frontend/src/resources/css/common/_material.scss @@ -50,6 +50,9 @@ $color-dark-surface-container-low: rgb(24 28 31); $color-dark-surface-container: rgb(28 32 36); $color-dark-surface-container-high: rgb(38 43 46); $color-dark-surface-container-highest: rgb(49 53 57); +$color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%); +$color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%); + // custom colors $color-1: rgb(82, 109, 246); $color-2: rgb(65, 11, 113); diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index e7c93cc..065cdbf 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -1,14 +1,18 @@ import { useState } from "react"; import { RichTextArea } from "../core/RichTextArea"; import type { Message } from "../../../core/types"; +import Quote from "../core/Quote"; +import AnimatedHeight from "../core/animations/AnimatedHeight"; interface ChatInputWrapperProps { - onSendMessage: (message: string) => void | ((message: string) => void); + onSendMessage: (message: string) => void; replyTo?: Message | null; + replyToVisible: boolean; onClearReply?: () => void; + onCloseReply?: () => void; } -export function ChatInputWrapper({ onSendMessage, replyTo, onClearReply }: ChatInputWrapperProps) { +export function ChatInputWrapper({ onSendMessage, replyTo, replyToVisible, onClearReply, onCloseReply }: ChatInputWrapperProps) { const [message, setMessage] = useState(""); const handleSubmit = async (e: React.FormEvent | Event) => { @@ -23,15 +27,17 @@ export function ChatInputWrapper({ onSendMessage, replyTo, onClearReply }: ChatI return (
- {replyTo && ( -
-
- {replyTo.username} - {replyTo.content} + + {replyTo && ( +
+ + {replyTo!.username} + {replyTo!.content} + +
- -
- )} + )} +
-
- {message.reply_to.username} - {message.reply_to.content} -
-
+ + {message.reply_to.username} + {message.reply_to.content} + )}
diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index a6ad7f7..b442436 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -17,6 +17,13 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen const [switchOut, setSwitchOut] = useState(false); const messagesEndRef = useRef(null); const [replyTo, setReplyTo] = useState(null); + const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo)); + + useEffect(() => { + if (replyTo) { + setReplyToVisible(true); + } + }, [replyTo]); // Handle panel state changes useEffect(() => { @@ -144,8 +151,10 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen panel.handleSendMessage(text, replyTo?.id); setReplyTo(null); }} - replyTo={replyTo} - onClearReply={() => setReplyTo(null)} + replyTo={replyTo} + replyToVisible={replyToVisible} + onClearReply={() => setReplyToVisible(false)} + onCloseReply={() => setReplyTo(null)} />
diff --git a/frontend/src/ui/components/core/Quote.tsx b/frontend/src/ui/components/core/Quote.tsx new file mode 100644 index 0000000..39c9e77 --- /dev/null +++ b/frontend/src/ui/components/core/Quote.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +export interface QuoteProps { + className?: string; + children?: ReactNode; + background?: "surfaceContainer" | "primaryContainer" +} + +export default function Quote({ className, children, background = "primaryContainer" }: QuoteProps) { + return ( +
+
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx new file mode 100644 index 0000000..b4436bc --- /dev/null +++ b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState, useRef, type ReactNode } from "react"; + +export interface AnimatedHeightProps { + visible: any; + duration?: number; + onFinish?: () => void + children?: ReactNode; +} + +export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children }: AnimatedHeightProps) { + const [height, setHeight] = useState("0px"); + const [shouldRender, setShouldRender] = useState(visible); + const [isAnimating, setIsAnimating] = useState(false); + const contentRef = useRef(null); + const measureRef = useRef(null); + + useEffect(() => { + if (visible) { + setShouldRender(true); + setIsAnimating(true); + // Wait for content to render, then measure + setTimeout(() => { + if (measureRef.current) { + const contentHeight = measureRef.current.scrollHeight; + setHeight(`${contentHeight}px`); + } + // Animation complete + setTimeout(() => { + setIsAnimating(false); + }, duration * 1000); + }, 0); + } else { + if (shouldRender) { + setIsAnimating(true); + if (measureRef.current) { + const contentHeight = measureRef.current.scrollHeight; + setHeight(`${contentHeight}px`); + // Force a reflow before animating to 0 + requestAnimationFrame(() => { + setHeight("0px"); + }); + } + // Hide content after animation completes + setTimeout(() => { + setShouldRender(false); + setIsAnimating(false); + if (onFinish) { + onFinish(); + } + }, duration * 1000); + } + } + }, [visible, duration, shouldRender]); + + // Don't render if not visible and not animating + if (!visible && !shouldRender && !isAnimating) { + return null; + } + + return ( +
+
+ {shouldRender && children} +
+
+ ); +} \ No newline at end of file