feat: thêm tính năng lưu dự án bằng modal, Lưu dưới tên khác (Save As) và quản trị dự án/tệp tin trong Hồ sơ cá nhân
This commit is contained in:
+88
-4
@@ -236,16 +236,17 @@ async def ai_scan_audio(req: AIScanRequest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/ai-cut")
|
@router.post("/ai-cut")
|
||||||
async def ai_cut_audio(req: AICutRequest):
|
async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
17_AI_SCAN.md Feature 2: Fade-Free AI Cut (Zero-Crossing Aligned Slicing).
|
17_AI_SCAN.md Feature 2: Fade-Free AI Cut (Zero-Crossing Aligned Slicing).
|
||||||
Executes raw binary sample slice at exact zero-crossing coordinates.
|
Executes raw binary sample slice at exact zero-crossing coordinates.
|
||||||
"""
|
"""
|
||||||
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
output_file_id = f"ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
||||||
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||||
|
|
||||||
file_path = None
|
file_path = None
|
||||||
@@ -278,11 +279,12 @@ async def ai_cut_audio(req: AICutRequest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/python-tool")
|
@router.post("/python-tool")
|
||||||
async def run_python_dsp_tool(req: PythonToolRequest):
|
async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
Non-AI Python DSP Tools endpoint.
|
Non-AI Python DSP Tools endpoint.
|
||||||
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
||||||
"""
|
"""
|
||||||
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
from app.core.python_tools_engine import PythonToolsEngine
|
from app.core.python_tools_engine import PythonToolsEngine
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
@@ -290,7 +292,7 @@ async def run_python_dsp_tool(req: PythonToolRequest):
|
|||||||
|
|
||||||
if req.tool_type == "synth_wave":
|
if req.tool_type == "synth_wave":
|
||||||
wave = PythonToolsEngine.generate_synth_wave(req.wave_type or "sine", req.freq or 440.0, req.duration or 2.0)
|
wave = PythonToolsEngine.generate_synth_wave(req.wave_type or "sine", req.freq or 440.0, req.duration or 2.0)
|
||||||
output_file_id = f"synth_{req.wave_type}_{uuid.uuid4().hex[:6]}.wav"
|
output_file_id = f"user_{user_id}_synth_{req.wave_type}_{uuid.uuid4().hex[:6]}.wav"
|
||||||
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||||
sf.write(out_path, wave, 44100)
|
sf.write(out_path, wave, 44100)
|
||||||
return {
|
return {
|
||||||
@@ -311,3 +313,85 @@ async def run_python_dsp_tool(req: PythonToolRequest):
|
|||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Python Tool '{req.tool_type}' executed successfully for track {req.track_id}"
|
"message": f"Python Tool '{req.tool_type}' executed successfully for track {req.track_id}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MyFilesRequest(BaseModel):
|
||||||
|
active_file_ids: List[str] = []
|
||||||
|
|
||||||
|
@router.post("/my-files")
|
||||||
|
async def list_user_files(req: MyFilesRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
user_id = current_user["user_id"]
|
||||||
|
prefix = f"user_{user_id}_"
|
||||||
|
|
||||||
|
# Scan all user's projects to find referenced files
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT data_json FROM projects WHERE user_id = ?", (user_id,))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
referenced_in_db = set()
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
proj = json.loads(row["data_json"])
|
||||||
|
for track in proj.get("tracks", []):
|
||||||
|
fid = track.get("serverFileId")
|
||||||
|
if fid:
|
||||||
|
referenced_in_db.add(fid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
active_set = set(req.active_file_ids) | referenced_in_db
|
||||||
|
|
||||||
|
files_map = {}
|
||||||
|
|
||||||
|
def scan_dir(directory, type_label):
|
||||||
|
if not os.path.exists(directory):
|
||||||
|
return
|
||||||
|
for filename in os.listdir(directory):
|
||||||
|
if filename.startswith(prefix):
|
||||||
|
filepath = os.path.join(directory, filename)
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
stat = os.stat(filepath)
|
||||||
|
is_in_use = filename in active_set
|
||||||
|
|
||||||
|
if filename in files_map:
|
||||||
|
files_map[filename]["size_mb"] = round(files_map[filename]["size_mb"] + stat.st_size / (1024 * 1024), 2)
|
||||||
|
else:
|
||||||
|
files_map[filename] = {
|
||||||
|
"file_id": filename,
|
||||||
|
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||||
|
"created_at": stat.st_mtime,
|
||||||
|
"type": type_label,
|
||||||
|
"is_in_use": is_in_use
|
||||||
|
}
|
||||||
|
|
||||||
|
scan_dir(settings.UPLOADS_DIR, "Upload")
|
||||||
|
scan_dir(settings.PROCESSED_DIR, "Processed")
|
||||||
|
|
||||||
|
user_files = list(files_map.values())
|
||||||
|
user_files.sort(key=lambda x: x["created_at"], reverse=True)
|
||||||
|
return user_files
|
||||||
|
|
||||||
|
@router.delete("/my-files/{file_id}")
|
||||||
|
async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)):
|
||||||
|
user_id = current_user["user_id"]
|
||||||
|
prefix = f"user_{user_id}_"
|
||||||
|
|
||||||
|
# Guard: only own files can be deleted
|
||||||
|
if not file_id.startswith(prefix):
|
||||||
|
raise HTTPException(status_code=403, detail="Bạn không có quyền xóa tệp này")
|
||||||
|
|
||||||
|
deleted = False
|
||||||
|
for directory in [settings.UPLOADS_DIR, settings.PROCESSED_DIR]:
|
||||||
|
filepath = os.path.join(directory, file_id)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
deleted = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Không tìm thấy tệp trên server")
|
||||||
|
|
||||||
|
return {"success": True, "message": "Đã xóa tệp thành công"}
|
||||||
|
|||||||
+506
-117
@@ -2725,18 +2725,41 @@ const AIConfigModal = ({
|
|||||||
};
|
};
|
||||||
const ProfileModal = ({
|
const ProfileModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose
|
onClose,
|
||||||
|
tracks,
|
||||||
|
setTracks,
|
||||||
|
setSelectedTrackId,
|
||||||
|
projectName,
|
||||||
|
setProjectName,
|
||||||
|
currentProjectId,
|
||||||
|
setCurrentProjectId,
|
||||||
|
showToast
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
const [activeTab, setActiveTab] = useState('account');
|
||||||
const [profile, setProfile] = useState(null);
|
const [profile, setProfile] = useState(null);
|
||||||
const [oldPassword, setOldPassword] = useState('');
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Projects list state
|
||||||
|
const [projectsList, setProjectsList] = useState([]);
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
|
|
||||||
|
// Files list state
|
||||||
|
const [filesList, setFilesList] = useState([]);
|
||||||
|
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) fetchProfile();
|
if (isOpen) {
|
||||||
}, [isOpen]);
|
fetchProfile();
|
||||||
|
if (activeTab === 'projects') fetchProjects();
|
||||||
|
if (activeTab === 'files') fetchFiles();
|
||||||
|
}
|
||||||
|
}, [isOpen, activeTab]);
|
||||||
|
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await window.SonicAPI.getProfile();
|
const data = await window.SonicAPI.getProfile();
|
||||||
@@ -2745,6 +2768,103 @@ const ProfileModal = ({
|
|||||||
setError(e.message || 'Không thể tải thông tin profile');
|
setError(e.message || 'Không thể tải thông tin profile');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
setLoadingProjects(true);
|
||||||
|
try {
|
||||||
|
const data = await window.SonicAPI.listCloudProjects();
|
||||||
|
setProjectsList(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || 'Không thể tải danh sách dự án', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoadingProjects(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFiles = async () => {
|
||||||
|
setLoadingFiles(true);
|
||||||
|
try {
|
||||||
|
const activeFileIds = tracks.map(t => t.serverFileId).filter(Boolean);
|
||||||
|
const data = await window.SonicAPI.listMyFiles(activeFileIds);
|
||||||
|
setFilesList(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || 'Không thể tải danh sách tệp tin', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoadingFiles(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenProject = async (projectId) => {
|
||||||
|
if (!confirm("Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.")) return;
|
||||||
|
try {
|
||||||
|
const proj = await window.SonicAPI.getCloudProject(projectId);
|
||||||
|
const parsed = JSON.parse(proj.data_json);
|
||||||
|
const restored = (parsed.tracks || []).map(t => ({
|
||||||
|
...t,
|
||||||
|
buffer: null,
|
||||||
|
channelInfo: t.channelInfo || null,
|
||||||
|
clips: t.clips || [],
|
||||||
|
serverFileId: t.serverFileId || null
|
||||||
|
}));
|
||||||
|
setTracks(restored);
|
||||||
|
setSelectedTrackId(restored[0]?.id || '1');
|
||||||
|
setProjectName(proj.name);
|
||||||
|
setCurrentProjectId(proj.id);
|
||||||
|
localStorage.setItem('sonic_project_name', proj.name);
|
||||||
|
localStorage.setItem('sonic_project_id', proj.id);
|
||||||
|
showToast(`Đã nạp dự án "${proj.name}" thành công!`, "success");
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || "Lỗi khi nạp dự án", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteProject = async (projectId, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm("Bạn có chắc chắn muốn xóa dự án này khỏi Cloud?")) return;
|
||||||
|
try {
|
||||||
|
await window.SonicAPI.deleteCloudProject(projectId);
|
||||||
|
showToast("Đã xóa dự án thành công!", "success");
|
||||||
|
fetchProjects();
|
||||||
|
fetchProfile();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "Lỗi khi xóa dự án", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFile = async (fileId) => {
|
||||||
|
if (!confirm(`Bạn có chắc chắn muốn xóa tệp tin ${fileId}?`)) return;
|
||||||
|
try {
|
||||||
|
await window.SonicAPI.deleteMyFile(fileId);
|
||||||
|
showToast("Đã xóa tệp tin thành công!", "success");
|
||||||
|
fetchFiles();
|
||||||
|
fetchProfile();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "Lỗi khi xóa tệp tin", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCleanUnusedFiles = async () => {
|
||||||
|
const unusedFiles = filesList.filter(f => !f.is_in_use);
|
||||||
|
if (unusedFiles.length === 0) {
|
||||||
|
showToast("Không có tập tin rác nào để dọn dẹp.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Bạn có chắc chắn muốn xóa tất cả ${unusedFiles.length} tập tin rác để giải phóng dung lượng?`)) return;
|
||||||
|
let successCount = 0;
|
||||||
|
for (const file of unusedFiles) {
|
||||||
|
try {
|
||||||
|
await window.SonicAPI.deleteMyFile(file.file_id);
|
||||||
|
successCount++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Lỗi xóa file rác: ", file.file_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(`Đã dọn dẹp thành công ${successCount}/${unusedFiles.length} tập tin rác!`, "success");
|
||||||
|
fetchFiles();
|
||||||
|
fetchProfile();
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangePassword = async e => {
|
const handleChangePassword = async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMsg('');
|
setMsg('');
|
||||||
@@ -2761,80 +2881,300 @@ const ProfileModal = ({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return /*#__PURE__*/React.createElement("div", {
|
return /*#__PURE__*/React.createElement("div", {
|
||||||
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200"
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200 flex flex-col max-h-[85vh]"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex justify-between items-center pb-4 border-b border-[#383838]"
|
className: "flex justify-between items-center pb-3 border-b border-[#383838] shrink-0"
|
||||||
}, /*#__PURE__*/React.createElement("h3", {
|
}, /*#__PURE__*/React.createElement("h3", {
|
||||||
className: "text-lg font-bold text-teal-400"
|
className: "text-md font-bold text-teal-400 flex items-center gap-1.5"
|
||||||
}, "👤 Hồ Sơ Cá Nhân & Hạn Mức Quota"), /*#__PURE__*/React.createElement("button", {
|
}, "👤 Hồ Sơ Cá Nhân & Quản Lý Dự Án"), /*#__PURE__*/React.createElement("button", {
|
||||||
onClick: onClose,
|
onClick: onClose,
|
||||||
className: "text-slate-400 hover:text-slate-200"
|
className: "text-slate-400 hover:text-slate-200"
|
||||||
}, "✕")), profile && /*#__PURE__*/React.createElement("div", {
|
}, "✕")), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "mt-4 space-y-4"
|
className: "flex gap-2 border-b border-[#383838] py-2 shrink-0 text-xs font-semibold"
|
||||||
|
}, [
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "tab-acc",
|
||||||
|
onClick: () => setActiveTab('account'),
|
||||||
|
className: `px-3 py-1.5 rounded transition ${activeTab === 'account' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}`
|
||||||
|
}, "Tài Khoản"),
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "tab-proj",
|
||||||
|
onClick: () => setActiveTab('projects'),
|
||||||
|
className: `px-3 py-1.5 rounded transition ${activeTab === 'projects' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}`
|
||||||
|
}, "Dự Án Cloud"),
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "tab-files",
|
||||||
|
onClick: () => setActiveTab('files'),
|
||||||
|
className: `px-3 py-1.5 rounded transition ${activeTab === 'files' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}`
|
||||||
|
}, "Tập Tin Của Tôi")
|
||||||
|
]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "flex-1 overflow-y-auto pt-4 space-y-4 pr-1 min-h-[300px]"
|
||||||
|
}, activeTab === 'account' && profile ? [
|
||||||
|
/*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "quota-info",
|
||||||
|
className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs"
|
||||||
|
}, [
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "username" }, [
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "text-slate-500 block" }, "Tên người dùng"),
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "font-bold text-teal-300 text-sm" }, profile.username)
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "role" }, [
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "text-slate-500 block" }, "Vai trò"),
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "uppercase font-semibold text-amber-400" }, profile.role)
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "email" }, [
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "text-slate-500 block" }, "Email"),
|
||||||
|
/*#__PURE__*/React.createElement("span", null, profile.email)
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "quota" }, [
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "text-slate-500 block" }, "Dung lượng Quota"),
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "font-semibold text-slate-200" }, `${profile.quota.used_mb} MB / ${profile.quota.storage_limit_mb} MB`)
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "progress" }, [
|
||||||
|
/*#__PURE__*/React.createElement("div", { className: "flex justify-between text-xs mb-1" }, [
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "text-slate-400" }, "Tiến trình sử dụng bộ nhớ Server"),
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "font-bold text-teal-400" }, `${(profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1)}%`)
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", { className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden" }, [
|
||||||
|
/*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "h-full bg-teal-500 rounded-full transition-all duration-300",
|
||||||
|
style: { width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%` }
|
||||||
|
})
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("form", {
|
||||||
|
key: "pwd-form",
|
||||||
|
onSubmit: handleChangePassword,
|
||||||
|
className: "pt-4 border-t border-[#383838] space-y-3"
|
||||||
|
}, [
|
||||||
|
/*#__PURE__*/React.createElement("h4", { className: "text-xs font-bold text-slate-300 uppercase" }, "Thay Đổi Mật Khẩu"),
|
||||||
|
msg && /*#__PURE__*/React.createElement("div", { className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" }, msg),
|
||||||
|
error && /*#__PURE__*/React.createElement("div", { className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" }, error),
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "old" }, [
|
||||||
|
/*#__PURE__*/React.createElement("label", { className: "block text-xs text-slate-400 mb-1" }, "Mật khẩu cũ"),
|
||||||
|
/*#__PURE__*/React.createElement("input", {
|
||||||
|
type: "password",
|
||||||
|
autoComplete: "current-password",
|
||||||
|
required: true,
|
||||||
|
value: oldPassword,
|
||||||
|
onChange: e => setOldPassword(e.target.value),
|
||||||
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "new" }, [
|
||||||
|
/*#__PURE__*/React.createElement("label", { className: "block text-xs text-slate-400 mb-1" }, "Mật khẩu mới"),
|
||||||
|
/*#__PURE__*/React.createElement("input", {
|
||||||
|
type: "password",
|
||||||
|
autoComplete: "new-password",
|
||||||
|
required: true,
|
||||||
|
value: newPassword,
|
||||||
|
onChange: e => setNewPassword(e.target.value),
|
||||||
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
type: "submit",
|
||||||
|
disabled: loading,
|
||||||
|
className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition"
|
||||||
|
}, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu')
|
||||||
|
])
|
||||||
|
] : activeTab === 'projects' ? [
|
||||||
|
loadingProjects ? /*#__PURE__*/React.createElement("div", { key: "loading", className: "text-center py-8 text-xs text-zinc-500" }, "Đang tải danh sách dự án...") :
|
||||||
|
projectsList.length === 0 ? /*#__PURE__*/React.createElement("div", { key: "empty", className: "text-center py-8 text-xs text-zinc-500" }, "Bạn chưa có dự án nào lưu trên Cloud.") :
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "list", className: "space-y-1.5" }, [
|
||||||
|
projectsList.map(proj => /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: proj.id,
|
||||||
|
onClick: () => handleOpenProject(proj.id),
|
||||||
|
className: "flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group"
|
||||||
|
}, [
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "meta" }, [
|
||||||
|
/*#__PURE__*/React.createElement("div", { className: "font-bold text-slate-200 group-hover:text-teal-400" }, proj.name),
|
||||||
|
/*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 mt-0.5" }, `Dung lượng: ${proj.size_mb} MB | Cập nhật: ${new Date(proj.updated_at * 1000).toLocaleString()}`)
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "actions", className: "flex items-center gap-1.5" }, [
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: (e) => { e.stopPropagation(); handleOpenProject(proj.id); },
|
||||||
|
className: "px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]"
|
||||||
|
}, "MỞ"),
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: (e) => handleDeleteProject(proj.id, e),
|
||||||
|
className: "px-2 py-1 bg-rose-950/60 hover:bg-rose-900/80 text-rose-300 rounded font-semibold text-[10px]"
|
||||||
|
}, "XÓA")
|
||||||
|
])
|
||||||
|
]))
|
||||||
|
])
|
||||||
|
] : activeTab === 'files' ? [
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "cleanup-header", className: "flex items-center justify-between bg-zinc-900/60 p-2.5 rounded border border-zinc-800 text-xs shrink-0 mb-3" }, [
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "text-zinc-400 text-[10px]" }, "💡 Các tập tin không dùng trong dự án nào sẽ được đánh dấu 'Không dùng' và có thể xóa để giải phóng dung lượng."),
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: handleCleanUnusedFiles,
|
||||||
|
className: "px-2.5 py-1 bg-rose-700 hover:bg-rose-600 text-white rounded font-bold text-[10px] uppercase flex items-center gap-1"
|
||||||
|
}, "🧹 Dọn dẹp tệp rác")
|
||||||
|
]),
|
||||||
|
loadingFiles ? /*#__PURE__*/React.createElement("div", { key: "loading", className: "text-center py-8 text-xs text-zinc-500" }, "Đang tải danh sách tập tin...") :
|
||||||
|
filesList.length === 0 ? /*#__PURE__*/React.createElement("div", { key: "empty", className: "text-center py-8 text-xs text-zinc-500" }, "Chưa có tập tin nào tải lên hoặc tạo ra.") :
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "list", className: "space-y-1.5" }, [
|
||||||
|
filesList.map(file => /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: file.file_id,
|
||||||
|
className: "flex items-center justify-between p-3 bg-[#1e1e1e] rounded border border-zinc-800 text-xs"
|
||||||
|
}, [
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "meta", className: "max-w-[70%]" }, [
|
||||||
|
/*#__PURE__*/React.createElement("div", { className: "font-mono font-semibold text-slate-300 truncate" }, file.file_id.replace(/^user_[^_]+_/, '')),
|
||||||
|
/*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 mt-0.5" }, `Loại: ${file.type} | Dung lượng: ${file.size_mb} MB | Tạo lúc: ${new Date(file.created_at * 1000).toLocaleString()}`)
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", { key: "actions", className: "flex items-center gap-2" }, [
|
||||||
|
file.is_in_use ?
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "px-2 py-0.5 bg-emerald-950 text-emerald-400 border border-emerald-900 rounded text-[9px] uppercase font-bold font-mono" }, "Đang dùng") :
|
||||||
|
/*#__PURE__*/React.createElement("span", { className: "px-2 py-0.5 bg-zinc-850 text-zinc-400 border border-zinc-700 rounded text-[9px] uppercase font-bold font-mono" }, "Không dùng"),
|
||||||
|
!file.is_in_use && /*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: () => handleDeleteFile(file.file_id),
|
||||||
|
className: "px-2 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded font-semibold text-[10px] border border-rose-900"
|
||||||
|
}, "Xóa")
|
||||||
|
])
|
||||||
|
]))
|
||||||
|
])
|
||||||
|
] : null)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const SaveProjectModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
onSave(name.trim());
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
return /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs"
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"
|
||||||
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
}, /*#__PURE__*/React.createElement("h3", {
|
||||||
className: "text-slate-500 block"
|
className: "text-sm font-bold text-teal-400 mb-4 uppercase"
|
||||||
}, "Tên người dùng"), /*#__PURE__*/React.createElement("span", {
|
}, "Đặt tên dự án"), /*#__PURE__*/React.createElement("form", {
|
||||||
className: "font-bold text-teal-300 text-sm"
|
onSubmit: handleSubmit,
|
||||||
}, profile.username)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
className: "space-y-4"
|
||||||
className: "text-slate-500 block"
|
}, [
|
||||||
}, "Vai trò"), /*#__PURE__*/React.createElement("span", {
|
/*#__PURE__*/React.createElement("input", {
|
||||||
className: "uppercase font-semibold text-amber-400"
|
key: "name-input",
|
||||||
}, profile.role)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
type: "text",
|
||||||
className: "text-slate-500 block"
|
placeholder: "Nhập tên dự án...",
|
||||||
}, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
required: true,
|
||||||
className: "text-slate-500 block"
|
value: name,
|
||||||
}, "Dung lượng Quota"), /*#__PURE__*/React.createElement("span", {
|
onChange: e => setName(e.target.value),
|
||||||
className: "font-semibold text-slate-200"
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold",
|
||||||
}, profile.quota.used_mb, " MB / ", profile.quota.storage_limit_mb, " MB"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", {
|
autoFocus: true
|
||||||
className: "flex justify-between text-xs mb-1"
|
}),
|
||||||
}, /*#__PURE__*/React.createElement("span", {
|
/*#__PURE__*/React.createElement("div", {
|
||||||
className: "text-slate-400"
|
key: "actions",
|
||||||
}, "Tiến trình sử dụng bộ nhớ Server"), /*#__PURE__*/React.createElement("span", {
|
className: "flex justify-end gap-2 text-xs"
|
||||||
className: "font-bold text-teal-400"
|
}, [
|
||||||
}, (profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1), "%")), /*#__PURE__*/React.createElement("div", {
|
/*#__PURE__*/React.createElement("button", {
|
||||||
className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden"
|
key: "cancel",
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
type: "button",
|
||||||
className: "h-full bg-teal-500 rounded-full transition-all duration-300",
|
onClick: onClose,
|
||||||
style: {
|
className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"
|
||||||
width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%`
|
}, "Hủy"),
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "save",
|
||||||
|
type: "submit",
|
||||||
|
className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold"
|
||||||
|
}, "Lưu")
|
||||||
|
])
|
||||||
|
])));
|
||||||
|
};
|
||||||
|
|
||||||
|
const SaveAsModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
projectName,
|
||||||
|
onSaveCloud,
|
||||||
|
onSaveLocal
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
const [name, setName] = useState(projectName || '');
|
||||||
|
const [saveType, setSaveType] = useState('cloud');
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
if (saveType === 'cloud') {
|
||||||
|
onSaveCloud(name.trim());
|
||||||
|
} else {
|
||||||
|
onSaveLocal(name.trim());
|
||||||
}
|
}
|
||||||
}))), /*#__PURE__*/React.createElement("form", {
|
onClose();
|
||||||
onSubmit: handleChangePassword,
|
};
|
||||||
className: "pt-4 border-t border-[#383838] space-y-3"
|
return /*#__PURE__*/React.createElement("div", {
|
||||||
}, /*#__PURE__*/React.createElement("h4", {
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
className: "text-xs font-bold text-slate-300 uppercase"
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
}, "Thay Đổi Mật Khẩu"), msg && /*#__PURE__*/React.createElement("div", {
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"
|
||||||
className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"
|
}, /*#__PURE__*/React.createElement("h3", {
|
||||||
}, msg), error && /*#__PURE__*/React.createElement("div", {
|
className: "text-sm font-bold text-teal-400 mb-4 uppercase"
|
||||||
className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"
|
}, "Lưu dưới tên khác (Save As...)"), /*#__PURE__*/React.createElement("form", {
|
||||||
}, error), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
onSubmit: handleSubmit,
|
||||||
className: "block text-xs text-slate-400 mb-1"
|
className: "space-y-4"
|
||||||
}, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", {
|
}, [
|
||||||
type: "password",
|
/*#__PURE__*/React.createElement("div", { key: "name-block" }, [
|
||||||
autoComplete: "current-password",
|
/*#__PURE__*/React.createElement("label", { className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1" }, "Tên dự án mới"),
|
||||||
required: true,
|
/*#__PURE__*/React.createElement("input", {
|
||||||
value: oldPassword,
|
type: "text",
|
||||||
onChange: e => setOldPassword(e.target.value),
|
placeholder: "Nhập tên mới...",
|
||||||
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
required: true,
|
||||||
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
value: name,
|
||||||
className: "block text-xs text-slate-400 mb-1"
|
onChange: e => setName(e.target.value),
|
||||||
}, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", {
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold",
|
||||||
type: "password",
|
autoFocus: true
|
||||||
autoComplete: "new-password",
|
})
|
||||||
required: true,
|
]),
|
||||||
value: newPassword,
|
/*#__PURE__*/React.createElement("div", { key: "type-block" }, [
|
||||||
onChange: e => setNewPassword(e.target.value),
|
/*#__PURE__*/React.createElement("label", { className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1.5" }, "Phương thức lưu trữ"),
|
||||||
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
/*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs" }, [
|
||||||
})), /*#__PURE__*/React.createElement("button", {
|
/*#__PURE__*/React.createElement("button", {
|
||||||
type: "submit",
|
key: "btn-cloud",
|
||||||
disabled: loading,
|
type: "button",
|
||||||
className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition"
|
onClick: () => setSaveType('cloud'),
|
||||||
}, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu')))));
|
className: `py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}`
|
||||||
|
}, [
|
||||||
|
/*#__PURE__*/React.createElement("span", { key: "title" }, "☁️ Lưu Cloud"),
|
||||||
|
/*#__PURE__*/React.createElement("span", { key: "desc", className: "text-[9px] font-normal text-zinc-500" }, "Lưu lên server cá nhân")
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "btn-local",
|
||||||
|
type: "button",
|
||||||
|
onClick: () => setSaveType('local'),
|
||||||
|
className: `py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}`
|
||||||
|
}, [
|
||||||
|
/*#__PURE__*/React.createElement("span", { key: "title" }, "💾 Tải về máy (.sfs)"),
|
||||||
|
/*#__PURE__*/React.createElement("span", { key: "desc", className: "text-[9px] font-normal text-zinc-500" }, "Tải tệp JSON dự án về máy")
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
/*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "actions",
|
||||||
|
className: "flex justify-end gap-2 text-xs pt-2"
|
||||||
|
}, [
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "cancel",
|
||||||
|
type: "button",
|
||||||
|
onClick: onClose,
|
||||||
|
className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"
|
||||||
|
}, "Hủy"),
|
||||||
|
/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "save",
|
||||||
|
type: "submit",
|
||||||
|
className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold"
|
||||||
|
}, "Thực hiện lưu")
|
||||||
|
])
|
||||||
|
])));
|
||||||
};
|
};
|
||||||
const SystemManagerModal = ({
|
const SystemManagerModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -3043,6 +3383,9 @@ const App = () => {
|
|||||||
const [subTabHeight, setSubTabHeight] = useState(96);
|
const [subTabHeight, setSubTabHeight] = useState(96);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [projectName, setProjectName] = useState(() => localStorage.getItem('sonic_project_name') || '');
|
const [projectName, setProjectName] = useState(() => localStorage.getItem('sonic_project_name') || '');
|
||||||
|
const [currentProjectId, setCurrentProjectId] = useState(() => localStorage.getItem('sonic_project_id') || null);
|
||||||
|
const [saveProjectModalOpen, setSaveProjectModalOpen] = useState(false);
|
||||||
|
const [saveAsModalOpen, setSaveAsModalOpen] = useState(false);
|
||||||
const [soloedTrackId, setSoloedTrackId] = useState(null);
|
const [soloedTrackId, setSoloedTrackId] = useState(null);
|
||||||
const [toastMessage, setToastMessage] = useState(null);
|
const [toastMessage, setToastMessage] = useState(null);
|
||||||
const [showAIConfig, setShowAIConfig] = useState(false);
|
const [showAIConfig, setShowAIConfig] = useState(false);
|
||||||
@@ -6414,57 +6757,69 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
const handleSaveProject = async () => {
|
const handleSaveProject = async () => {
|
||||||
if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; }
|
if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; }
|
||||||
let name = projectName;
|
if (!projectName) {
|
||||||
if (!name) { name = prompt("Nhập tên dự án:", "Dự án SonicForge"); if (!name) return; }
|
setSaveProjectModalOpen(true);
|
||||||
setProjectName(name);
|
|
||||||
localStorage.setItem('sonic_project_name', name);
|
|
||||||
const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, startTime: t.startTime, height: t.height, volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null }));
|
|
||||||
try {
|
|
||||||
const dataJson = JSON.stringify({ id: 'project_' + Date.now(), name, tracks: ss(tracks) });
|
|
||||||
await window.SonicAPI.saveCloudProject(name, dataJson);
|
|
||||||
showToast(`Đã lưu "${name}" lên server!`, "success");
|
|
||||||
} catch (err) { showToast(err.message || "Lỗi lưu server", "error"); }
|
|
||||||
};
|
|
||||||
const handleSaveCloud = async () => {
|
|
||||||
if (!currentUser) {
|
|
||||||
setIsMandatoryLogin(false);
|
|
||||||
setAuthMode('login');
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const name = prompt("Nhập tên dự án để lưu lên Cloud:", "Dự án SonicForge");
|
const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, startTime: t.startTime, height: t.height, volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null }));
|
||||||
if (!name) return;
|
const dataJson = JSON.stringify({ id: currentProjectId || 'project_' + Date.now(), name: projectName, tracks: ss(tracks) });
|
||||||
const serializeSafe = arr => (arr || []).map(t => ({
|
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
startTime: t.startTime,
|
|
||||||
height: t.height,
|
|
||||||
volumeDb: t.volumeDb,
|
|
||||||
pan: t.pan,
|
|
||||||
muted: t.muted,
|
|
||||||
solo: t.solo,
|
|
||||||
color: t.color,
|
|
||||||
markers: t.markers || [],
|
|
||||||
serverFileId: t.serverFileId || null,
|
|
||||||
channelInfo: t.channelInfo ? {
|
|
||||||
channels: t.channelInfo.channels,
|
|
||||||
isStereo: t.channelInfo.isStereo,
|
|
||||||
label: t.channelInfo.label
|
|
||||||
} : null
|
|
||||||
}));
|
|
||||||
try {
|
try {
|
||||||
const dataJson = JSON.stringify({
|
if (currentProjectId) {
|
||||||
id: 'cloud_project',
|
await window.SonicAPI.updateCloudProject(currentProjectId, projectName, dataJson);
|
||||||
name,
|
showToast(`Đã cập nhật dự án "${projectName}" lên server!`, "success");
|
||||||
tracks: serializeSafe(tracks)
|
} else {
|
||||||
});
|
const res = await window.SonicAPI.saveCloudProject(projectName, dataJson);
|
||||||
await window.SonicAPI.saveCloudProject(name, dataJson);
|
const newProjId = res.project_id;
|
||||||
showToast("Đã lưu dự án lên Cloud thành công!", "success");
|
if (newProjId) {
|
||||||
|
setCurrentProjectId(newProjId);
|
||||||
|
localStorage.setItem('sonic_project_id', newProjId);
|
||||||
|
}
|
||||||
|
showToast(`Đã lưu dự án "${projectName}" mới lên server!`, "success");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "Lỗi lưu Cloud", "error");
|
showToast(err.message || "Lỗi lưu server", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleExportSFS = () => {
|
|
||||||
|
const handleSaveProjectWithName = async (newName) => {
|
||||||
|
setProjectName(newName);
|
||||||
|
localStorage.setItem('sonic_project_name', newName);
|
||||||
|
const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, startTime: t.startTime, height: t.height, volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null }));
|
||||||
|
const dataJson = JSON.stringify({ id: 'project_' + Date.now(), name: newName, tracks: ss(tracks) });
|
||||||
|
try {
|
||||||
|
const res = await window.SonicAPI.saveCloudProject(newName, dataJson);
|
||||||
|
const newProjId = res.project_id;
|
||||||
|
if (newProjId) {
|
||||||
|
setCurrentProjectId(newProjId);
|
||||||
|
localStorage.setItem('sonic_project_id', newProjId);
|
||||||
|
}
|
||||||
|
showToast(`Đã lưu dự án "${newName}" mới lên server!`, "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "Lỗi lưu server", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAsCloud = async (newName) => {
|
||||||
|
const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, startTime: t.startTime, height: t.height, volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null }));
|
||||||
|
const dataJson = JSON.stringify({ id: 'project_' + Date.now(), name: newName, tracks: ss(tracks) });
|
||||||
|
try {
|
||||||
|
const res = await window.SonicAPI.saveCloudProject(newName, dataJson);
|
||||||
|
const newProjId = res.project_id;
|
||||||
|
setProjectName(newName);
|
||||||
|
localStorage.setItem('sonic_project_name', newName);
|
||||||
|
if (newProjId) {
|
||||||
|
setCurrentProjectId(newProjId);
|
||||||
|
localStorage.setItem('sonic_project_id', newProjId);
|
||||||
|
}
|
||||||
|
showToast(`Đã lưu dự án dưới tên mới "${newName}" lên server!`, "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "Lỗi Save As lên server", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCloud = handleSaveProject;
|
||||||
|
const handleExportSFS = (customName = null) => {
|
||||||
|
const finalName = customName || projectName || 'Dự án SonicForge';
|
||||||
const serializeSafe = arr => (arr || []).map(t => ({
|
const serializeSafe = arr => (arr || []).map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
@@ -6484,8 +6839,8 @@ const App = () => {
|
|||||||
} : null
|
} : null
|
||||||
}));
|
}));
|
||||||
window.SonicStorage.exportProjectToSFS({
|
window.SonicStorage.exportProjectToSFS({
|
||||||
id: 'proj_' + Date.now(),
|
id: currentProjectId || 'proj_' + Date.now(),
|
||||||
name: 'Dự án SonicForge',
|
name: finalName,
|
||||||
tracks: serializeSafe(tracks)
|
tracks: serializeSafe(tracks)
|
||||||
});
|
});
|
||||||
showToast("Đã xuất dự án (.sfs) thành công!", "success");
|
showToast("Đã xuất dự án (.sfs) thành công!", "success");
|
||||||
@@ -6507,6 +6862,10 @@ const App = () => {
|
|||||||
}));
|
}));
|
||||||
if (restored.length > 0) {
|
if (restored.length > 0) {
|
||||||
setTracks(restored);
|
setTracks(restored);
|
||||||
|
setProjectName(proj.name || 'Dự án mới');
|
||||||
|
setCurrentProjectId(null);
|
||||||
|
localStorage.setItem('sonic_project_name', proj.name || 'Dự án mới');
|
||||||
|
localStorage.removeItem('sonic_project_id');
|
||||||
showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success");
|
showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -8170,6 +8529,10 @@ const App = () => {
|
|||||||
serverFileId: null
|
serverFileId: null
|
||||||
}]);
|
}]);
|
||||||
setSelectedTrackId('1');
|
setSelectedTrackId('1');
|
||||||
|
setProjectName('');
|
||||||
|
setCurrentProjectId(null);
|
||||||
|
localStorage.removeItem('sonic_project_name');
|
||||||
|
localStorage.removeItem('sonic_project_id');
|
||||||
showToast('New project created', 'info');
|
showToast('New project created', 'info');
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
@@ -8186,7 +8549,7 @@ const App = () => {
|
|||||||
label: 'Save As...',
|
label: 'Save As...',
|
||||||
icon: 'download',
|
icon: 'download',
|
||||||
shortcut: 'Ctrl+Alt+S',
|
shortcut: 'Ctrl+Alt+S',
|
||||||
action: () => handleExportSFS()
|
action: () => setSaveAsModalOpen(true)
|
||||||
}, {
|
}, {
|
||||||
sep: true
|
sep: true
|
||||||
}, {
|
}, {
|
||||||
@@ -10603,7 +10966,33 @@ const App = () => {
|
|||||||
onSuccess: handleAuthSuccess
|
onSuccess: handleAuthSuccess
|
||||||
}), /*#__PURE__*/React.createElement(ProfileModal, {
|
}), /*#__PURE__*/React.createElement(ProfileModal, {
|
||||||
isOpen: profileModalOpen,
|
isOpen: profileModalOpen,
|
||||||
onClose: () => setProfileModalOpen(false)
|
onClose: () => setProfileModalOpen(false),
|
||||||
|
tracks: tracks,
|
||||||
|
setTracks: setTracks,
|
||||||
|
setSelectedTrackId: setSelectedTrackId,
|
||||||
|
projectName: projectName,
|
||||||
|
setProjectName: setProjectName,
|
||||||
|
currentProjectId: currentProjectId,
|
||||||
|
setCurrentProjectId: setCurrentProjectId,
|
||||||
|
showToast: showToast
|
||||||
|
}), /*#__PURE__*/React.createElement(SaveProjectModal, {
|
||||||
|
isOpen: saveProjectModalOpen,
|
||||||
|
onClose: () => setSaveProjectModalOpen(false),
|
||||||
|
onSave: (newName) => {
|
||||||
|
handleSaveProjectWithName(newName);
|
||||||
|
}
|
||||||
|
}), /*#__PURE__*/React.createElement(SaveAsModal, {
|
||||||
|
isOpen: saveAsModalOpen,
|
||||||
|
onClose: () => setSaveAsModalOpen(false),
|
||||||
|
projectName: projectName,
|
||||||
|
onSaveCloud: (newName) => {
|
||||||
|
handleSaveAsCloud(newName);
|
||||||
|
},
|
||||||
|
onSaveLocal: (newName) => {
|
||||||
|
handleExportSFS(newName);
|
||||||
|
setProjectName(newName);
|
||||||
|
localStorage.setItem('sonic_project_name', newName);
|
||||||
|
}
|
||||||
}), /*#__PURE__*/React.createElement(AIConfigModal, {
|
}), /*#__PURE__*/React.createElement(AIConfigModal, {
|
||||||
isOpen: aiConfigModalOpen,
|
isOpen: aiConfigModalOpen,
|
||||||
onClose: () => setAiConfigModalOpen(false),
|
onClose: () => setAiConfigModalOpen(false),
|
||||||
|
|||||||
@@ -2762,18 +2762,39 @@ const AIConfigModal = ({
|
|||||||
};
|
};
|
||||||
const ProfileModal = ({
|
const ProfileModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose
|
onClose,
|
||||||
|
tracks,
|
||||||
|
setTracks,
|
||||||
|
setSelectedTrackId,
|
||||||
|
projectName,
|
||||||
|
setProjectName,
|
||||||
|
currentProjectId,
|
||||||
|
setCurrentProjectId,
|
||||||
|
showToast
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
const [activeTab, setActiveTab] = useState('account');
|
||||||
const [profile, setProfile] = useState(null);
|
const [profile, setProfile] = useState(null);
|
||||||
const [oldPassword, setOldPassword] = useState('');
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Projects list state
|
||||||
|
const [projectsList, setProjectsList] = useState([]);
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
|
|
||||||
|
// Files list state
|
||||||
|
const [filesList, setFilesList] = useState([]);
|
||||||
|
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) fetchProfile();
|
if (isOpen) {
|
||||||
}, [isOpen]);
|
fetchProfile();
|
||||||
|
if (activeTab === 'projects') fetchProjects();
|
||||||
|
if (activeTab === 'files') fetchFiles();
|
||||||
|
}
|
||||||
|
}, [isOpen, activeTab]);
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await window.SonicAPI.getProfile();
|
const data = await window.SonicAPI.getProfile();
|
||||||
@@ -2782,6 +2803,96 @@ const ProfileModal = ({
|
|||||||
setError(e.message || 'Không thể tải thông tin profile');
|
setError(e.message || 'Không thể tải thông tin profile');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
setLoadingProjects(true);
|
||||||
|
try {
|
||||||
|
const data = await window.SonicAPI.listCloudProjects();
|
||||||
|
setProjectsList(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || 'Không thể tải danh sách dự án', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoadingProjects(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchFiles = async () => {
|
||||||
|
setLoadingFiles(true);
|
||||||
|
try {
|
||||||
|
const activeFileIds = tracks.map(t => t.serverFileId).filter(Boolean);
|
||||||
|
const data = await window.SonicAPI.listMyFiles(activeFileIds);
|
||||||
|
setFilesList(data || []);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || 'Không thể tải danh sách tệp tin', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoadingFiles(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleOpenProject = async projectId => {
|
||||||
|
if (!confirm("Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.")) return;
|
||||||
|
try {
|
||||||
|
const proj = await window.SonicAPI.getCloudProject(projectId);
|
||||||
|
const parsed = JSON.parse(proj.data_json);
|
||||||
|
const restored = (parsed.tracks || []).map(t => ({
|
||||||
|
...t,
|
||||||
|
buffer: null,
|
||||||
|
channelInfo: t.channelInfo || null,
|
||||||
|
clips: t.clips || [],
|
||||||
|
serverFileId: t.serverFileId || null
|
||||||
|
}));
|
||||||
|
setTracks(restored);
|
||||||
|
setSelectedTrackId(restored[0]?.id || '1');
|
||||||
|
setProjectName(proj.name);
|
||||||
|
setCurrentProjectId(proj.id);
|
||||||
|
localStorage.setItem('sonic_project_name', proj.name);
|
||||||
|
localStorage.setItem('sonic_project_id', proj.id);
|
||||||
|
showToast(`Đã nạp dự án "${proj.name}" thành công!`, "success");
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || "Lỗi khi nạp dự án", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeleteProject = async (projectId, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm("Bạn có chắc chắn muốn xóa dự án này khỏi Cloud?")) return;
|
||||||
|
try {
|
||||||
|
await window.SonicAPI.deleteCloudProject(projectId);
|
||||||
|
showToast("Đã xóa dự án thành công!", "success");
|
||||||
|
fetchProjects();
|
||||||
|
fetchProfile();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "Lỗi khi xóa dự án", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeleteFile = async fileId => {
|
||||||
|
if (!confirm(`Bạn có chắc chắn muốn xóa tệp tin ${fileId}?`)) return;
|
||||||
|
try {
|
||||||
|
await window.SonicAPI.deleteMyFile(fileId);
|
||||||
|
showToast("Đã xóa tệp tin thành công!", "success");
|
||||||
|
fetchFiles();
|
||||||
|
fetchProfile();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "Lỗi khi xóa tệp tin", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleCleanUnusedFiles = async () => {
|
||||||
|
const unusedFiles = filesList.filter(f => !f.is_in_use);
|
||||||
|
if (unusedFiles.length === 0) {
|
||||||
|
showToast("Không có tập tin rác nào để dọn dẹp.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Bạn có chắc chắn muốn xóa tất cả ${unusedFiles.length} tập tin rác để giải phóng dung lượng?`)) return;
|
||||||
|
let successCount = 0;
|
||||||
|
for (const file of unusedFiles) {
|
||||||
|
try {
|
||||||
|
await window.SonicAPI.deleteMyFile(file.file_id);
|
||||||
|
successCount++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Lỗi xóa file rác: ", file.file_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(`Đã dọn dẹp thành công ${successCount}/${unusedFiles.length} tập tin rác!`, "success");
|
||||||
|
fetchFiles();
|
||||||
|
fetchProfile();
|
||||||
|
};
|
||||||
const handleChangePassword = async e => {
|
const handleChangePassword = async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMsg('');
|
setMsg('');
|
||||||
@@ -2801,55 +2912,83 @@ const ProfileModal = ({
|
|||||||
return /*#__PURE__*/React.createElement("div", {
|
return /*#__PURE__*/React.createElement("div", {
|
||||||
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200"
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200 flex flex-col max-h-[85vh]"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex justify-between items-center pb-4 border-b border-[#383838]"
|
className: "flex justify-between items-center pb-3 border-b border-[#383838] shrink-0"
|
||||||
}, /*#__PURE__*/React.createElement("h3", {
|
}, /*#__PURE__*/React.createElement("h3", {
|
||||||
className: "text-lg font-bold text-teal-400"
|
className: "text-md font-bold text-teal-400 flex items-center gap-1.5"
|
||||||
}, "👤 Hồ Sơ Cá Nhân & Hạn Mức Quota"), /*#__PURE__*/React.createElement("button", {
|
}, "👤 Hồ Sơ Cá Nhân & Quản Lý Dự Án"), /*#__PURE__*/React.createElement("button", {
|
||||||
onClick: onClose,
|
onClick: onClose,
|
||||||
className: "text-slate-400 hover:text-slate-200"
|
className: "text-slate-400 hover:text-slate-200"
|
||||||
}, "✕")), profile && /*#__PURE__*/React.createElement("div", {
|
}, "✕")), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "mt-4 space-y-4"
|
className: "flex gap-2 border-b border-[#383838] py-2 shrink-0 text-xs font-semibold"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, [/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "tab-acc",
|
||||||
|
onClick: () => setActiveTab('account'),
|
||||||
|
className: `px-3 py-1.5 rounded transition ${activeTab === 'account' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}`
|
||||||
|
}, "Tài Khoản"), /*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "tab-proj",
|
||||||
|
onClick: () => setActiveTab('projects'),
|
||||||
|
className: `px-3 py-1.5 rounded transition ${activeTab === 'projects' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}`
|
||||||
|
}, "Dự Án Cloud"), /*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "tab-files",
|
||||||
|
onClick: () => setActiveTab('files'),
|
||||||
|
className: `px-3 py-1.5 rounded transition ${activeTab === 'files' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}`
|
||||||
|
}, "Tập Tin Của Tôi")]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "flex-1 overflow-y-auto pt-4 space-y-4 pr-1 min-h-[300px]"
|
||||||
|
}, activeTab === 'account' && profile ? [/*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "quota-info",
|
||||||
className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs"
|
className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs"
|
||||||
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
}, [/*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "username"
|
||||||
|
}, [/*#__PURE__*/React.createElement("span", {
|
||||||
className: "text-slate-500 block"
|
className: "text-slate-500 block"
|
||||||
}, "Tên người dùng"), /*#__PURE__*/React.createElement("span", {
|
}, "Tên người dùng"), /*#__PURE__*/React.createElement("span", {
|
||||||
className: "font-bold text-teal-300 text-sm"
|
className: "font-bold text-teal-300 text-sm"
|
||||||
}, profile.username)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
}, profile.username)]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "role"
|
||||||
|
}, [/*#__PURE__*/React.createElement("span", {
|
||||||
className: "text-slate-500 block"
|
className: "text-slate-500 block"
|
||||||
}, "Vai trò"), /*#__PURE__*/React.createElement("span", {
|
}, "Vai trò"), /*#__PURE__*/React.createElement("span", {
|
||||||
className: "uppercase font-semibold text-amber-400"
|
className: "uppercase font-semibold text-amber-400"
|
||||||
}, profile.role)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
}, profile.role)]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "email"
|
||||||
|
}, [/*#__PURE__*/React.createElement("span", {
|
||||||
className: "text-slate-500 block"
|
className: "text-slate-500 block"
|
||||||
}, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
}, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "quota"
|
||||||
|
}, [/*#__PURE__*/React.createElement("span", {
|
||||||
className: "text-slate-500 block"
|
className: "text-slate-500 block"
|
||||||
}, "Dung lượng Quota"), /*#__PURE__*/React.createElement("span", {
|
}, "Dung lượng Quota"), /*#__PURE__*/React.createElement("span", {
|
||||||
className: "font-semibold text-slate-200"
|
className: "font-semibold text-slate-200"
|
||||||
}, profile.quota.used_mb, " MB / ", profile.quota.storage_limit_mb, " MB"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", {
|
}, `${profile.quota.used_mb} MB / ${profile.quota.storage_limit_mb} MB`)])]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "progress"
|
||||||
|
}, [/*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex justify-between text-xs mb-1"
|
className: "flex justify-between text-xs mb-1"
|
||||||
}, /*#__PURE__*/React.createElement("span", {
|
}, [/*#__PURE__*/React.createElement("span", {
|
||||||
className: "text-slate-400"
|
className: "text-slate-400"
|
||||||
}, "Tiến trình sử dụng bộ nhớ Server"), /*#__PURE__*/React.createElement("span", {
|
}, "Tiến trình sử dụng bộ nhớ Server"), /*#__PURE__*/React.createElement("span", {
|
||||||
className: "font-bold text-teal-400"
|
className: "font-bold text-teal-400"
|
||||||
}, (profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1), "%")), /*#__PURE__*/React.createElement("div", {
|
}, `${(profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1)}%`)]), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden"
|
className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, [/*#__PURE__*/React.createElement("div", {
|
||||||
className: "h-full bg-teal-500 rounded-full transition-all duration-300",
|
className: "h-full bg-teal-500 rounded-full transition-all duration-300",
|
||||||
style: {
|
style: {
|
||||||
width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%`
|
width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%`
|
||||||
}
|
}
|
||||||
}))), /*#__PURE__*/React.createElement("form", {
|
})])]), /*#__PURE__*/React.createElement("form", {
|
||||||
|
key: "pwd-form",
|
||||||
onSubmit: handleChangePassword,
|
onSubmit: handleChangePassword,
|
||||||
className: "pt-4 border-t border-[#383838] space-y-3"
|
className: "pt-4 border-t border-[#383838] space-y-3"
|
||||||
}, /*#__PURE__*/React.createElement("h4", {
|
}, [/*#__PURE__*/React.createElement("h4", {
|
||||||
className: "text-xs font-bold text-slate-300 uppercase"
|
className: "text-xs font-bold text-slate-300 uppercase"
|
||||||
}, "Thay Đổi Mật Khẩu"), msg && /*#__PURE__*/React.createElement("div", {
|
}, "Thay Đổi Mật Khẩu"), msg && /*#__PURE__*/React.createElement("div", {
|
||||||
className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"
|
className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"
|
||||||
}, msg), error && /*#__PURE__*/React.createElement("div", {
|
}, msg), error && /*#__PURE__*/React.createElement("div", {
|
||||||
className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"
|
className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"
|
||||||
}, error), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
}, error), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "old"
|
||||||
|
}, [/*#__PURE__*/React.createElement("label", {
|
||||||
className: "block text-xs text-slate-400 mb-1"
|
className: "block text-xs text-slate-400 mb-1"
|
||||||
}, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", {
|
}, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", {
|
||||||
type: "password",
|
type: "password",
|
||||||
@@ -2858,7 +2997,9 @@ const ProfileModal = ({
|
|||||||
value: oldPassword,
|
value: oldPassword,
|
||||||
onChange: e => setOldPassword(e.target.value),
|
onChange: e => setOldPassword(e.target.value),
|
||||||
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
||||||
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
})]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "new"
|
||||||
|
}, [/*#__PURE__*/React.createElement("label", {
|
||||||
className: "block text-xs text-slate-400 mb-1"
|
className: "block text-xs text-slate-400 mb-1"
|
||||||
}, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", {
|
}, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", {
|
||||||
type: "password",
|
type: "password",
|
||||||
@@ -2867,11 +3008,205 @@ const ProfileModal = ({
|
|||||||
value: newPassword,
|
value: newPassword,
|
||||||
onChange: e => setNewPassword(e.target.value),
|
onChange: e => setNewPassword(e.target.value),
|
||||||
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
||||||
})), /*#__PURE__*/React.createElement("button", {
|
})]), /*#__PURE__*/React.createElement("button", {
|
||||||
type: "submit",
|
type: "submit",
|
||||||
disabled: loading,
|
disabled: loading,
|
||||||
className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition"
|
className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition"
|
||||||
}, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu')))));
|
}, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu')])] : activeTab === 'projects' ? [loadingProjects ? /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "loading",
|
||||||
|
className: "text-center py-8 text-xs text-zinc-500"
|
||||||
|
}, "Đang tải danh sách dự án...") : projectsList.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "empty",
|
||||||
|
className: "text-center py-8 text-xs text-zinc-500"
|
||||||
|
}, "Bạn chưa có dự án nào lưu trên Cloud.") : /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "list",
|
||||||
|
className: "space-y-1.5"
|
||||||
|
}, [projectsList.map(proj => /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: proj.id,
|
||||||
|
onClick: () => handleOpenProject(proj.id),
|
||||||
|
className: "flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group"
|
||||||
|
}, [/*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "meta"
|
||||||
|
}, [/*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "font-bold text-slate-200 group-hover:text-teal-400"
|
||||||
|
}, proj.name), /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "text-[10px] text-zinc-500 mt-0.5"
|
||||||
|
}, `Dung lượng: ${proj.size_mb} MB | Cập nhật: ${new Date(proj.updated_at * 1000).toLocaleString()}`)]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "actions",
|
||||||
|
className: "flex items-center gap-1.5"
|
||||||
|
}, [/*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenProject(proj.id);
|
||||||
|
},
|
||||||
|
className: "px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]"
|
||||||
|
}, "MỞ"), /*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: e => handleDeleteProject(proj.id, e),
|
||||||
|
className: "px-2 py-1 bg-rose-950/60 hover:bg-rose-900/80 text-rose-300 rounded font-semibold text-[10px]"
|
||||||
|
}, "XÓA")])]))])] : activeTab === 'files' ? [/*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "cleanup-header",
|
||||||
|
className: "flex items-center justify-between bg-zinc-900/60 p-2.5 rounded border border-zinc-800 text-xs shrink-0 mb-3"
|
||||||
|
}, [/*#__PURE__*/React.createElement("span", {
|
||||||
|
className: "text-zinc-400 text-[10px]"
|
||||||
|
}, "💡 Các tập tin không dùng trong dự án nào sẽ được đánh dấu 'Không dùng' và có thể xóa để giải phóng dung lượng."), /*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: handleCleanUnusedFiles,
|
||||||
|
className: "px-2.5 py-1 bg-rose-700 hover:bg-rose-600 text-white rounded font-bold text-[10px] uppercase flex items-center gap-1"
|
||||||
|
}, "🧹 Dọn dẹp tệp rác")]), loadingFiles ? /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "loading",
|
||||||
|
className: "text-center py-8 text-xs text-zinc-500"
|
||||||
|
}, "Đang tải danh sách tập tin...") : filesList.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "empty",
|
||||||
|
className: "text-center py-8 text-xs text-zinc-500"
|
||||||
|
}, "Chưa có tập tin nào tải lên hoặc tạo ra.") : /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "list",
|
||||||
|
className: "space-y-1.5"
|
||||||
|
}, [filesList.map(file => /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: file.file_id,
|
||||||
|
className: "flex items-center justify-between p-3 bg-[#1e1e1e] rounded border border-zinc-800 text-xs"
|
||||||
|
}, [/*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "meta",
|
||||||
|
className: "max-w-[70%]"
|
||||||
|
}, [/*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "font-mono font-semibold text-slate-300 truncate"
|
||||||
|
}, file.file_id.replace(/^user_[^_]+_/, '')), /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "text-[10px] text-zinc-500 mt-0.5"
|
||||||
|
}, `Loại: ${file.type} | Dung lượng: ${file.size_mb} MB | Tạo lúc: ${new Date(file.created_at * 1000).toLocaleString()}`)]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "actions",
|
||||||
|
className: "flex items-center gap-2"
|
||||||
|
}, [file.is_in_use ? /*#__PURE__*/React.createElement("span", {
|
||||||
|
className: "px-2 py-0.5 bg-emerald-950 text-emerald-400 border border-emerald-900 rounded text-[9px] uppercase font-bold font-mono"
|
||||||
|
}, "Đang dùng") : /*#__PURE__*/React.createElement("span", {
|
||||||
|
className: "px-2 py-0.5 bg-zinc-850 text-zinc-400 border border-zinc-700 rounded text-[9px] uppercase font-bold font-mono"
|
||||||
|
}, "Không dùng"), !file.is_in_use && /*#__PURE__*/React.createElement("button", {
|
||||||
|
onClick: () => handleDeleteFile(file.file_id),
|
||||||
|
className: "px-2 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded font-semibold text-[10px] border border-rose-900"
|
||||||
|
}, "Xóa")])]))])] : null)));
|
||||||
|
};
|
||||||
|
const SaveProjectModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSave
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const handleSubmit = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
onSave(name.trim());
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
return /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"
|
||||||
|
}, /*#__PURE__*/React.createElement("h3", {
|
||||||
|
className: "text-sm font-bold text-teal-400 mb-4 uppercase"
|
||||||
|
}, "Đặt tên dự án"), /*#__PURE__*/React.createElement("form", {
|
||||||
|
onSubmit: handleSubmit,
|
||||||
|
className: "space-y-4"
|
||||||
|
}, [/*#__PURE__*/React.createElement("input", {
|
||||||
|
key: "name-input",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Nhập tên dự án...",
|
||||||
|
required: true,
|
||||||
|
value: name,
|
||||||
|
onChange: e => setName(e.target.value),
|
||||||
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold",
|
||||||
|
autoFocus: true
|
||||||
|
}), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "actions",
|
||||||
|
className: "flex justify-end gap-2 text-xs"
|
||||||
|
}, [/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "cancel",
|
||||||
|
type: "button",
|
||||||
|
onClick: onClose,
|
||||||
|
className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"
|
||||||
|
}, "Hủy"), /*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "save",
|
||||||
|
type: "submit",
|
||||||
|
className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold"
|
||||||
|
}, "Lưu")])])));
|
||||||
|
};
|
||||||
|
const SaveAsModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
projectName,
|
||||||
|
onSaveCloud,
|
||||||
|
onSaveLocal
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
const [name, setName] = useState(projectName || '');
|
||||||
|
const [saveType, setSaveType] = useState('cloud');
|
||||||
|
const handleSubmit = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
if (saveType === 'cloud') {
|
||||||
|
onSaveCloud(name.trim());
|
||||||
|
} else {
|
||||||
|
onSaveLocal(name.trim());
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
return /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"
|
||||||
|
}, /*#__PURE__*/React.createElement("h3", {
|
||||||
|
className: "text-sm font-bold text-teal-400 mb-4 uppercase"
|
||||||
|
}, "Lưu dưới tên khác (Save As...)"), /*#__PURE__*/React.createElement("form", {
|
||||||
|
onSubmit: handleSubmit,
|
||||||
|
className: "space-y-4"
|
||||||
|
}, [/*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "name-block"
|
||||||
|
}, [/*#__PURE__*/React.createElement("label", {
|
||||||
|
className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1"
|
||||||
|
}, "Tên dự án mới"), /*#__PURE__*/React.createElement("input", {
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Nhập tên mới...",
|
||||||
|
required: true,
|
||||||
|
value: name,
|
||||||
|
onChange: e => setName(e.target.value),
|
||||||
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold",
|
||||||
|
autoFocus: true
|
||||||
|
})]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "type-block"
|
||||||
|
}, [/*#__PURE__*/React.createElement("label", {
|
||||||
|
className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1.5"
|
||||||
|
}, "Phương thức lưu trữ"), /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "grid grid-cols-2 gap-2 text-xs"
|
||||||
|
}, [/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "btn-cloud",
|
||||||
|
type: "button",
|
||||||
|
onClick: () => setSaveType('cloud'),
|
||||||
|
className: `py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}`
|
||||||
|
}, [/*#__PURE__*/React.createElement("span", {
|
||||||
|
key: "title"
|
||||||
|
}, "☁️ Lưu Cloud"), /*#__PURE__*/React.createElement("span", {
|
||||||
|
key: "desc",
|
||||||
|
className: "text-[9px] font-normal text-zinc-500"
|
||||||
|
}, "Lưu lên server cá nhân")]), /*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "btn-local",
|
||||||
|
type: "button",
|
||||||
|
onClick: () => setSaveType('local'),
|
||||||
|
className: `py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}`
|
||||||
|
}, [/*#__PURE__*/React.createElement("span", {
|
||||||
|
key: "title"
|
||||||
|
}, "💾 Tải về máy (.sfs)"), /*#__PURE__*/React.createElement("span", {
|
||||||
|
key: "desc",
|
||||||
|
className: "text-[9px] font-normal text-zinc-500"
|
||||||
|
}, "Tải tệp JSON dự án về máy")])])]), /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "actions",
|
||||||
|
className: "flex justify-end gap-2 text-xs pt-2"
|
||||||
|
}, [/*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "cancel",
|
||||||
|
type: "button",
|
||||||
|
onClick: onClose,
|
||||||
|
className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"
|
||||||
|
}, "Hủy"), /*#__PURE__*/React.createElement("button", {
|
||||||
|
key: "save",
|
||||||
|
type: "submit",
|
||||||
|
className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold"
|
||||||
|
}, "Thực hiện lưu")])])));
|
||||||
};
|
};
|
||||||
const SystemManagerModal = ({
|
const SystemManagerModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -3086,6 +3421,9 @@ const App = () => {
|
|||||||
const [subTabHeight, setSubTabHeight] = useState(96);
|
const [subTabHeight, setSubTabHeight] = useState(96);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [projectName, setProjectName] = useState(() => localStorage.getItem('sonic_project_name') || '');
|
const [projectName, setProjectName] = useState(() => localStorage.getItem('sonic_project_name') || '');
|
||||||
|
const [currentProjectId, setCurrentProjectId] = useState(() => localStorage.getItem('sonic_project_id') || null);
|
||||||
|
const [saveProjectModalOpen, setSaveProjectModalOpen] = useState(false);
|
||||||
|
const [saveAsModalOpen, setSaveAsModalOpen] = useState(false);
|
||||||
const [soloedTrackId, setSoloedTrackId] = useState(null);
|
const [soloedTrackId, setSoloedTrackId] = useState(null);
|
||||||
const [toastMessage, setToastMessage] = useState(null);
|
const [toastMessage, setToastMessage] = useState(null);
|
||||||
const [showAIConfig, setShowAIConfig] = useState(false);
|
const [showAIConfig, setShowAIConfig] = useState(false);
|
||||||
@@ -6521,13 +6859,10 @@ const App = () => {
|
|||||||
setAuthModalOpen(true);
|
setAuthModalOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let name = projectName;
|
if (!projectName) {
|
||||||
if (!name) {
|
setSaveProjectModalOpen(true);
|
||||||
name = prompt("Nhập tên dự án:", "Dự án SonicForge");
|
return;
|
||||||
if (!name) return;
|
|
||||||
}
|
}
|
||||||
setProjectName(name);
|
|
||||||
localStorage.setItem('sonic_project_name', name);
|
|
||||||
const ss = arr => (arr || []).map(t => ({
|
const ss = arr => (arr || []).map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
@@ -6546,28 +6881,32 @@ const App = () => {
|
|||||||
label: t.channelInfo.label
|
label: t.channelInfo.label
|
||||||
} : null
|
} : null
|
||||||
}));
|
}));
|
||||||
|
const dataJson = JSON.stringify({
|
||||||
|
id: currentProjectId || 'project_' + Date.now(),
|
||||||
|
name: projectName,
|
||||||
|
tracks: ss(tracks)
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const dataJson = JSON.stringify({
|
if (currentProjectId) {
|
||||||
id: 'project_' + Date.now(),
|
await window.SonicAPI.updateCloudProject(currentProjectId, projectName, dataJson);
|
||||||
name,
|
showToast(`Đã cập nhật dự án "${projectName}" lên server!`, "success");
|
||||||
tracks: ss(tracks)
|
} else {
|
||||||
});
|
const res = await window.SonicAPI.saveCloudProject(projectName, dataJson);
|
||||||
await window.SonicAPI.saveCloudProject(name, dataJson);
|
const newProjId = res.project_id;
|
||||||
showToast(`Đã lưu "${name}" lên server!`, "success");
|
if (newProjId) {
|
||||||
|
setCurrentProjectId(newProjId);
|
||||||
|
localStorage.setItem('sonic_project_id', newProjId);
|
||||||
|
}
|
||||||
|
showToast(`Đã lưu dự án "${projectName}" mới lên server!`, "success");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "Lỗi lưu server", "error");
|
showToast(err.message || "Lỗi lưu server", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleSaveCloud = async () => {
|
const handleSaveProjectWithName = async newName => {
|
||||||
if (!currentUser) {
|
setProjectName(newName);
|
||||||
setIsMandatoryLogin(false);
|
localStorage.setItem('sonic_project_name', newName);
|
||||||
setAuthMode('login');
|
const ss = arr => (arr || []).map(t => ({
|
||||||
setAuthModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const name = prompt("Nhập tên dự án để lưu lên Cloud:", "Dự án SonicForge");
|
|
||||||
if (!name) return;
|
|
||||||
const serializeSafe = arr => (arr || []).map(t => ({
|
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
startTime: t.startTime,
|
startTime: t.startTime,
|
||||||
@@ -6585,19 +6924,64 @@ const App = () => {
|
|||||||
label: t.channelInfo.label
|
label: t.channelInfo.label
|
||||||
} : null
|
} : null
|
||||||
}));
|
}));
|
||||||
|
const dataJson = JSON.stringify({
|
||||||
|
id: 'project_' + Date.now(),
|
||||||
|
name: newName,
|
||||||
|
tracks: ss(tracks)
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const dataJson = JSON.stringify({
|
const res = await window.SonicAPI.saveCloudProject(newName, dataJson);
|
||||||
id: 'cloud_project',
|
const newProjId = res.project_id;
|
||||||
name,
|
if (newProjId) {
|
||||||
tracks: serializeSafe(tracks)
|
setCurrentProjectId(newProjId);
|
||||||
});
|
localStorage.setItem('sonic_project_id', newProjId);
|
||||||
await window.SonicAPI.saveCloudProject(name, dataJson);
|
}
|
||||||
showToast("Đã lưu dự án lên Cloud thành công!", "success");
|
showToast(`Đã lưu dự án "${newName}" mới lên server!`, "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "Lỗi lưu Cloud", "error");
|
showToast(err.message || "Lỗi lưu server", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleExportSFS = () => {
|
const handleSaveAsCloud = async newName => {
|
||||||
|
const ss = arr => (arr || []).map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
startTime: t.startTime,
|
||||||
|
height: t.height,
|
||||||
|
volumeDb: t.volumeDb,
|
||||||
|
pan: t.pan,
|
||||||
|
muted: t.muted,
|
||||||
|
solo: t.solo,
|
||||||
|
color: t.color,
|
||||||
|
markers: t.markers || [],
|
||||||
|
serverFileId: t.serverFileId || null,
|
||||||
|
channelInfo: t.channelInfo ? {
|
||||||
|
channels: t.channelInfo.channels,
|
||||||
|
isStereo: t.channelInfo.isStereo,
|
||||||
|
label: t.channelInfo.label
|
||||||
|
} : null
|
||||||
|
}));
|
||||||
|
const dataJson = JSON.stringify({
|
||||||
|
id: 'project_' + Date.now(),
|
||||||
|
name: newName,
|
||||||
|
tracks: ss(tracks)
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await window.SonicAPI.saveCloudProject(newName, dataJson);
|
||||||
|
const newProjId = res.project_id;
|
||||||
|
setProjectName(newName);
|
||||||
|
localStorage.setItem('sonic_project_name', newName);
|
||||||
|
if (newProjId) {
|
||||||
|
setCurrentProjectId(newProjId);
|
||||||
|
localStorage.setItem('sonic_project_id', newProjId);
|
||||||
|
}
|
||||||
|
showToast(`Đã lưu dự án dưới tên mới "${newName}" lên server!`, "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "Lỗi Save As lên server", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleSaveCloud = handleSaveProject;
|
||||||
|
const handleExportSFS = (customName = null) => {
|
||||||
|
const finalName = customName || projectName || 'Dự án SonicForge';
|
||||||
const serializeSafe = arr => (arr || []).map(t => ({
|
const serializeSafe = arr => (arr || []).map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
@@ -6617,8 +7001,8 @@ const App = () => {
|
|||||||
} : null
|
} : null
|
||||||
}));
|
}));
|
||||||
window.SonicStorage.exportProjectToSFS({
|
window.SonicStorage.exportProjectToSFS({
|
||||||
id: 'proj_' + Date.now(),
|
id: currentProjectId || 'proj_' + Date.now(),
|
||||||
name: 'Dự án SonicForge',
|
name: finalName,
|
||||||
tracks: serializeSafe(tracks)
|
tracks: serializeSafe(tracks)
|
||||||
});
|
});
|
||||||
showToast("Đã xuất dự án (.sfs) thành công!", "success");
|
showToast("Đã xuất dự án (.sfs) thành công!", "success");
|
||||||
@@ -6640,6 +7024,10 @@ const App = () => {
|
|||||||
}));
|
}));
|
||||||
if (restored.length > 0) {
|
if (restored.length > 0) {
|
||||||
setTracks(restored);
|
setTracks(restored);
|
||||||
|
setProjectName(proj.name || 'Dự án mới');
|
||||||
|
setCurrentProjectId(null);
|
||||||
|
localStorage.setItem('sonic_project_name', proj.name || 'Dự án mới');
|
||||||
|
localStorage.removeItem('sonic_project_id');
|
||||||
showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success");
|
showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -8617,6 +9005,10 @@ const App = () => {
|
|||||||
serverFileId: null
|
serverFileId: null
|
||||||
}]);
|
}]);
|
||||||
setSelectedTrackId('1');
|
setSelectedTrackId('1');
|
||||||
|
setProjectName('');
|
||||||
|
setCurrentProjectId(null);
|
||||||
|
localStorage.removeItem('sonic_project_name');
|
||||||
|
localStorage.removeItem('sonic_project_id');
|
||||||
showToast('New project created', 'info');
|
showToast('New project created', 'info');
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
@@ -8633,7 +9025,7 @@ const App = () => {
|
|||||||
label: 'Save As...',
|
label: 'Save As...',
|
||||||
icon: 'download',
|
icon: 'download',
|
||||||
shortcut: 'Ctrl+Alt+S',
|
shortcut: 'Ctrl+Alt+S',
|
||||||
action: () => handleExportSFS()
|
action: () => setSaveAsModalOpen(true)
|
||||||
}, {
|
}, {
|
||||||
sep: true
|
sep: true
|
||||||
}, {
|
}, {
|
||||||
@@ -11118,7 +11510,33 @@ const App = () => {
|
|||||||
onSuccess: handleAuthSuccess
|
onSuccess: handleAuthSuccess
|
||||||
}), /*#__PURE__*/React.createElement(ProfileModal, {
|
}), /*#__PURE__*/React.createElement(ProfileModal, {
|
||||||
isOpen: profileModalOpen,
|
isOpen: profileModalOpen,
|
||||||
onClose: () => setProfileModalOpen(false)
|
onClose: () => setProfileModalOpen(false),
|
||||||
|
tracks: tracks,
|
||||||
|
setTracks: setTracks,
|
||||||
|
setSelectedTrackId: setSelectedTrackId,
|
||||||
|
projectName: projectName,
|
||||||
|
setProjectName: setProjectName,
|
||||||
|
currentProjectId: currentProjectId,
|
||||||
|
setCurrentProjectId: setCurrentProjectId,
|
||||||
|
showToast: showToast
|
||||||
|
}), /*#__PURE__*/React.createElement(SaveProjectModal, {
|
||||||
|
isOpen: saveProjectModalOpen,
|
||||||
|
onClose: () => setSaveProjectModalOpen(false),
|
||||||
|
onSave: newName => {
|
||||||
|
handleSaveProjectWithName(newName);
|
||||||
|
}
|
||||||
|
}), /*#__PURE__*/React.createElement(SaveAsModal, {
|
||||||
|
isOpen: saveAsModalOpen,
|
||||||
|
onClose: () => setSaveAsModalOpen(false),
|
||||||
|
projectName: projectName,
|
||||||
|
onSaveCloud: newName => {
|
||||||
|
handleSaveAsCloud(newName);
|
||||||
|
},
|
||||||
|
onSaveLocal: newName => {
|
||||||
|
handleExportSFS(newName);
|
||||||
|
setProjectName(newName);
|
||||||
|
localStorage.setItem('sonic_project_name', newName);
|
||||||
|
}
|
||||||
}), /*#__PURE__*/React.createElement(AIConfigModal, {
|
}), /*#__PURE__*/React.createElement(AIConfigModal, {
|
||||||
isOpen: aiConfigModalOpen,
|
isOpen: aiConfigModalOpen,
|
||||||
onClose: () => setAiConfigModalOpen(false),
|
onClose: () => setAiConfigModalOpen(false),
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
|||||||
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
|
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
|
||||||
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
|
||||||
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }),
|
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }),
|
||||||
|
getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }),
|
||||||
|
deleteCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'DELETE' }),
|
||||||
|
updateCloudProject: (projectId, name, dataJson) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'PUT', body: JSON.stringify({ name, data_json: dataJson }) }),
|
||||||
|
listMyFiles: (activeFileIds) => apiRequest('/api/v1/audio/my-files', { method: 'POST', body: JSON.stringify({ active_file_ids: activeFileIds }) }),
|
||||||
|
deleteMyFile: (fileId) => apiRequest(`/api/v1/audio/my-files/${fileId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
aiScan: (trackId, fileId, minLoopDuration = 2.0, maxLoopDuration = 6.0) => apiRequest('/api/v1/audio/ai-scan', { method: 'POST', body: JSON.stringify({ track_id: trackId, file_id: fileId, min_loop_duration: minLoopDuration, max_loop_duration: maxLoopDuration }) }),
|
aiScan: (trackId, fileId, minLoopDuration = 2.0, maxLoopDuration = 6.0) => apiRequest('/api/v1/audio/ai-scan', { method: 'POST', body: JSON.stringify({ track_id: trackId, file_id: fileId, min_loop_duration: minLoopDuration, max_loop_duration: maxLoopDuration }) }),
|
||||||
aiCut: (sourceTrackId, fileId, selectionStart, selectionEnd) => apiRequest('/api/v1/audio/ai-cut', { method: 'POST', body: JSON.stringify({ source_track_id: sourceTrackId, file_id: fileId, selection_start: selectionStart, selection_end: selectionEnd }) }),
|
aiCut: (sourceTrackId, fileId, selectionStart, selectionEnd) => apiRequest('/api/v1/audio/ai-cut', { method: 'POST', body: JSON.stringify({ source_track_id: sourceTrackId, file_id: fileId, selection_start: selectionStart, selection_end: selectionEnd }) }),
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Implementation Plan: Project Management, Save As, and File Management inside Profile
|
||||||
|
|
||||||
|
We will add robust cloud/local project management, a custom "Save Project" name modal, a "Save As..." dialog offering server/local options, and a comprehensive file and project manager inside the User Profile Modal.
|
||||||
|
|
||||||
|
## User Review Required
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The profile modal will now contain three tabs: Account, Cloud Projects, and My Uploaded Files. Unused files (those not in the current session tracks or any saved projects) can be deleted by the user to free up quota storage.
|
||||||
|
>
|
||||||
|
> **Save As...** will trigger a modal allowing the user to type a new name and save it to either the server or export locally as a `.sfs` file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Changes
|
||||||
|
|
||||||
|
### Backend APIs
|
||||||
|
|
||||||
|
#### [MODIFY] [projects.py](file:///home/locpham/SonicForgeStudio/app/api/v1/projects.py)
|
||||||
|
- **`GET /cloud/{project_id}`**: Retrieves a specific user cloud project.
|
||||||
|
- **`DELETE /cloud/{project_id}`**: Deletes a specific user cloud project.
|
||||||
|
- **`PUT /cloud/{project_id}`**: Updates/overwrites an existing user cloud project.
|
||||||
|
|
||||||
|
#### [MODIFY] [audio.py](file:///home/locpham/SonicForgeStudio/app/api/v1/audio.py)
|
||||||
|
- **`POST /upload`**, **`run_python_dsp_tool`** (for synth), and **`ai_cut_audio`**: Prefix file IDs with `user_{user_id}_` to establish file ownership and quota tracking securely.
|
||||||
|
- **`POST /my-files`**: Lists all files starting with `user_{user_id}_` on the server disk. Identifies if they are referenced in the active project session or any database project records to compute their `is_in_use` status.
|
||||||
|
- **`DELETE /my-files/{file_id}`**: Deletes a user's uploaded/generated file from the server uploads and processed directories after verifying ownership.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Services & UI
|
||||||
|
|
||||||
|
#### [MODIFY] [api.js](file:///home/locpham/SonicForgeStudio/app/static/js/services/api.js)
|
||||||
|
- Expose APIs for fetching, deleting, and updating cloud projects.
|
||||||
|
- Expose APIs for listing and deleting user audio files.
|
||||||
|
|
||||||
|
#### [MODIFY] [app.jsx](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx)
|
||||||
|
- **State Additions**:
|
||||||
|
- `currentProjectId`: Tracks the ID of the loaded cloud project (synced with localStorage).
|
||||||
|
- `saveProjectModalOpen`, `saveAsModalOpen`: Controls the new custom modals.
|
||||||
|
- **Save Project Modal**:
|
||||||
|
- Modal with an input for project name, used when saving a project that doesn't have a name yet.
|
||||||
|
- **Save As Modal**:
|
||||||
|
- Allows choosing to save under a new name either locally (.sfs file) or on the server.
|
||||||
|
- **Profile Modal Extensions**:
|
||||||
|
- Add Tabs: **Account Settings**, **Cloud Projects**, **My Uploaded Files**.
|
||||||
|
- **Cloud Projects Tab**: Displays saved projects with load (open DAW project) and delete options.
|
||||||
|
- **My Uploaded Files Tab**: Displays files with sizes, creation dates, usage badges, individual delete buttons, and a global "Clean Up Unused Files" button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Automated Tests
|
||||||
|
- Run backend lint and sanity checks.
|
||||||
|
```bash
|
||||||
|
python -m flake8 app/api/v1/projects.py app/api/v1/audio.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
1. Create a new project, press Save, verify the custom input modal appears.
|
||||||
|
2. Upload some files, check the Profile -> My Uploaded Files tab. Verify the files are listed as "In Use".
|
||||||
|
3. Remove a track containing a file, verify the file changes to "Not In Use". Press delete to free up quota.
|
||||||
|
4. Click File -> Save As... and select either Cloud or Local .sfs and verify name updates and downloads.
|
||||||
Reference in New Issue
Block a user