Files
record-app-next/components/RecordingList.tsx
2025-07-31 17:05:07 +08:00

388 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}