refactor: update chat ui (#13269)

### What problem does this PR solve?

Update **Chat** UI:
- Align to the design.
- Update `<AudioButton>` visualizer logic.
- Fix keyboard navigation issue.

### Type of change

- [x] Refactoring
This commit is contained in:
Jimmy Ben Klieve
2026-02-27 22:26:19 +08:00
committed by GitHub
parent 4e48aba5c4
commit c0823e8d6d
23 changed files with 785 additions and 602 deletions

View File

@@ -1,5 +1,5 @@
import { transformFile2Base64 } from '@/utils/file-util';
import { Pencil, Plus, XIcon } from 'lucide-react';
import { LucidePencil, LucidePlus, LucideX } from 'lucide-react';
import {
ChangeEventHandler,
forwardRef,
@@ -45,7 +45,7 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
}
ev.target.value = '';
},
[onChange],
[],
);
const handleRemove = useCallback(() => {
@@ -249,30 +249,36 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
};
}
}, 100);
}, [handleWheel, containerRef.current]);
}, [handleWheel, imageToCrop, isCropModalOpen]);
return (
<div className="flex justify-start items-end space-x-2">
<div className="relative group">
{!avatarBase64Str ? (
<div className="w-[64px] h-[64px] grid place-content-center border border-dashed bg-bg-input rounded-md">
<div className="flex flex-col items-center">
<Plus />
<p>{t('common.upload')}</p>
</div>
<div
className="
border border-dashed border-borer-button rounded-md size-16
flex flex-col gap-1 justify-center items-center text-sm text-text-secondary transition-colors
group-has-[input:focus-visible]:border-accent-primary group-has-[input:focus-visible]:text-text-primary"
>
<LucidePlus className="size-4" />
<span>{t('common.upload')}</span>
</div>
) : (
<div className="w-[64px] h-[64px] relative grid place-content-center">
<Avatar className="w-[64px] h-[64px] rounded-md">
<div className="size-16 relative grid place-content-center">
<Avatar className="size-16 rounded-md">
<AvatarImage className="block" src={avatarBase64Str} alt="" />
<AvatarFallback></AvatarFallback>
</Avatar>
<div className="absolute inset-0 bg-[#000]/20 group-hover:bg-[#000]/60">
<Pencil
size={20}
className="absolute right-2 bottom-0 opacity-50 hidden group-hover:block"
/>
<div
className="
absolute inset-0 bg-black/50 flex items-center justify-center
transition-opacity opacity-0 group-hover:opacity-100 group-has-[input:focus-visible]:opacity-100"
>
<LucidePencil className="size-5 opacity-75" />
</div>
<Button
onClick={handleRemove}
size="icon"
@@ -280,10 +286,11 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
aria-label="Remove image"
type="button"
>
<XIcon className="size-3.5" />
<LucideX className="size-3" />
</Button>
</div>
)}
<Input
placeholder=""
type="file"
@@ -294,7 +301,7 @@ export const AvatarUpload = forwardRef<HTMLInputElement, AvatarUploadProps>(
ref={ref}
/>
</div>
<div className="margin-1 text-text-secondary">
<div className="margin-1 text-sm text-text-secondary">
{tips ?? t('knowledgeConfiguration.photoTip')}
</div>

View File

@@ -9,7 +9,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { AlertDialogOverlay } from '@radix-ui/react-alert-dialog';
import { DialogProps } from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -55,53 +55,48 @@ export function ConfirmDeleteDialog({
defaultOpen={defaultOpen}
>
{children && <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>}
<AlertDialogOverlay
onClick={(e) => {
e.stopPropagation();
}}
<AlertDialogContent
onSelect={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
className="bg-bg-base "
>
<AlertDialogContent
onSelect={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
className="bg-bg-base "
>
<AlertDialogHeader className="space-y-5">
<AlertDialogTitle>
{title ?? t('common.deleteModalTitle')}
<AlertDialogCancel
onClick={onCancel}
className="border-none bg-transparent hover:border-none hover:bg-transparent absolute right-3 top-3 hover:text-text-primary"
>
<X size={16} />
</AlertDialogCancel>
</AlertDialogTitle>
{content && (
<>
<Separator className="w-[calc(100%+48px)] -translate-x-6"></Separator>
<AlertDialogDescription className="mt-5">
<div className="flex flex-col gap-5 text-base mb-10 px-5">
<div className="text-text-primary">
{content.title || t('common.deleteModalTitle')}
</div>
{content.node}
</div>
</AlertDialogDescription>
</>
)}
</AlertDialogHeader>
<AlertDialogFooter className="px-5 flex items-center gap-2">
<AlertDialogCancel onClick={onCancel}>
{cancelButtonText || t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
className="bg-state-error text-text-primary hover:text-text-primary hover:bg-state-error"
onClick={onOk}
<AlertDialogHeader className="space-y-5">
<AlertDialogTitle>
{title ?? t('common.deleteModalTitle')}
<AlertDialogCancel
onClick={onCancel}
className="border-none bg-transparent hover:border-none hover:bg-transparent absolute right-3 top-3 hover:text-text-primary"
>
{okButtonText || t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
<X size={16} />
</AlertDialogCancel>
</AlertDialogTitle>
{content && (
<>
<Separator className="w-[calc(100%+48px)] -translate-x-6"></Separator>
<AlertDialogDescription className="mt-5">
<div className="flex flex-col gap-5 text-base mb-10 px-5">
<div className="text-text-primary">
{content.title || t('common.deleteModalTitle')}
</div>
{content.node}
</div>
</AlertDialogDescription>
</>
)}
</AlertDialogHeader>
<AlertDialogFooter className="px-5 flex items-center gap-2">
<AlertDialogCancel onClick={onCancel}>
{cancelButtonText || t('common.cancel')}
</AlertDialogCancel>
<AlertDialogAction
className="bg-state-error text-text-primary hover:text-text-primary hover:bg-state-error"
onClick={onOk}
>
{okButtonText || t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,10 +1,16 @@
import { useTranslate } from '@/hooks/common-hooks';
import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
import { cn } from '@/lib/utils';
import { LucideCheck, LucideCopy } from 'lucide-react';
import { useState } from 'react';
import { CopyToClipboard as Clipboard, Props } from 'react-copy-to-clipboard';
import { CopyToClipboard as Clipboard } from 'react-copy-to-clipboard';
import { Button, ButtonProps } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
const CopyToClipboard = ({ text }: Props) => {
const CopyToClipboard = ({
text,
className,
...buttonProps
}: { text: string } & ButtonProps) => {
const [copied, setCopied] = useState(false);
const { t } = useTranslate('common');
@@ -16,12 +22,19 @@ const CopyToClipboard = ({ text }: Props) => {
};
return (
<Tooltip>
<TooltipTrigger>
<Clipboard text={text} onCopy={handleCopy}>
{copied ? <CheckOutlined /> : <CopyOutlined />}
</Clipboard>
</TooltipTrigger>
<Tooltip open={copied ? true : undefined}>
<Clipboard text={text} onCopy={handleCopy}>
<TooltipTrigger asChild>
<Button
variant="transparent"
size="icon-sm"
{...buttonProps}
className={cn(className, copied && '!text-state-success')}
>
{copied ? <LucideCheck /> : <LucideCopy />}
</Button>
</TooltipTrigger>
</Clipboard>
<TooltipContent>{copied ? t('copied') : t('copy')}</TooltipContent>
</Tooltip>
);

View File

@@ -14,7 +14,6 @@ import {
} from '@/components/file-upload';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { t } from 'i18next';
import {
Atom,
@@ -159,7 +158,7 @@ export function NextMessageInput({
onValueChange={setFiles}
onUpload={onUpload}
onFileReject={onFileReject}
className="relative w-full items-center "
className="relative w-full items-center"
disabled={isUploading || disabled}
>
<FileUploadDropzone
@@ -178,9 +177,14 @@ export function NextMessageInput({
</p>
</div>
</FileUploadDropzone>
<form
onSubmit={onSubmit}
className="relative flex w-full flex-col gap-2.5 rounded-md border border-input px-3 py-2 outline-none focus-within:ring-1 focus-within:ring-ring/50"
className="
relative flex w-full flex-col gap-2.5 rounded-md
border-0.5 border-border-default bg-bg-card p-2 outline-none
has-[textarea:focus]:outline-accent-primary has-[textarea:focus]:outline-1 has-[textarea:focus]:outline-offset-2
"
>
<FileUploadList
orientation="horizontal"
@@ -205,25 +209,29 @@ export function NextMessageInput({
</FileUploadItem>
))}
</FileUploadList>
<Textarea
value={value}
onChange={onInputChange}
placeholder={t('chat.messagePlaceholder')}
className="min-h-10 max-h-40 w-full border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 dark:bg-transparent"
className="
min-h-10 max-h-40 w-full p-0 overflow-auto
!outline-none !border-transparent !bg-transparent !shadow-none !ring-transparent !ring-offset-transparent
"
disabled={isUploading || disabled || sendLoading}
onKeyDown={handleKeyDown}
autoSize={{ minRows: 1, maxRows: 8 }}
resize={resize}
autoSize={{ minRows: 2, maxRows: 8 }}
/>
<div className={cn('flex items-center justify-between gap-1.5')}>
<div className="flex items-center gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{showUploadIcon && (
<FileUploadTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 rounded-sm"
size="icon-xs"
variant="transparent"
className="rounded-sm border-0"
disabled={isUploading || sendLoading}
>
<Paperclip className="size-3.5" />
@@ -231,40 +239,35 @@ export function NextMessageInput({
</Button>
</FileUploadTrigger>
)}
{showReasoning && (
<Button
type="button"
variant="ghost"
className={cn(
'rounded-sm h-7 focus-visible:bg-none! hover:bg-none!',
{
'bg-accent-primary text-white': enableThinking,
},
)}
size="sm"
variant={enableThinking ? 'accent' : 'transparent'}
className="border-0 h-7 text-sm"
onClick={handleThinkingToggle}
>
<Atom />
<span>Thinking</span>
</Button>
)}
{showInternet && (
<Button
type="button"
variant="ghost"
className={cn(
'rounded-sm h-7 focus-visible:bg-none! hover:bg-none!',
{
'bg-accent-primary text-white': enableInternet,
},
)}
variant={enableInternet ? 'accent' : 'transparent'}
size="icon-xs"
className="border-0"
onClick={handleInternetToggle}
>
<Globe />
</Button>
)}
</div>
{sendLoading ? (
<Button onClick={stopOutputMessage} className="size-5 rounded-sm">
<Button onClick={stopOutputMessage} size="icon-xs">
<CircleStop />
</Button>
) : (
@@ -274,8 +277,9 @@ export function NextMessageInput({
setAudioInputValue(value);
}}
/>
<Button
className="size-5 rounded-sm"
size="icon-xs"
disabled={
sendDisabled || isUploading || sendLoading || !value.trim()
}

View File

@@ -1,6 +1,5 @@
import { PromptIcon } from '@/assets/icon/next-icon';
import CopyToClipboard from '@/components/copy-to-clipboard';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
@@ -8,18 +7,20 @@ import {
} from '@/components/ui/tooltip';
import { useSetModalState } from '@/hooks/common-hooks';
import { IRemoveMessageById } from '@/hooks/logic-hooks';
import { cn } from '@/lib/utils';
import {
DeleteOutlined,
DislikeOutlined,
LikeOutlined,
PauseCircleOutlined,
SoundOutlined,
SyncOutlined,
} from '@ant-design/icons';
LucidePauseCircle,
LucideRefreshCw,
LucideThumbsDown,
LucideThumbsUp,
LucideTrash2,
LucideVolume2,
} from 'lucide-react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import FeedbackDialog from '../feedback-dialog';
import { PromptDialog } from '../prompt-dialog';
import { Button } from '../ui/button';
import { useRemoveMessage, useSendFeedback, useSpeech } from './hooks';
interface IProps {
@@ -55,44 +56,57 @@ export const AssistantGroupButton = ({
return (
<>
<ToggleGroup
type="single"
size="sm"
variant="outline"
className="space-x-1"
>
<ToggleGroupItem value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</ToggleGroupItem>
<div className="flex gap-1" role="toolbar">
<CopyToClipboard text={content} className="border-0" size="icon-xs" />
{showLoudspeaker && (
<ToggleGroupItem value="b" onClick={handleRead}>
<>
<Tooltip>
<TooltipTrigger asChild>
<span>
{isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />}
</span>
<Button
variant="transparent"
size="icon-xs"
className="border-0"
onClick={handleRead}
>
<span>
{isPlaying ? <LucidePauseCircle /> : <LucideVolume2 />}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t('chat.read')}</TooltipContent>
</Tooltip>
<audio src="" ref={ref}></audio>
</ToggleGroupItem>
</>
)}
{showLikeButton && (
<>
<ToggleGroupItem value="c" onClick={handleLike}>
<LikeOutlined />
</ToggleGroupItem>
<ToggleGroupItem value="d" onClick={showModal}>
<DislikeOutlined />
</ToggleGroupItem>
<Button
variant="transparent"
size="icon-xs"
className="border-0"
onClick={handleLike}
>
<LucideThumbsUp />
</Button>
<Button
variant="transparent"
size="icon-xs"
className="border-0"
onClick={showModal}
>
<LucideThumbsDown />
</Button>
</>
)}
{prompt && (
<ToggleGroupItem value="e" onClick={showPromptModal}>
<Button onClick={showPromptModal}>
<PromptIcon style={{ fontSize: '16px' }} />
</ToggleGroupItem>
</Button>
)}
</ToggleGroup>
</div>
{visible && (
<FeedbackDialog
visible={visible}
@@ -133,39 +147,41 @@ export const UserGroupButton = ({
const { t } = useTranslation();
return (
<ToggleGroup
type="single"
size="sm"
variant="outline"
className="space-x-1"
>
<ToggleGroupItem value="a">
<CopyToClipboard text={content}></CopyToClipboard>
</ToggleGroupItem>
<div className="flex gap-1">
<CopyToClipboard text={content} className="border-0" size="icon-xs" />
{regenerateMessage && (
<ToggleGroupItem
value="b"
onClick={regenerateMessage}
disabled={sendLoading}
>
<Tooltip>
<TooltipTrigger asChild>
<SyncOutlined spin={sendLoading} />
</TooltipTrigger>
<TooltipContent>{t('chat.regenerate')}</TooltipContent>
</Tooltip>
</ToggleGroupItem>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="transparent"
size="icon-xs"
className="border-0"
onClick={regenerateMessage}
disabled={sendLoading}
>
<LucideRefreshCw className={cn(sendLoading && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('chat.regenerate')}</TooltipContent>
</Tooltip>
)}
{removeMessageById && (
<ToggleGroupItem value="c" onClick={onRemoveMessage} disabled={loading}>
<Tooltip>
<TooltipTrigger asChild>
<DeleteOutlined spin={loading} />
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
</ToggleGroupItem>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="transparent"
size="icon-xs"
className="border-0"
onClick={onRemoveMessage}
disabled={loading}
>
<LucideTrash2 />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
)}
</ToggleGroup>
</div>
);
};

View File

@@ -32,6 +32,7 @@
.messageTextDark {
.chunkText();
.messageTextBase();
padding: 0;
word-break: break-word;
:global(section.think) {
color: rgb(166, 166, 166);

View File

@@ -4,14 +4,15 @@ import React from 'react';
import { Button, ButtonProps } from './ui/button';
export const MoreButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, size, ...props }, ref) => {
function MoreButton({ className, size, ...props }, ref) {
return (
<Button
ref={ref}
variant="ghost"
size={size || 'icon'}
className={cn(
'invisible group-hover:visible size-3.5 bg-transparent group-hover:bg-transparent',
'invisible size-3.5 bg-transparent group-hover:bg-transparent',
'group-focus-within:visible group-hover:visible aria-expanded:visible',
className,
)}
{...props}

View File

@@ -5,11 +5,13 @@ import { Authorization } from '@/constants/authorization';
import { cn } from '@/lib/utils';
import api from '@/utils/api';
import { getAuthorization } from '@/utils/authorization-util';
import { chain, sum } from 'lodash';
import { Loader2, Mic, Square } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIsDarkTheme } from '../theme-provider';
import { Input } from './input';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const audioContextRef = useRef<AudioContext | null>(null);
@@ -18,7 +20,79 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
const streamRef = useRef<MediaStream | null>(null);
const isDark = useIsDarkTheme();
const startVisualization = async () => {
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const analyser = analyserRef.current;
if (!analyser) return;
// Set canvas dimensions
const width = canvas.clientWidth * window.devicePixelRatio;
const height = canvas.clientHeight * window.devicePixelRatio;
const centerY = height / 2;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Get frequency data
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
/**
* The frequencies are spread linearly from 0Hz to 1/2 of the sample rate
* @see https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode/getByteFrequencyData
*/
analyser.getByteFrequencyData(dataArray);
const desiredBarCount = 6;
const freqSpanPerPole =
analyser.context.sampleRate / 2 / analyser.frequencyBinCount;
const freqMinIndex = Math.max(1, Math.floor(100 / freqSpanPerPole));
const freqMaxIndex =
Math.max(freqMinIndex, Math.ceil(3500 / freqSpanPerPole)) + 1;
const freqData = dataArray.slice(
freqMinIndex,
Math.max(freqMinIndex + desiredBarCount, freqMaxIndex),
);
const reducedFreqData = chain(freqData)
.chunk(Math.floor(freqData.length / desiredBarCount))
.take(desiredBarCount)
.map(sum)
.value();
// Draw waveform
const barGap = 1 * window.devicePixelRatio;
const barWidth = (width - barGap * (desiredBarCount - 1)) / desiredBarCount;
// Create gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#3ba05c'); // Blue
gradient.addColorStop(1, '#3ba05c'); // Light blue
// gradient.addColorStop(0, isDark ? '#fff' : '#000'); // Blue
// gradient.addColorStop(1, isDark ? '#eee' : '#eee'); // Light blue
for (let i = 0, x = 0; i < desiredBarCount; i++, x += barWidth + barGap) {
const barHeight = (reducedFreqData[i] / 255) * centerY;
ctx.fillStyle = gradient;
ctx.fillRect(x, centerY - barHeight, barWidth, barHeight * 2);
}
animationFrameRef.current = requestAnimationFrame(draw);
}, []);
const startVisualization = useCallback(async () => {
try {
// Check if the browser supports getUserMedia
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
@@ -30,13 +104,14 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
streamRef.current = stream;
// Create audio context and analyzer
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const audioContext = new (
window.AudioContext || (window as any).webkitAudioContext
)();
audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser();
analyserRef.current = analyser;
analyser.fftSize = 32;
analyser.fftSize = 256;
// Connect audio nodes
const source = audioContext.createMediaStreamSource(stream);
@@ -50,9 +125,9 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
error,
);
}
};
}, [draw]);
const stopVisualization = () => {
const stopVisualization = useCallback(() => {
// Stop animation frame
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
@@ -76,7 +151,8 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
};
}, []);
useEffect(() => {
if (isRecording) {
startVisualization();
@@ -87,68 +163,9 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
return () => {
stopVisualization();
};
}, [isRecording]);
const draw = () => {
const canvas = canvasRef.current;
if (!canvas) return;
}, [isRecording, startVisualization, stopVisualization]);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const analyser = analyserRef.current;
if (!analyser) return;
// Set canvas dimensions
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const centerY = height / 2;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Get frequency data
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(dataArray);
// Draw waveform
const barWidth = (width / bufferLength) * 1.5;
let x = 0;
for (let i = 0; i < bufferLength; i = i + 2) {
const barHeight = (dataArray[i] / 255) * centerY;
// Create gradient
const gradient = ctx.createLinearGradient(
0,
centerY - barHeight,
0,
centerY + barHeight,
);
gradient.addColorStop(0, '#3ba05c'); // Blue
gradient.addColorStop(1, '#3ba05c'); // Light blue
// gradient.addColorStop(0, isDark ? '#fff' : '#000'); // Blue
// gradient.addColorStop(1, isDark ? '#eee' : '#eee'); // Light blue
ctx.fillStyle = gradient;
ctx.fillRect(x, centerY - barHeight, barWidth, barHeight * 2);
x += barWidth + 2;
}
animationFrameRef.current = requestAnimationFrame(draw);
};
return (
<div className="w-full h-6 bg-transparent flex items-center justify-center overflow-hidden ">
<canvas ref={canvasRef} className="w-full h-full" />
</div>
);
return <canvas ref={canvasRef} className="block size-full" />;
};
const VoiceInputBox = ({
@@ -363,21 +380,22 @@ export const AudioButton = ({
<div className=" relative w-6 h-6 flex items-center justify-center">
{isRecording && (
<div
className={cn(
'absolute inset-0 w-full h-6 rounded-full overflow-hidden flex items-center justify-center p-1',
{ 'bg-state-success-5': isRecording },
)}
>
<div className="absolute inset-0 size-full overflow-hidden flex items-center justify-center p-1">
<VoiceVisualizer isRecording={isRecording} />
</div>
)}
{isRecording && (
<div className="absolute inset-0 rounded-full border-2 border-state-success animate-ping opacity-75"></div>
<div
className="
absolute inset-0 rounded-full border-2 border-state-success
animate-ping duration-[4s] opacity-75 pointer-events-none"
/>
)}
<Button
variant="outline"
size="sm"
variant="transparent"
size="icon-xs"
// onMouseDown={() => {
// startRecording();
// }}
@@ -391,11 +409,11 @@ export const AudioButton = ({
startRecording();
}
}}
className={`w-6 h-6 p-2 rounded-md border-none bg-transparent hover:bg-state-success-5 ${
isRecording
? 'animate-pulse bg-state-success-5 text-state-success'
: ''
}`}
className={cn(
'border-0 p-2 rounded-md border-none bg-transparent hover:bg-state-success-5',
isRecording &&
'animate-pulse !bg-state-success/20 text-state-success rounded-full',
)}
disabled={isProcessing}
>
{isProcessing ? (

View File

@@ -53,7 +53,10 @@ const BreadcrumbLink = React.forwardRef<
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
className={cn(
'transition-colors hover:text-foreground focus-visible:text-foreground',
className,
)}
{...props}
/>
);

View File

@@ -0,0 +1,83 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
className,
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View File

@@ -36,7 +36,7 @@ const buttonVariants = cva(
link: 'text-primary underline-offset-4 hover:underline',
icon: 'bg-colors-background-inverse-standard text-foreground hover:bg-colors-background-inverse-standard/80',
dashed: 'border border-dashed border-input hover:bg-accent',
dashed: 'border border-dashed border-border-button hover:bg-accent',
transparent: `
text-text-secondary bg-transparent border-0.5 border-border-button
@@ -49,10 +49,16 @@ const buttonVariants = cva(
hover:bg-state-error/10 focus-visible:bg-state-error/10
`,
accent: `
bg-accent-primary text-white
hover:bg-accent-primary/90 focus-visible:bg-accent-primary/90
`,
highlighted: `
bg-text-primary text-bg-base border-b-4 border-b-accent-primary
hover:bg-text-primary/90 focus-visible:bg-text-primary/90
`,
delete: `
text-text-secondary
hover:bg-state-error-5 hover:text-state-error
@@ -65,6 +71,8 @@ const buttonVariants = cva(
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
auto: 'h-full px-1',
'icon-sm': 'size-8',
'icon-xs': 'size-7',
},
},
defaultVariants: {
@@ -75,7 +83,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
@@ -126,7 +135,7 @@ ButtonLoading.displayName = 'ButtonLoading';
export { Button, buttonVariants };
export const BlockButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ children, className, ...props }, ref) => {
function BlockButton({ children, className, ...props }, ref) {
return (
<Button
variant={'outline'}

View File

@@ -5,8 +5,9 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
interface DualRangeSliderProps
extends React.ComponentProps<typeof SliderPrimitive.Root> {
interface DualRangeSliderProps extends React.ComponentProps<
typeof SliderPrimitive.Root
> {
labelPosition?: 'top' | 'bottom';
label?: (value: number | undefined) => React.ReactNode;
}
@@ -33,7 +34,12 @@ const DualRangeSlider = React.forwardRef<
</SliderPrimitive.Track>
{initialValue.map((value, index) => (
<React.Fragment key={index}>
<SliderPrimitive.Thumb className="relative block h-2.5 w-2.5 rounded-full border-2 border-accent-primary bg-white ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer">
<SliderPrimitive.Thumb
className="
relative block h-2.5 w-2.5 rounded-full border-2 border-accent-primary bg-white ring-offset-background transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
>
{label && (
<span
className={cn(

View File

@@ -20,7 +20,13 @@ const Slider = React.forwardRef<
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-colors-background-inverse-strong">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-colors-text-core-standard ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb
className="
block h-5 w-5 rounded-full border-2 border-primary bg-colors-text-core-standard transition-colors ring-offset-background
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50"
/>
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
@@ -33,16 +39,18 @@ type SliderProps = Omit<
const FormSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
SliderProps
>(({ onChange, value, ...props }, ref) => (
<Slider
ref={ref}
{...props}
value={[value]}
onValueChange={(vals) => {
onChange(vals[0]);
}}
></Slider>
));
>(function FormSlider({ onChange, value, ...props }, ref) {
return (
<Slider
ref={ref}
{...props}
value={[value]}
onValueChange={(vals) => {
onChange(vals[0]);
}}
/>
);
});
Slider.displayName = SliderPrimitive.Root.displayName;