Initial commit
This commit is contained in:
431
components/AudioPlayer.tsx
Normal file
431
components/AudioPlayer.tsx
Normal file
@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
title?: string;
|
||||
duration?: number; // 从数据库获取的时长
|
||||
recordingId?: string; // 录音ID,用于检查访问权限
|
||||
}
|
||||
|
||||
export default function AudioPlayer({
|
||||
src,
|
||||
title,
|
||||
duration: dbDuration,
|
||||
recordingId,
|
||||
}: AudioPlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [duration, setDuration] = useState(dbDuration || 0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [showVolumeControl, setShowVolumeControl] = useState(false);
|
||||
const [showDownloadMenu, setShowDownloadMenu] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState(src);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 检查 S3 文件访问权限
|
||||
useEffect(() => {
|
||||
if (recordingId && src.includes("amazonaws.com")) {
|
||||
const checkAccess = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/recordings/${recordingId}/check-access`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.accessible) {
|
||||
console.log("S3 文件可访问:", data.url);
|
||||
setError(null);
|
||||
|
||||
// 使用代理 URL 而不是直接访问 S3
|
||||
const proxyUrl = `/api/recordings/${recordingId}/stream`;
|
||||
setAudioUrl(proxyUrl);
|
||||
} else {
|
||||
console.error("S3 文件无法访问:", data.error);
|
||||
setError("音频文件无法访问,请检查权限设置");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("检查文件访问失败:", error);
|
||||
setError("检查文件访问失败");
|
||||
}
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
}
|
||||
}, [recordingId, src]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
console.log("音频元数据加载完成:", {
|
||||
duration: audio.duration,
|
||||
src: audio.src,
|
||||
readyState: audio.readyState,
|
||||
networkState: audio.networkState,
|
||||
currentSrc: audio.currentSrc,
|
||||
error: audio.error,
|
||||
});
|
||||
|
||||
// 如果数据库中没有时长,则从音频文件获取
|
||||
if (!dbDuration) {
|
||||
setDuration(audio.duration);
|
||||
}
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
console.log("音频开始播放");
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
console.log("音频暂停");
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
console.log("音频播放结束");
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
|
||||
const handleError = (e: Event) => {
|
||||
const target = e.target as HTMLAudioElement;
|
||||
console.error("音频加载失败:", {
|
||||
error: target.error,
|
||||
errorCode: target.error?.code,
|
||||
errorMessage: target.error?.message,
|
||||
src: target.src,
|
||||
networkState: target.networkState,
|
||||
readyState: target.readyState,
|
||||
currentSrc: target.currentSrc,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
setError("音频加载失败,请检查文件是否存在");
|
||||
};
|
||||
|
||||
const handleLoadStart = () => {
|
||||
console.log("开始加载音频:", src);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
console.log("音频可以播放:", src);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
console.log("音频可以流畅播放:", src);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
console.log("音频加载完成:", src);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleAbort = () => {
|
||||
console.log("音频加载被中止:", src);
|
||||
};
|
||||
|
||||
const handleSuspend = () => {
|
||||
console.log("音频加载被暂停:", src);
|
||||
};
|
||||
|
||||
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
audio.addEventListener("timeupdate", handleTimeUpdate);
|
||||
audio.addEventListener("play", handlePlay);
|
||||
audio.addEventListener("pause", handlePause);
|
||||
audio.addEventListener("ended", handleEnded);
|
||||
audio.addEventListener("error", handleError);
|
||||
audio.addEventListener("loadstart", handleLoadStart);
|
||||
audio.addEventListener("canplay", handleCanPlay);
|
||||
audio.addEventListener("canplaythrough", handleCanPlayThrough);
|
||||
audio.addEventListener("load", handleLoad);
|
||||
audio.addEventListener("abort", handleAbort);
|
||||
audio.addEventListener("suspend", handleSuspend);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
audio.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
audio.removeEventListener("play", handlePlay);
|
||||
audio.removeEventListener("pause", handlePause);
|
||||
audio.removeEventListener("ended", handleEnded);
|
||||
audio.removeEventListener("error", handleError);
|
||||
audio.removeEventListener("loadstart", handleLoadStart);
|
||||
audio.removeEventListener("canplay", handleCanPlay);
|
||||
audio.removeEventListener("canplaythrough", handleCanPlayThrough);
|
||||
audio.removeEventListener("load", handleLoad);
|
||||
audio.removeEventListener("abort", handleAbort);
|
||||
audio.removeEventListener("suspend", handleSuspend);
|
||||
};
|
||||
}, [dbDuration, src]);
|
||||
|
||||
// 点击外部区域关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest(".audio-player-controls")) {
|
||||
setShowVolumeControl(false);
|
||||
setShowDownloadMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play().catch((error) => {
|
||||
console.error("播放失败:", error);
|
||||
setError("播放失败,请重试");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const audio = audioRef.current;
|
||||
const progress = progressRef.current;
|
||||
if (!audio || !progress || duration === 0) return;
|
||||
|
||||
const rect = progress.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const progressWidth = rect.width;
|
||||
const clickPercent = clickX / progressWidth;
|
||||
|
||||
audio.currentTime = clickPercent * duration;
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
audio.volume = newVolume;
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement("a");
|
||||
link.href = src;
|
||||
link.download = `${title || "recording"}.webm`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time) || time === Infinity) return "0:00";
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<audio ref={audioRef} preload="metadata">
|
||||
<source src={audioUrl} type="audio/webm" />
|
||||
您的浏览器不支持音频播放。
|
||||
</audio>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 播放控制 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 disabled:from-gray-400 disabled:to-gray-400 text-white rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none group"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
) : isPlaying ? (
|
||||
<svg
|
||||
className="w-5 h-5 group-hover:scale-110 transition-transform"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 ml-0.5 group-hover:scale-110 transition-transform"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{title || "录音"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>/</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮组 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 音量控制 */}
|
||||
<div className="relative audio-player-controls">
|
||||
<button
|
||||
onClick={() => setShowVolumeControl(!showVolumeControl)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M6.343 6.343a1 1 0 011.414 0l8.486 8.486a1 1 0 01-1.414 1.414L6.343 7.757a1 1 0 010-1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showVolumeControl && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl p-3 shadow-xl z-10 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M6.343 6.343a1 1 0 011.414 0l8.486 8.486a1 1 0 01-1.414 1.414L6.343 7.757a1 1 0 010-1.414z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-24 h-2 bg-gray-200 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<div className="relative audio-player-controls">
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
|
||||
title="下载录音"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showDownloadMenu && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl p-2 shadow-xl z-10 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors whitespace-nowrap"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<span>下载录音</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
onClick={handleProgressClick}
|
||||
className="w-full h-2 bg-gray-200 dark:bg-gray-600 rounded-full cursor-pointer relative group"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all duration-100 relative overflow-hidden"
|
||||
style={{
|
||||
width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
|
||||
}}
|
||||
>
|
||||
{/* 进度条动画效果 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
{/* 进度条悬停效果 */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-blue-200 dark:bg-blue-400/30 rounded-full opacity-30"></div>
|
||||
</div>
|
||||
|
||||
{/* 进度条滑块 */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white dark:bg-gray-200 rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
style={{
|
||||
left: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
534
components/AudioRecorder.tsx
Normal file
534
components/AudioRecorder.tsx
Normal file
@ -0,0 +1,534 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import {
|
||||
getBestRecordingFormat,
|
||||
SUPPORTED_AUDIO_FORMATS,
|
||||
} from "@/lib/config/audio-config";
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onRecordingComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function AudioRecorder({
|
||||
onRecordingComplete,
|
||||
}: AudioRecorderProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
const [recordingTitle, setRecordingTitle] = useState(""); // 录音标题
|
||||
const [isRenaming, setIsRenaming] = useState(false); // 是否正在重命名
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
|
||||
// 音频分析器设置
|
||||
const setupAudioAnalyzer = (stream: MediaStream) => {
|
||||
try {
|
||||
const audioContext = new AudioContext();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
|
||||
source.connect(analyser);
|
||||
|
||||
audioContextRef.current = audioContext;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
return true; // 表示设置成功
|
||||
} catch (error) {
|
||||
console.error("音频分析器设置失败:", error);
|
||||
return false; // 表示设置失败
|
||||
}
|
||||
};
|
||||
|
||||
// 更新音频电平
|
||||
const updateAudioLevel = () => {
|
||||
if (!analyserRef.current || !isRecording || isPaused) {
|
||||
setAudioLevel(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
// 计算平均音量
|
||||
const average =
|
||||
dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length;
|
||||
const normalizedLevel = Math.min(average / 128, 1);
|
||||
|
||||
setAudioLevel(normalizedLevel);
|
||||
|
||||
if (isRecording && !isPaused) {
|
||||
animationFrameRef.current = requestAnimationFrame(updateAudioLevel);
|
||||
}
|
||||
};
|
||||
|
||||
// 清理音频分析器
|
||||
const cleanupAudioAnalyzer = () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
setAudioLevel(0);
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
// 录音参数直接写死
|
||||
const constraints = {
|
||||
audio: {
|
||||
sampleRate: 48000,
|
||||
channelCount: 2,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
};
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
streamRef.current = stream;
|
||||
|
||||
// 录音格式直接写死为 webm
|
||||
const mimeType = "audio/webm;codecs=opus";
|
||||
|
||||
// 检查浏览器是否支持该格式
|
||||
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||
console.warn(`不支持的格式: ${mimeType},使用默认格式`);
|
||||
// 使用默认格式
|
||||
const defaultFormat = getBestRecordingFormat();
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: defaultFormat.mimeType,
|
||||
});
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
} else {
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: mimeType,
|
||||
});
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
}
|
||||
|
||||
const audioChunks: Blob[] = [];
|
||||
|
||||
mediaRecorderRef.current.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
// 使用实际使用的 MIME 类型
|
||||
const actualMimeType =
|
||||
mediaRecorderRef.current?.mimeType || "audio/webm";
|
||||
const audioBlob = new Blob(audioChunks, {
|
||||
type: actualMimeType,
|
||||
});
|
||||
setAudioBlob(audioBlob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
cleanupAudioAnalyzer();
|
||||
};
|
||||
|
||||
// 设置音频分析器
|
||||
let analyzerSetup = false;
|
||||
try {
|
||||
analyzerSetup = setupAudioAnalyzer(stream);
|
||||
if (analyzerSetup) {
|
||||
// 音频分析器设置成功
|
||||
}
|
||||
} catch (error) {
|
||||
// 音频分析器设置失败,但不影响录音功能
|
||||
return false; // 表示设置失败
|
||||
}
|
||||
|
||||
// 开始录音
|
||||
mediaRecorderRef.current.start();
|
||||
setIsRecording(true);
|
||||
setIsPaused(false);
|
||||
setRecordingTime(0);
|
||||
|
||||
// 开始计时
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
// 开始音频分析
|
||||
if (analyzerSetup) {
|
||||
updateAudioLevel();
|
||||
}
|
||||
} catch (error) {
|
||||
// 无法访问麦克风
|
||||
alert("无法访问麦克风,请检查权限设置。");
|
||||
}
|
||||
};
|
||||
|
||||
const pauseRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
if (isPaused) {
|
||||
// 继续录音
|
||||
mediaRecorderRef.current.resume();
|
||||
setIsPaused(false);
|
||||
// 重新启动计时器
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
// 重新开始音频分析
|
||||
updateAudioLevel();
|
||||
} else {
|
||||
// 暂停录音
|
||||
mediaRecorderRef.current.pause();
|
||||
setIsPaused(true);
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// 停止音频分析
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
setAudioLevel(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
// setIsAnalyzing(false); // This line is removed
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// 清理音频分析器
|
||||
cleanupAudioAnalyzer();
|
||||
// 停止音频流
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
// 保持录音时长,不清零
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (mediaRecorderRef.current) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// 清理音频分析器
|
||||
cleanupAudioAnalyzer();
|
||||
// 停止音频流
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setAudioBlob(null);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadRecording = async () => {
|
||||
if (!audioBlob) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 直接上传文件到后端,让后端处理 S3 上传
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioBlob, `recording-${Date.now()}.webm`);
|
||||
formData.append("duration", recordingTime.toString());
|
||||
|
||||
// 添加录音标题
|
||||
const title =
|
||||
recordingTitle.trim() || `录音 ${new Date().toLocaleString("zh-CN")}`;
|
||||
formData.append("title", title);
|
||||
|
||||
const response = await fetch("/api/recordings/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert("录音上传成功!");
|
||||
setAudioBlob(null);
|
||||
setRecordingTitle(""); // 清空录音标题
|
||||
// 保持录音时长用于显示,不重置为0
|
||||
// 触发父组件刷新
|
||||
console.log("录音上传成功,触发刷新事件");
|
||||
window.dispatchEvent(new CustomEvent("recording-uploaded"));
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "保存录音记录失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("上传失败:", error);
|
||||
alert(`上传失败:${error instanceof Error ? error.message : "请重试"}`);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 生成波形条
|
||||
const generateWaveformBars = () => {
|
||||
const bars = [];
|
||||
const barCount = 20;
|
||||
const baseHeight = 4;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const height =
|
||||
isRecording && !isPaused
|
||||
? baseHeight + audioLevel * 20 * Math.random()
|
||||
: baseHeight;
|
||||
|
||||
bars.push(
|
||||
<div
|
||||
key={i}
|
||||
className="bg-blue-500 rounded-full transition-all duration-100"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: "3px",
|
||||
opacity: isRecording && !isPaused ? 0.8 : 0.3,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return bars;
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAudioAnalyzer();
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
录音机
|
||||
</h3>
|
||||
|
||||
{/* 录音状态指示器 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-colors ${
|
||||
isRecording && !isPaused
|
||||
? "bg-red-500 animate-pulse"
|
||||
: isPaused
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-300"
|
||||
}`}
|
||||
></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{!isRecording ? "准备就绪" : isPaused ? "已暂停" : "录音中..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 波形可视化 */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-center items-end gap-1 h-8">
|
||||
{generateWaveformBars()}
|
||||
</div>
|
||||
{isRecording && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{isPaused ? "已暂停" : "录音中..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRecording && (
|
||||
<div className="mb-4">
|
||||
<div className="text-2xl font-mono text-red-500">
|
||||
{formatTime(recordingTime)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{isPaused ? "已暂停" : "录音中..."}
|
||||
</div>
|
||||
{/* 录音进度条 */}
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-red-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min((recordingTime / 300) * 100, 100)}%`, // 5分钟为100%
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
进度: {Math.round((recordingTime / 300) * 100)}% (最大5分钟)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="bg-red-500 hover:bg-red-600 text-white px-6 py-3 rounded-full font-medium transition-colors"
|
||||
>
|
||||
开始录音
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={pauseRecording}
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
|
||||
>
|
||||
{isPaused ? "继续" : "暂停"}
|
||||
</button>
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelRecording}
|
||||
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
|
||||
>
|
||||
取消录音
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{audioBlob && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* 录音标题 */}
|
||||
<div className="text-center">
|
||||
{isRenaming ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={recordingTitle}
|
||||
onChange={(e) => setRecordingTitle(e.target.value)}
|
||||
placeholder="输入录音标题"
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-center min-w-0 flex-1 max-w-xs"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (recordingTitle.trim()) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRecordingTitle("");
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
className="px-3 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{recordingTitle || "录音"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setIsRenaming(true)}
|
||||
className="p-1 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="重命名"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<audio controls className="w-full max-w-md mx-auto">
|
||||
<source
|
||||
src={URL.createObjectURL(audioBlob)}
|
||||
type="audio/webm"
|
||||
/>
|
||||
您的浏览器不支持音频播放。
|
||||
</audio>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={uploadRecording}
|
||||
disabled={isUploading}
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-6 py-2 rounded-full font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
"保存录音"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAudioBlob(null);
|
||||
setRecordingTitle("");
|
||||
}}
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
|
||||
>
|
||||
取消录音
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
components/Header.tsx
Normal file
47
components/Header.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import UserMenu from "./UserMenu";
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 应用标题 */}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
录音应用
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
录制、管理和分享你的音频
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* 用户菜单 */}
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
54
components/LoadingSpinner.tsx
Normal file
54
components/LoadingSpinner.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
color?: "blue" | "red" | "green" | "gray" | "white";
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({
|
||||
size = "md",
|
||||
color = "blue",
|
||||
text,
|
||||
}: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-8 h-8",
|
||||
lg: "w-12 h-12",
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
blue: "text-blue-500",
|
||||
red: "text-red-500",
|
||||
green: "text-green-500",
|
||||
gray: "text-gray-500",
|
||||
white: "text-white",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div
|
||||
className={`${sizeClasses[size]} ${colorClasses[color]} animate-spin`}
|
||||
>
|
||||
<svg className="w-full h-full" 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>
|
||||
</div>
|
||||
{text && (
|
||||
<div className="mt-2 text-sm text-gray-500 animate-pulse">{text}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
components/NotificationToast.tsx
Normal file
133
components/NotificationToast.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
notificationManager,
|
||||
AppNotification,
|
||||
} from "@/lib/utils/notifications";
|
||||
|
||||
export default function NotificationToast() {
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationsChange = (newNotifications: AppNotification[]) => {
|
||||
setNotifications(newNotifications);
|
||||
};
|
||||
|
||||
notificationManager.addListener(handleNotificationsChange);
|
||||
setNotifications(notificationManager.getNotifications());
|
||||
|
||||
return () => {
|
||||
notificationManager.removeListener(handleNotificationsChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case "error":
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case "warning":
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case "info":
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeClasses = (type: string) => {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "bg-green-50 border-green-200 text-green-800";
|
||||
case "error":
|
||||
return "bg-red-50 border-red-200 text-red-800";
|
||||
case "warning":
|
||||
return "bg-yellow-50 border-yellow-200 text-yellow-800";
|
||||
case "info":
|
||||
return "bg-blue-50 border-blue-200 text-blue-800";
|
||||
default:
|
||||
return "bg-gray-50 border-gray-200 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`max-w-sm w-full border rounded-lg shadow-lg p-4 transition-all duration-300 transform ${getTypeClasses(
|
||||
notification.type
|
||||
)}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">{getIcon(notification.type)}</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<p className="text-sm font-medium">{notification.title}</p>
|
||||
<p className="text-sm mt-1 opacity-90">{notification.message}</p>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={() =>
|
||||
notificationManager.removeNotification(notification.id)
|
||||
}
|
||||
className="inline-flex text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
387
components/RecordingList.tsx
Normal file
387
components/RecordingList.tsx
Normal file
@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import AudioPlayer from "./AudioPlayer";
|
||||
|
||||
interface Recording {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: number;
|
||||
createdAt: string;
|
||||
audioUrl: string;
|
||||
}
|
||||
|
||||
interface RecordingListProps {
|
||||
recordings: Recording[];
|
||||
onRecordingUpdated?: (updatedRecording: Recording) => void;
|
||||
}
|
||||
|
||||
export default function RecordingList({
|
||||
recordings,
|
||||
onRecordingUpdated,
|
||||
}: RecordingListProps) {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renamingTitle, setRenamingTitle] = useState("");
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInHours = Math.floor(
|
||||
(now.getTime() - date.getTime()) / (1000 * 60 * 60)
|
||||
);
|
||||
|
||||
if (diffInHours < 24) {
|
||||
if (diffInHours < 1) {
|
||||
return "刚刚";
|
||||
} else if (diffInHours < 2) {
|
||||
return "1小时前";
|
||||
} else {
|
||||
return `${diffInHours}小时前`;
|
||||
}
|
||||
} else {
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确定要删除这个录音吗?")) return;
|
||||
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const response = await fetch(`/api/recordings/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 触发父组件刷新
|
||||
console.log("录音删除成功,触发刷新事件");
|
||||
window.dispatchEvent(new CustomEvent("recording-deleted"));
|
||||
} else {
|
||||
throw new Error("删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除错误:", error);
|
||||
alert("删除失败,请重试。");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (id: string, newTitle: string) => {
|
||||
if (!newTitle.trim()) {
|
||||
alert("录音标题不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`开始重命名录音: ${id}, 新标题: "${newTitle}"`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/recordings/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ title: newTitle.trim() }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
// 触发父组件刷新
|
||||
console.log("录音重命名成功,触发刷新事件");
|
||||
window.dispatchEvent(new CustomEvent("recording-renamed"));
|
||||
|
||||
// 强制刷新当前录音的显示
|
||||
const updatedRecording = result.data;
|
||||
|
||||
// 调用父组件的更新回调
|
||||
if (onRecordingUpdated && updatedRecording) {
|
||||
onRecordingUpdated(updatedRecording);
|
||||
}
|
||||
|
||||
setRenamingId(null);
|
||||
setRenamingTitle("");
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error(
|
||||
`重命名失败,状态码: ${response.status}, 错误信息:`,
|
||||
errorData
|
||||
);
|
||||
throw new Error(`重命名失败: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("重命名错误:", error);
|
||||
alert("重命名失败,请重试。");
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (recording: Recording) => {
|
||||
setRenamingId(recording.id);
|
||||
setRenamingTitle(recording.title);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setRenamingId(null);
|
||||
setRenamingTitle("");
|
||||
};
|
||||
|
||||
if (!recordings || recordings.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||
<div className="relative mb-8">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-blue-100 to-purple-100 dark:from-blue-900/20 dark:to-purple-900/20 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-blue-500 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute -top-2 -right-2 w-8 h-8 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center">
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
开始你的录音之旅
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center max-w-md">
|
||||
录制你的第一个音频,让创意在这里发声
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:gap-6">
|
||||
{recordings.map((recording) => (
|
||||
<div
|
||||
key={recording.id}
|
||||
onMouseEnter={() => setHoveredId(recording.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className="group relative bg-white dark:bg-gray-800/50 backdrop-blur-sm rounded-2xl border border-gray-100 dark:border-gray-700/50 p-6 hover:shadow-lg hover:shadow-blue-500/5 dark:hover:shadow-blue-400/5 transition-all duration-300 hover:scale-[1.02] hover:border-blue-200 dark:hover:border-blue-700/50"
|
||||
>
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-50/50 to-purple-50/50 dark:from-blue-900/10 dark:to-purple-900/10 rounded-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* 头部信息 */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full animate-pulse" />
|
||||
{renamingId === recording.id ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={renamingTitle}
|
||||
onChange={(e) => setRenamingTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleRename(recording.id, renamingTitle);
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-3 py-1 border border-blue-300 dark:border-blue-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-lg font-semibold"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleRename(recording.id, renamingTitle)
|
||||
}
|
||||
className="p-1 text-green-500 hover:text-green-600 transition-colors"
|
||||
title="确定"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelRename}
|
||||
className="p-1 text-gray-500 hover:text-gray-600 transition-colors"
|
||||
title="取消"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-lg truncate group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
{recording.title} {/* 当前标题: {recording.title} */}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">
|
||||
{formatDuration(recording.duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{formatDate(recording.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
{/* 重命名按钮 */}
|
||||
<button
|
||||
onClick={() => startRename(recording)}
|
||||
className="p-2 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-all duration-200 group/btn"
|
||||
title="重命名"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 group-hover/btn:scale-110 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={() => handleDelete(recording.id)}
|
||||
disabled={deletingId === recording.id}
|
||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all duration-200 group/btn"
|
||||
title="删除录音"
|
||||
>
|
||||
{deletingId === recording.id ? (
|
||||
<LoadingSpinner size="sm" color="red" />
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5 group-hover/btn:scale-110 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音频播放器 */}
|
||||
<div className="relative">
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-xl p-4 border border-gray-100 dark:border-gray-600/50">
|
||||
<AudioPlayer
|
||||
src={recording.audioUrl}
|
||||
title={recording.title}
|
||||
duration={recording.duration}
|
||||
recordingId={recording.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 播放器装饰 */}
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
components/UserMenu.tsx
Normal file
180
components/UserMenu.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function UserMenu() {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsSigningOut(true);
|
||||
try {
|
||||
await signOut({
|
||||
callbackUrl: "/login",
|
||||
redirect: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("退出登录失败:", error);
|
||||
setIsSigningOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 用户头像按钮 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{session.user.image ? (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={session.user.name || "用户头像"}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-medium">
|
||||
{session.user.name?.[0] || session.user.email?.[0] || "U"}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden sm:block text-left">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{session.user.name || "用户"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{session.user.email}</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
||||
{/* 用户信息 */}
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{session.user.name || "用户"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{session.user.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单项 */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
router.push("/profile");
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
个人资料
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
router.push("/settings");
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
设置
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-100 my-1"></div>
|
||||
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={isSigningOut}
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSigningOut ? (
|
||||
<div className="w-4 h-4 border-2 border-red-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isSigningOut ? "退出中..." : "退出登录"}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 点击外部区域关闭菜单 */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
components/ui/button.tsx
Normal file
63
components/ui/button.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "underline-offset-4 hover:underline text-primary",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 py-2 px-4",
|
||||
sm: "h-9 px-3 rounded-md",
|
||||
lg: "h-11 px-8 rounded-md",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className, variant, size, loading, children, disabled, ...props },
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
Reference in New Issue
Block a user