diff --git a/app/api/v1/audio.py b/app/api/v1/audio.py index c699535..b1474c8 100644 --- a/app/api/v1/audio.py +++ b/app/api/v1/audio.py @@ -236,16 +236,17 @@ async def ai_scan_audio(req: AIScanRequest): } @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). 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 import soundfile as sf 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) file_path = None @@ -278,11 +279,12 @@ async def ai_cut_audio(req: AICutRequest): } @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. 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.ai_dsp_engine import AIDSPEngine import soundfile as sf @@ -290,7 +292,7 @@ async def run_python_dsp_tool(req: PythonToolRequest): 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) - 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) sf.write(out_path, wave, 44100) return { @@ -311,3 +313,85 @@ async def run_python_dsp_tool(req: PythonToolRequest): "success": True, "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"} diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index c8bbeb3..1f75093 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -2725,18 +2725,41 @@ const AIConfigModal = ({ }; const ProfileModal = ({ isOpen, - onClose + onClose, + tracks, + setTracks, + setSelectedTrackId, + projectName, + setProjectName, + currentProjectId, + setCurrentProjectId, + showToast }) => { if (!isOpen) return null; + const [activeTab, setActiveTab] = useState('account'); const [profile, setProfile] = useState(null); const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [msg, setMsg] = useState(''); const [error, setError] = useState(''); 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(() => { - if (isOpen) fetchProfile(); - }, [isOpen]); + if (isOpen) { + fetchProfile(); + if (activeTab === 'projects') fetchProjects(); + if (activeTab === 'files') fetchFiles(); + } + }, [isOpen, activeTab]); + const fetchProfile = async () => { try { const data = await window.SonicAPI.getProfile(); @@ -2745,6 +2768,103 @@ const ProfileModal = ({ 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 => { e.preventDefault(); setMsg(''); @@ -2761,80 +2881,300 @@ const ProfileModal = ({ setLoading(false); } }; + 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-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", { - 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", { - className: "text-lg font-bold text-teal-400" - }, "👤 Hồ Sơ Cá Nhân & Hạn Mức Quota"), /*#__PURE__*/React.createElement("button", { + className: "text-md font-bold text-teal-400 flex items-center gap-1.5" + }, "👤 Hồ Sơ Cá Nhân & Quản Lý Dự Án"), /*#__PURE__*/React.createElement("button", { onClick: onClose, className: "text-slate-400 hover:text-slate-200" - }, "✕")), profile && /*#__PURE__*/React.createElement("div", { - className: "mt-4 space-y-4" + }, "✕")), /*#__PURE__*/React.createElement("div", { + 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", { - 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", { - 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", null, /*#__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", null, /*#__PURE__*/React.createElement("span", { - className: "text-slate-500 block" - }, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)), /*#__PURE__*/React.createElement("div", null, /*#__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", null, /*#__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)}%` + 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()); } - }))), /*#__PURE__*/React.createElement("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", null, /*#__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", null, /*#__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'))))); + 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 = ({ isOpen, @@ -3043,6 +3383,9 @@ const App = () => { const [subTabHeight, setSubTabHeight] = useState(96); const [isExporting, setIsExporting] = useState(false); 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 [toastMessage, setToastMessage] = useState(null); const [showAIConfig, setShowAIConfig] = useState(false); @@ -6414,57 +6757,69 @@ const App = () => { }; const handleSaveProject = async () => { if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; } - let name = projectName; - if (!name) { name = prompt("Nhập tên dự án:", "Dự án SonicForge"); if (!name) return; } - 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); + if (!projectName) { + setSaveProjectModalOpen(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, - 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 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: currentProjectId || 'project_' + Date.now(), name: projectName, tracks: ss(tracks) }); try { - const dataJson = JSON.stringify({ - id: 'cloud_project', - name, - tracks: serializeSafe(tracks) - }); - await window.SonicAPI.saveCloudProject(name, dataJson); - showToast("Đã lưu dự án lên Cloud thành công!", "success"); + if (currentProjectId) { + await window.SonicAPI.updateCloudProject(currentProjectId, projectName, dataJson); + showToast(`Đã cập nhật dự án "${projectName}" lên server!`, "success"); + } else { + const res = await window.SonicAPI.saveCloudProject(projectName, dataJson); + const newProjId = res.project_id; + if (newProjId) { + setCurrentProjectId(newProjId); + localStorage.setItem('sonic_project_id', newProjId); + } + showToast(`Đã lưu dự án "${projectName}" mới lên server!`, "success"); + } } 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 => ({ id: t.id, name: t.name, @@ -6484,8 +6839,8 @@ const App = () => { } : null })); window.SonicStorage.exportProjectToSFS({ - id: 'proj_' + Date.now(), - name: 'Dự án SonicForge', + id: currentProjectId || 'proj_' + Date.now(), + name: finalName, tracks: serializeSafe(tracks) }); showToast("Đã xuất dự án (.sfs) thành công!", "success"); @@ -6507,6 +6862,10 @@ const App = () => { })); if (restored.length > 0) { 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"); } } catch (err) { @@ -8170,6 +8529,10 @@ const App = () => { serverFileId: null }]); setSelectedTrackId('1'); + setProjectName(''); + setCurrentProjectId(null); + localStorage.removeItem('sonic_project_name'); + localStorage.removeItem('sonic_project_id'); showToast('New project created', 'info'); } }, { @@ -8186,7 +8549,7 @@ const App = () => { label: 'Save As...', icon: 'download', shortcut: 'Ctrl+Alt+S', - action: () => handleExportSFS() + action: () => setSaveAsModalOpen(true) }, { sep: true }, { @@ -10603,7 +10966,33 @@ const App = () => { onSuccess: handleAuthSuccess }), /*#__PURE__*/React.createElement(ProfileModal, { 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, { isOpen: aiConfigModalOpen, onClose: () => setAiConfigModalOpen(false), diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index aaf810c..533dde9 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -2762,18 +2762,39 @@ const AIConfigModal = ({ }; const ProfileModal = ({ isOpen, - onClose + onClose, + tracks, + setTracks, + setSelectedTrackId, + projectName, + setProjectName, + currentProjectId, + setCurrentProjectId, + showToast }) => { if (!isOpen) return null; + const [activeTab, setActiveTab] = useState('account'); const [profile, setProfile] = useState(null); const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [msg, setMsg] = useState(''); const [error, setError] = useState(''); 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(() => { - if (isOpen) fetchProfile(); - }, [isOpen]); + if (isOpen) { + fetchProfile(); + if (activeTab === 'projects') fetchProjects(); + if (activeTab === 'files') fetchFiles(); + } + }, [isOpen, activeTab]); const fetchProfile = async () => { try { const data = await window.SonicAPI.getProfile(); @@ -2782,6 +2803,96 @@ const ProfileModal = ({ 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 => { e.preventDefault(); setMsg(''); @@ -2801,55 +2912,83 @@ const ProfileModal = ({ 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-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", { - 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", { - className: "text-lg font-bold text-teal-400" - }, "👤 Hồ Sơ Cá Nhân & Hạn Mức Quota"), /*#__PURE__*/React.createElement("button", { + className: "text-md font-bold text-teal-400 flex items-center gap-1.5" + }, "👤 Hồ Sơ Cá Nhân & Quản Lý Dự Án"), /*#__PURE__*/React.createElement("button", { onClick: onClose, className: "text-slate-400 hover:text-slate-200" - }, "✕")), profile && /*#__PURE__*/React.createElement("div", { - className: "mt-4 space-y-4" - }, /*#__PURE__*/React.createElement("div", { + }, "✕")), /*#__PURE__*/React.createElement("div", { + 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", null, /*#__PURE__*/React.createElement("span", { + }, [/*#__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", null, /*#__PURE__*/React.createElement("span", { + }, 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", null, /*#__PURE__*/React.createElement("span", { + }, 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", 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" }, "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", 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" - }, /*#__PURE__*/React.createElement("span", { + }, [/*#__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", { + }, `${(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", { + }, [/*#__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", { + })])]), /*#__PURE__*/React.createElement("form", { + key: "pwd-form", onSubmit: handleChangePassword, 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" }, "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", 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" }, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", { type: "password", @@ -2858,7 +2997,9 @@ const ProfileModal = ({ 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", null, /*#__PURE__*/React.createElement("label", { + })]), /*#__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", @@ -2867,11 +3008,205 @@ const ProfileModal = ({ 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", { + })]), /*#__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'))))); + }, 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 = ({ isOpen, @@ -3086,6 +3421,9 @@ const App = () => { const [subTabHeight, setSubTabHeight] = useState(96); const [isExporting, setIsExporting] = useState(false); 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 [toastMessage, setToastMessage] = useState(null); const [showAIConfig, setShowAIConfig] = useState(false); @@ -6521,13 +6859,10 @@ const App = () => { setAuthModalOpen(true); return; } - let name = projectName; - if (!name) { - name = prompt("Nhập tên dự án:", "Dự án SonicForge"); - if (!name) return; + if (!projectName) { + setSaveProjectModalOpen(true); + return; } - setProjectName(name); - localStorage.setItem('sonic_project_name', name); const ss = arr => (arr || []).map(t => ({ id: t.id, name: t.name, @@ -6546,28 +6881,32 @@ const App = () => { label: t.channelInfo.label } : null })); + const dataJson = JSON.stringify({ + id: currentProjectId || 'project_' + Date.now(), + name: projectName, + tracks: ss(tracks) + }); 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"); + if (currentProjectId) { + await window.SonicAPI.updateCloudProject(currentProjectId, projectName, dataJson); + showToast(`Đã cập nhật dự án "${projectName}" lên server!`, "success"); + } else { + const res = await window.SonicAPI.saveCloudProject(projectName, dataJson); + const newProjId = res.project_id; + if (newProjId) { + setCurrentProjectId(newProjId); + localStorage.setItem('sonic_project_id', newProjId); + } + showToast(`Đã lưu dự án "${projectName}" mới 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; - } - 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 => ({ + 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, @@ -6585,19 +6924,64 @@ const App = () => { label: t.channelInfo.label } : null })); + const dataJson = JSON.stringify({ + id: 'project_' + Date.now(), + name: newName, + tracks: ss(tracks) + }); try { - const dataJson = JSON.stringify({ - id: 'cloud_project', - name, - tracks: serializeSafe(tracks) - }); - await window.SonicAPI.saveCloudProject(name, dataJson); - showToast("Đã lưu dự án lên Cloud thành công!", "success"); + 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 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 => ({ id: t.id, name: t.name, @@ -6617,8 +7001,8 @@ const App = () => { } : null })); window.SonicStorage.exportProjectToSFS({ - id: 'proj_' + Date.now(), - name: 'Dự án SonicForge', + id: currentProjectId || 'proj_' + Date.now(), + name: finalName, tracks: serializeSafe(tracks) }); showToast("Đã xuất dự án (.sfs) thành công!", "success"); @@ -6640,6 +7024,10 @@ const App = () => { })); if (restored.length > 0) { 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"); } } catch (err) { @@ -8617,6 +9005,10 @@ const App = () => { serverFileId: null }]); setSelectedTrackId('1'); + setProjectName(''); + setCurrentProjectId(null); + localStorage.removeItem('sonic_project_name'); + localStorage.removeItem('sonic_project_id'); showToast('New project created', 'info'); } }, { @@ -8633,7 +9025,7 @@ const App = () => { label: 'Save As...', icon: 'download', shortcut: 'Ctrl+Alt+S', - action: () => handleExportSFS() + action: () => setSaveAsModalOpen(true) }, { sep: true }, { @@ -11118,7 +11510,33 @@ const App = () => { onSuccess: handleAuthSuccess }), /*#__PURE__*/React.createElement(ProfileModal, { 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, { isOpen: aiConfigModalOpen, onClose: () => setAiConfigModalOpen(false), diff --git a/app/static/js/services/api.js b/app/static/js/services/api.js index 72122b0..6fd749c 100644 --- a/app/static/js/services/api.js +++ b/app/static/js/services/api.js @@ -46,6 +46,11 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin; 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 }) }), 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 }) }), 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 }) }), diff --git a/implementatio_plan_nw.md b/implementatio_plan_nw.md new file mode 100644 index 0000000..4623a49 --- /dev/null +++ b/implementatio_plan_nw.md @@ -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.