Compare commits

..

5 Commits

Author SHA1 Message Date
gaoziman
aa469438c2 refactor(components): 全局组件适配暗色主题
- 聊天页面 header 和输入区域背景色使用 CSS 变量
- ChatInput 输入框背景色适配
- ModelSelector/ToolsDropdown 下拉菜单背景色适配
- QuickActions 按钮背景色适配
- Sidebar 侧边栏及下拉菜单背景色适配
- UserMenu 悬停效果颜色适配
2025-12-19 15:58:15 +08:00
gaoziman
0b5b67174f feat(settings): 实现数据导出和清除聊天功能
- 添加导出聊天数据功能,支持 JSON 格式下载
- 添加清除所有聊天功能,带确认对话框
- 显示待删除对话和消息数量统计
- 优化 SettingsSection 组件支持暗色主题
2025-12-19 15:57:50 +08:00
gaoziman
749247affa feat(ui): 添加 ConfirmDialog 确认对话框组件
- 支持默认和危险两种样式变体
- 支持 ESC 键关闭和点击外部关闭
- 支持 loading 状态和自定义按钮文案
- 支持自定义内容区域
2025-12-19 15:57:31 +08:00
gaoziman
199772a95d feat(api): 添加对话数据导出和清除 API
- 新增 GET /api/conversations/export 导出所有对话数据
- 新增 GET /api/conversations/all 获取对话统计信息
- 新增 DELETE /api/conversations/all 清除所有对话和消息
2025-12-19 15:57:12 +08:00
gaoziman
8aab630af6 feat(theme): 添加暗色主题支持
- 新增 [data-theme="dark"] CSS 变量定义
- 设置暗色模式主背景色为 #29252B
- 配置暗色模式下的品牌色、背景色、文字色、边框色
- 调整暗色模式阴影效果
- 侧边栏宽度调整为 280px
2025-12-19 15:56:52 +08:00
12 changed files with 483 additions and 20 deletions

View File

@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { conversations, messages } from '@/drizzle/schema';
import { sql } from 'drizzle-orm';
// GET /api/conversations/all - 获取统计信息
export async function GET() {
try {
// 获取对话数量
const conversationCount = await db
.select({ count: sql<number>`count(*)` })
.from(conversations);
// 获取消息数量
const messageCount = await db
.select({ count: sql<number>`count(*)` })
.from(messages);
return NextResponse.json({
conversationCount: Number(conversationCount[0]?.count || 0),
messageCount: Number(messageCount[0]?.count || 0),
});
} catch (error) {
console.error('Failed to get stats:', error);
return NextResponse.json(
{ error: 'Failed to get stats' },
{ status: 500 }
);
}
}
// DELETE /api/conversations/all - 清除所有对话和消息
export async function DELETE() {
try {
// 先删除所有消息
await db.delete(messages);
// 再删除所有对话
await db.delete(conversations);
return NextResponse.json({
success: true,
message: 'All conversations and messages have been deleted',
});
} catch (error) {
console.error('Failed to delete all conversations:', error);
return NextResponse.json(
{ error: 'Failed to delete all conversations' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { db } from '@/drizzle/db';
import { conversations, messages } from '@/drizzle/schema';
import { desc, eq } from 'drizzle-orm';
// GET /api/conversations/export - 导出所有对话数据
export async function GET() {
try {
// 获取所有对话
const allConversations = await db.query.conversations.findMany({
orderBy: [desc(conversations.createdAt)],
});
// 获取所有消息
const allMessages = await db.query.messages.findMany({
orderBy: [desc(messages.createdAt)],
});
// 按对话组织数据
const exportData = {
exportedAt: new Date().toISOString(),
version: '1.0',
totalConversations: allConversations.length,
totalMessages: allMessages.length,
conversations: allConversations.map((conv) => {
// 获取该对话的所有消息
const convMessages = allMessages
.filter((msg) => msg.conversationId === conv.conversationId)
.sort((a, b) => new Date(a.createdAt!).getTime() - new Date(b.createdAt!).getTime())
.map((msg) => ({
messageId: msg.messageId,
role: msg.role,
content: msg.content,
thinkingContent: msg.thinkingContent,
toolCalls: msg.toolCalls,
toolResults: msg.toolResults,
inputTokens: msg.inputTokens,
outputTokens: msg.outputTokens,
status: msg.status,
feedback: msg.feedback,
createdAt: msg.createdAt,
}));
return {
conversationId: conv.conversationId,
title: conv.title,
summary: conv.summary,
model: conv.model,
tools: conv.tools,
enableThinking: conv.enableThinking,
systemPrompt: conv.systemPrompt,
temperature: conv.temperature,
messageCount: conv.messageCount,
totalTokens: conv.totalTokens,
isArchived: conv.isArchived,
isPinned: conv.isPinned,
createdAt: conv.createdAt,
updatedAt: conv.updatedAt,
lastMessageAt: conv.lastMessageAt,
messages: convMessages,
};
}),
};
return NextResponse.json(exportData);
} catch (error) {
console.error('Failed to export conversations:', error);
return NextResponse.json(
{ error: 'Failed to export conversations' },
{ status: 500 }
);
}
}

View File

@ -207,7 +207,7 @@ export default function ChatPage({ params }: PageProps) {
)} )}
> >
{/* 固定顶部 Header */} {/* 固定顶部 Header */}
<header className="h-[var(--header-height)] px-4 flex items-center justify-between border-b border-[var(--color-border)] bg-white sticky top-0 z-10"> <header className="h-[var(--header-height)] px-4 flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-bg-primary)] sticky top-0 z-10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} /> <SidebarToggle onClick={() => setSidebarOpen(!sidebarOpen)} />
<h1 className="text-base font-medium text-[var(--color-text-primary)] truncate max-w-[300px]"> <h1 className="text-base font-medium text-[var(--color-text-primary)] truncate max-w-[300px]">
@ -280,7 +280,12 @@ export default function ChatPage({ params }: PageProps) {
</div> </div>
{/* 固定底部输入框 */} {/* 固定底部输入框 */}
<div className="sticky bottom-0 bg-gradient-to-t from-white via-white to-transparent pt-4"> <div
className="sticky bottom-0 pt-4"
style={{
background: `linear-gradient(to top, var(--color-bg-secondary) 0%, var(--color-bg-secondary) 80%, transparent 100%)`
}}
>
<div className="max-w-[900px] mx-auto px-4 pb-4"> <div className="max-w-[900px] mx-auto px-4 pb-4">
{isStreaming && ( {isStreaming && (
<div className="flex justify-center mb-3"> <div className="flex justify-center mb-3">

View File

@ -43,7 +43,7 @@
--shadow-input-focus: 0 4px 16px rgba(224, 107, 62, 0.1); --shadow-input-focus: 0 4px 16px rgba(224, 107, 62, 0.1);
/* 布局 */ /* 布局 */
--sidebar-width: 260px; --sidebar-width: 280px;
--header-height: 56px; --header-height: 56px;
--input-max-width: 900px; --input-max-width: 900px;
@ -64,6 +64,48 @@
--font-size-base: 15px; --font-size-base: 15px;
} }
/* ========================================
暗色模式
主背景色: #29252B
======================================== */
[data-theme="dark"] {
/* 品牌色 - 保持不变 */
--color-primary: #E06B3E;
--color-primary-hover: #E8805A;
--color-primary-light: rgba(224, 107, 62, 0.15);
--color-primary-alpha: rgba(224, 107, 62, 0.2);
/* 背景色 */
--color-bg-primary: #29252B;
--color-bg-secondary: #201D23;
--color-bg-tertiary: #332E38;
--color-bg-hover: #3A353E;
/* 文字色 */
--color-text-primary: #F5F5F5;
--color-text-secondary: #A8A8A8;
--color-text-tertiary: #6B6B6B;
--color-text-placeholder: #5A5A5A;
/* 边框色 */
--color-border: #3A353E;
--color-border-light: #332E38;
--color-border-focus: #E06B3E;
/* 消息气泡色 */
--color-message-user: #4A4352;
--color-message-assistant-bg: #332E38;
--color-message-assistant-border: #3A353E;
/* 阴影 - 暗色模式下增强 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
--shadow-dropdown: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-input: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-input-focus: 0 4px 16px rgba(224, 107, 62, 0.2);
}
@theme inline { @theme inline {
/* 品牌色 */ /* 品牌色 */
--color-primary: var(--color-primary); --color-primary: var(--color-primary);

View File

@ -2,10 +2,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, Download, Check, Loader2, Eye, EyeOff, RotateCcw, Moon, Sun, Sparkles, Trash2 } from 'lucide-react'; import { ArrowLeft, Download, Check, Loader2, Eye, EyeOff, RotateCcw, Moon, Sun, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
import { Toggle } from '@/components/ui/Toggle'; import { Toggle } from '@/components/ui/Toggle';
import { ModelCardSelector } from '@/components/ui/ModelCardSelector'; import { ModelCardSelector } from '@/components/ui/ModelCardSelector';
import { FontSizePicker } from '@/components/ui/FontSizePicker'; import { FontSizePicker } from '@/components/ui/FontSizePicker';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSettings, useModels, useTools } from '@/hooks/useSettings'; import { useSettings, useModels, useTools } from '@/hooks/useSettings';
@ -59,6 +60,12 @@ export default function SettingsPage() {
const [temperature, setTemperature] = useState('0.7'); const [temperature, setTemperature] = useState('0.7');
const [promptSaveStatus, setPromptSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [promptSaveStatus, setPromptSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
// 导出和清除状态
const [exportLoading, setExportLoading] = useState(false);
const [clearDialogOpen, setClearDialogOpen] = useState(false);
const [clearLoading, setClearLoading] = useState(false);
const [chatStats, setChatStats] = useState<{ conversationCount: number; messageCount: number } | null>(null);
// 当设置加载完成后,更新本地状态 // 当设置加载完成后,更新本地状态
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
@ -198,6 +205,70 @@ export default function SettingsPage() {
} }
}; };
// 导出聊天数据
const handleExportData = async () => {
setExportLoading(true);
try {
const response = await fetch('/api/conversations/export');
if (!response.ok) {
throw new Error('Export failed');
}
const data = await response.json();
// 创建 Blob 并下载
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const date = new Date().toISOString().split('T')[0];
a.download = `cchcode-chat-export-${date}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to export data:', error);
alert('导出失败,请稍后重试');
} finally {
setExportLoading(false);
}
};
// 打开清除对话框(获取统计信息)
const handleOpenClearDialog = async () => {
try {
const response = await fetch('/api/conversations/all');
if (response.ok) {
const stats = await response.json();
setChatStats(stats);
}
} catch (error) {
console.error('Failed to get stats:', error);
}
setClearDialogOpen(true);
};
// 清除所有聊天
const handleClearAllChats = async () => {
setClearLoading(true);
try {
const response = await fetch('/api/conversations/all', {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Delete failed');
}
setClearDialogOpen(false);
// 跳转到首页
window.location.href = '/';
} catch (error) {
console.error('Failed to clear chats:', error);
alert('清除失败,请稍后重试');
} finally {
setClearLoading(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">
@ -510,9 +581,17 @@ export default function SettingsPage() {
label="导出数据" label="导出数据"
description="下载所有聊天历史" description="下载所有聊天历史"
> >
<button className="btn-ghost inline-flex items-center gap-2"> <button
onClick={handleExportData}
disabled={exportLoading}
className="btn-ghost inline-flex items-center gap-2"
>
{exportLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} /> <Download size={16} />
)}
{exportLoading ? '导出中...' : '导出'}
</button> </button>
</SettingsItem> </SettingsItem>
@ -520,12 +599,49 @@ export default function SettingsPage() {
label="清除所有聊天" label="清除所有聊天"
description="删除所有对话历史" description="删除所有对话历史"
> >
<button className="btn-ghost text-red-600 hover:text-red-700 inline-flex items-center gap-2"> <button
onClick={handleOpenClearDialog}
className="btn-ghost text-red-600 hover:text-red-700 inline-flex items-center gap-2"
>
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</SettingsItem> </SettingsItem>
</SettingsSection> </SettingsSection>
{/* 清除确认对话框 */}
<ConfirmDialog
isOpen={clearDialogOpen}
onClose={() => setClearDialogOpen(false)}
onConfirm={handleClearAllChats}
title="确认清除所有聊天记录?"
variant="danger"
confirmText="确认清除"
cancelText="取消"
loading={clearLoading}
>
<div className="space-y-3">
<p className="text-sm text-[var(--color-text-secondary)]">
</p>
<ul className="text-sm text-[var(--color-text-primary)] space-y-1 ml-4">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
<span><strong>{chatStats?.conversationCount || 0}</strong> </span>
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
<span><strong>{chatStats?.messageCount || 0}</strong> </span>
</li>
</ul>
<div className="flex items-start gap-2 p-3 rounded-md bg-red-500/10 border border-red-500/20">
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-red-500">
</p>
</div>
</div>
</ConfirmDialog>
</main> </main>
</div> </div>
); );
@ -543,22 +659,22 @@ function SettingsSection({ title, description, children, variant = 'default' }:
return ( return (
<section <section
className={cn( className={cn(
'bg-white border rounded-md mb-6 overflow-hidden', 'bg-[var(--color-bg-primary)] border rounded-md mb-6 overflow-hidden',
variant === 'danger' ? 'border-red-200' : 'border-[var(--color-border)]' variant === 'danger' ? 'border-red-500/20' : 'border-[var(--color-border)]'
)} )}
> >
<div <div
className={cn( className={cn(
'px-5 py-4 border-b', 'px-5 py-4 border-b',
variant === 'danger' variant === 'danger'
? 'bg-red-50 border-red-200' ? 'bg-red-500/10 border-red-500/20'
: 'border-[var(--color-border-light)]' : 'border-[var(--color-border-light)]'
)} )}
> >
<h2 <h2
className={cn( className={cn(
'text-base font-semibold', 'text-base font-semibold',
variant === 'danger' ? 'text-red-600' : 'text-[var(--color-text-primary)]' variant === 'danger' ? 'text-red-500' : 'text-[var(--color-text-primary)]'
)} )}
> >
{title} {title}

View File

@ -52,7 +52,7 @@ export function ChatInput({
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}> <div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
<div <div
className={cn( className={cn(
'flex flex-col bg-white border border-[var(--color-border)] rounded-[18px] p-4 shadow-[var(--shadow-input)]', 'flex flex-col bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[18px] p-4 shadow-[var(--shadow-input)]',
'transition-all duration-150', 'transition-all duration-150',
'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]' 'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]'
)} )}

View File

@ -44,7 +44,7 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
{/* 下拉菜单 */} {/* 下拉菜单 */}
<div <div
className={cn( className={cn(
'absolute bottom-full right-0 mb-2 min-w-[160px] bg-white border border-[var(--color-border)] rounded-xl shadow-lg p-1 z-50', 'absolute bottom-full right-0 mb-2 min-w-[160px] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-xl shadow-lg p-1 z-50',
'transition-all duration-150', 'transition-all duration-150',
isOpen isOpen
? 'opacity-100 visible translate-y-0' ? 'opacity-100 visible translate-y-0'

View File

@ -29,7 +29,7 @@ export function QuickActions({ actions, onSelect, className }: QuickActionsProps
onClick={() => onSelect(action)} onClick={() => onSelect(action)}
className={cn( className={cn(
'flex items-center gap-2 px-4 py-2', 'flex items-center gap-2 px-4 py-2',
'bg-white border border-[var(--color-border)] rounded-full', 'bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-full',
'text-sm text-[var(--color-text-secondary)]', 'text-sm text-[var(--color-text-secondary)]',
'hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)]', 'hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)]',
'transition-all duration-150' 'transition-all duration-150'

View File

@ -63,7 +63,7 @@ export function ToolsDropdown({ tools, onToolToggle, onEnableAllToggle }: ToolsD
{/* 下拉菜单 */} {/* 下拉菜单 */}
<div <div
className={cn( className={cn(
'absolute bottom-full left-0 mb-2 min-w-[220px] bg-white border border-[var(--color-border)] rounded-xl shadow-lg p-2 z-50', 'absolute bottom-full left-0 mb-2 min-w-[220px] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-xl shadow-lg p-2 z-50',
'transition-all duration-150', 'transition-all duration-150',
isOpen isOpen
? 'opacity-100 visible translate-y-0' ? 'opacity-100 visible translate-y-0'

View File

@ -69,7 +69,7 @@ export function Sidebar({ user, isOpen = true }: SidebarProps) {
{/* 侧边栏 */} {/* 侧边栏 */}
<aside <aside
className={cn( className={cn(
'fixed top-0 left-0 bottom-0 z-50 bg-white border-r border-[var(--color-border-light)] flex flex-col transition-all duration-300 ease-in-out overflow-hidden', 'fixed top-0 left-0 bottom-0 z-50 bg-[var(--color-bg-primary)] border-r border-[var(--color-border-light)] flex flex-col transition-all duration-300 ease-in-out overflow-hidden',
isOpen ? 'w-[var(--sidebar-width)]' : 'w-0 border-r-0' isOpen ? 'w-[var(--sidebar-width)]' : 'w-0 border-r-0'
)} )}
> >
@ -146,10 +146,10 @@ export function Sidebar({ user, isOpen = true }: SidebarProps) {
{/* 下拉菜单 */} {/* 下拉菜单 */}
{menuOpen === conversation.conversationId && ( {menuOpen === conversation.conversationId && (
<div className="absolute right-0 top-full mt-1 bg-white border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10 min-w-[120px]"> <div className="absolute right-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10 min-w-[120px]">
<button <button
onClick={(e) => handleDeleteConversation(conversation.conversationId, e)} onClick={(e) => handleDeleteConversation(conversation.conversationId, e)}
className="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2" className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
> >
<Trash2 size={14} /> <Trash2 size={14} />

View File

@ -0,0 +1,175 @@
'use client';
import { useEffect, useRef } from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description?: string;
confirmText?: string;
cancelText?: string;
variant?: 'default' | 'danger';
loading?: boolean;
children?: React.ReactNode;
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
description,
confirmText = '确认',
cancelText = '取消',
variant = 'default',
loading = false,
children,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// ESC 关闭
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && !loading) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose, loading]);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dialogRef.current &&
!dialogRef.current.contains(e.target as Node) &&
!loading
) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose, loading]);
// 禁止背景滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 背景遮罩 */}
<div className="absolute inset-0 bg-black/50 animate-fade-in" />
{/* 对话框 */}
<div
ref={dialogRef}
className={cn(
'relative z-10 w-full max-w-md mx-4',
'bg-[var(--color-bg-primary)] rounded-lg shadow-xl',
'animate-pop-up'
)}
>
{/* 头部 */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border-light)]">
<div className="flex items-center gap-3">
{variant === 'danger' && (
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-red-500/10">
<AlertTriangle className="w-5 h-5 text-red-500" />
</div>
)}
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{title}
</h3>
</div>
<button
onClick={onClose}
disabled={loading}
className="p-1 rounded-md text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-50"
>
<X size={20} />
</button>
</div>
{/* 内容 */}
<div className="px-5 py-4">
{description && (
<p className="text-sm text-[var(--color-text-secondary)] mb-4">
{description}
</p>
)}
{children}
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-5 py-4 border-t border-[var(--color-border-light)] bg-[var(--color-bg-secondary)]">
<button
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-md hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-50"
>
{cancelText}
</button>
<button
onClick={onConfirm}
disabled={loading}
className={cn(
'px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50',
variant === 'danger'
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-dark)]'
)}
>
{loading ? (
<span className="flex items-center gap-2">
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
...
</span>
) : (
confirmText
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -168,7 +168,7 @@ export function UserMenu({ user }: UserMenuProps) {
className={cn( className={cn(
'flex items-center gap-3 w-full px-4 py-3', 'flex items-center gap-3 w-full px-4 py-3',
'text-sm text-red-500', 'text-sm text-red-500',
'hover:bg-red-50 transition-colors' 'hover:bg-[var(--color-bg-hover)] transition-colors'
)} )}
role="menuitem" role="menuitem"
> >