feat: auto-save 5min + backup 30min + backup management UI

- project_backups table + DB migration
- Backup CRUD API endpoints with auto cleanup (5-20 retention)
- setInterval 5min save + 30min backup in browser
- ProfileModal: backup list, delete, retention slider, cleanup button
This commit is contained in:
2026-07-30 20:48:45 +07:00
parent 58a294d845
commit 9c487614f1
4 changed files with 180 additions and 19 deletions
+162 -17
View File
@@ -4512,6 +4512,13 @@ const ProfileModal = ({
// Confirmation modal state
const [confirmModal, setConfirmModal] = useState(null);
// Backup state
const [expandedBackupId, setExpandedBackupId] = useState(null);
const [backupsMap, setBackupsMap] = useState({}); // project_id -> [backups]
const [loadingBackups, setLoadingBackups] = useState({}); // project_id -> bool
const [backupMaxCount, setBackupMaxCount] = useState(() => parseInt(localStorage.getItem('sonic_backup_max_count') || '10'));
const [showBackupConfig, setShowBackupConfig] = useState(false);
const handleDragStart = (e) => {
const r = dragRef.current;
r.active = true;
@@ -4574,6 +4581,46 @@ const ProfileModal = ({
}
};
const fetchBackups = async (projectId) => {
setLoadingBackups(prev => ({ ...prev, [projectId]: true }));
try {
const data = await window.SonicAPI.listBackups(projectId);
setBackupsMap(prev => ({ ...prev, [projectId]: data || [] }));
} catch (e) {
showToast(e.message || 'Không thể tải danh sách backup', 'error');
} finally {
setLoadingBackups(prev => ({ ...prev, [projectId]: false }));
}
};
const handleDeleteBackup = async (backupId) => {
try {
await window.SonicAPI.deleteBackup(backupId);
setBackupsMap(prev => {
const next = { ...prev };
Object.keys(next).forEach(pid => {
next[pid] = next[pid].filter(b => b.id !== backupId);
});
return next;
});
fetchProjects();
showToast('Đã xóa bản backup', 'info');
} catch (e) {
showToast(e.message || 'Lỗi xóa backup', 'error');
}
};
const handleCleanupBackups = async () => {
try {
const res = await window.SonicAPI.cleanupBackups(backupMaxCount);
setBackupsMap({});
fetchProjects();
showToast(`Đã dọn dẹp ${res.deleted} bản backup cũ (giữ lại ${res.keep})`, 'info');
} catch (e) {
showToast(e.message || 'Lỗi dọn dẹp backup', 'error');
}
};
const handleOpenProject = async (projectId, projectName) => {
setAppWarningModal({
title: "Mở dự án",
@@ -4851,28 +4898,82 @@ const ProfileModal = ({
] : 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,
/*#__PURE__*/React.createElement(React.Fragment, { key: "list" }, [
/*#__PURE__*/React.createElement("div", { key: "backup-config-bar", className: "flex items-center justify-between bg-zinc-900/60 p-2 rounded border border-zinc-800 mb-2 text-xs" }, [
/*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, [
/*#__PURE__*/React.createElement("span", { className: "text-zinc-400 text-[10px]" }, "⚙️ Tự động lưu 5 phút / Backup 30 phút"),
/*#__PURE__*/React.createElement("button", {
onClick: (e) => { e.stopPropagation(); setShowBackupConfig(!showBackupConfig); },
className: "px-2 py-0.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] border border-zinc-700"
}, showBackupConfig ? "ẨN" : "CẤU HÌNH")
]),
/*#__PURE__*/React.createElement("button", {
onClick: (e) => { e.stopPropagation(); handleCleanupBackups(); },
className: "px-2 py-0.5 bg-amber-800 hover:bg-amber-700 text-amber-200 rounded text-[10px] border border-amber-700"
}, "🧹 DỌN BACKUP")
]),
showBackupConfig && /*#__PURE__*/React.createElement("div", { key: "backup-config-detail", className: "bg-[#18181b] border border-zinc-800 rounded p-3 mb-2 text-xs" }, [
/*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between mb-2" }, [
/*#__PURE__*/React.createElement("label", { className: "text-zinc-300 font-semibold" }, "Số bản backup tối đa:"),
/*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, [
/*#__PURE__*/React.createElement("input", {
type: "range",
min: 5, max: 20,
value: backupMaxCount,
onChange: (e) => { const v = parseInt(e.target.value); setBackupMaxCount(v); localStorage.setItem('sonic_backup_max_count', v.toString()); },
className: "w-24 accent-amber-500"
}),
/*#__PURE__*/React.createElement("span", { className: "text-amber-400 font-bold w-6 text-center" }, backupMaxCount)
])
]),
/*#__PURE__*/React.createElement("p", { className: "text-[10px] text-zinc-500" }, "Mỗi dự án sẽ giữ tối đa số bản backup này. Backup cũ nhất sẽ tự động bị xóa khi vượt quá giới hạn.")
]),
/*#__PURE__*/React.createElement("div", { className: "space-y-1.5" }, projectsList.map(proj => /*#__PURE__*/React.createElement("div", { key: proj.id }, [
/*#__PURE__*/React.createElement("div", {
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: "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()}`,
proj.backup_count > 0 && ` | Backup: ${proj.backup_count}`
])
]),
/*#__PURE__*/React.createElement("div", { key: "actions", className: "flex items-center gap-1.5" }, [
/*#__PURE__*/React.createElement("button", {
onClick: (e) => { e.stopPropagation(); if (expandedBackupId === proj.id) { setExpandedBackupId(null); } else { setExpandedBackupId(proj.id); fetchBackups(proj.id); } },
className: `px-2 py-1 rounded font-semibold text-[10px] border ${expandedBackupId === proj.id ? 'bg-amber-800/80 text-amber-200 border-amber-700' : 'bg-zinc-800 hover:bg-zinc-700 text-zinc-400 border-zinc-700'}`
}, `Backup (${proj.backup_count})`),
/*#__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")
])
]),
/*#__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")
expandedBackupId === proj.id && /*#__PURE__*/React.createElement("div", { key: "backup-list", className: "ml-4 pl-3 border-l-2 border-amber-800/50 bg-[#161618] rounded-b p-2 mb-1" }, [
loadingBackups[proj.id] ? /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 py-2 text-center" }, "Đang tải...") :
(!backupsMap[proj.id] || backupsMap[proj.id].length === 0) ? /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 py-2 text-center" }, "Chưa có bản backup nào.") :
/*#__PURE__*/React.createElement("div", { className: "space-y-1 max-h-48 overflow-y-auto" }, backupsMap[proj.id].map(b => /*#__PURE__*/React.createElement("div", {
key: b.id,
className: "flex items-center justify-between py-1.5 px-2 bg-[#1e1e22] rounded border border-zinc-800"
}, [
/*#__PURE__*/React.createElement("div", { key: "info", className: "flex-1 min-w-0" }, [
/*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-300 truncate" }, b.name),
/*#__PURE__*/React.createElement("div", { className: "text-[9px] text-zinc-500 mt-0.5" }, `${b.size_mb} MB | ${new Date(b.created_at * 1000).toLocaleString()}`)
]),
/*#__PURE__*/React.createElement("button", {
onClick: (e) => { e.stopPropagation(); handleDeleteBackup(b.id); },
className: "px-1.5 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded text-[9px] border border-rose-900 ml-2 shrink-0"
}, "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."),
@@ -10065,6 +10166,50 @@ const App = () => {
});
}, [tracks, subTabs, sessionTabs, masteringSettings]);
// Timer-based auto-save (5 min) + backup (30 min)
useEffect(() => {
const BACKUP_MAX_KEY = 'sonic_backup_max_count';
if (!localStorage.getItem(BACKUP_MAX_KEY)) {
localStorage.setItem(BACKUP_MAX_KEY, '10');
}
const interval5 = setInterval(() => {
if (!currentProjectId) return;
// Auto-save: ging handleSaveProject
if (currentProjectId.startsWith('local_')) {
const finalName = projectName || 'Dự án mới';
const localId = currentProjectId;
const schemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const dataStr = JSON.stringify(schemaObj);
localStorage.setItem('sonic_local_project_data', dataStr);
localStorage.setItem('sonic_project_id', localId);
localStorage.setItem('sonic_project_name', finalName);
} else if (currentUser && window.SonicAPI) {
try {
const schemaObj = serializeProjectToSchema(currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const dataJson = JSON.stringify(schemaObj);
window.SonicAPI.updateCloudProject(currentProjectId, projectName || 'Dự án mới', dataJson).catch(() => {});
} catch (e) {
console.warn('Auto-save error:', e);
}
}
}, 5 * 60 * 1000);
const interval30 = setInterval(() => {
if (!currentProjectId || currentProjectId.startsWith('local_') || !currentUser || !window.SonicAPI) return;
try {
window.SonicAPI.createBackup(currentProjectId).catch(() => {});
} catch (e) {
console.warn('Backup error:', e);
}
}, 30 * 60 * 1000);
return () => {
clearInterval(interval5);
clearInterval(interval30);
};
}, [currentProjectId, projectName, currentUser, tracks, subTabs, sessionTabs, masteringSettings, bpm]);
// Lucide icons initialization
useEffect(() => {
setTimeout(() => {
File diff suppressed because one or more lines are too long
+5
View File
@@ -71,6 +71,11 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
createBackup: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}/backup`, { method: 'POST' }),
listBackups: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}/backups`, { method: 'GET' }),
deleteBackup: (backupId) => apiRequest(`/api/v1/projects/cloud/backups/${backupId}`, { method: 'DELETE' }),
cleanupBackups: (keep) => apiRequest('/api/v1/projects/cloud/backups/cleanup', { method: 'POST', body: JSON.stringify({ keep }) }),
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
uploadSoundFont: async (file) => {
+6
View File
@@ -849,6 +849,12 @@
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
### [2026-07-30 20:46] Task: Auto-save 5min + Backup 30min + Backup management UI
- **Tóm tắt thay đổi:** (1) DB: thêm bảng `project_backups` + migration cột `is_backup`/`original_id`; (2) Backend: `POST/POST /cloud/{id}/backup`, `GET /cloud/{id}/backups`, `DELETE /cloud/backups/{id}`, `POST /cloud/backups/cleanup` — tự động giới hạn retention (mặc định 10, cấu hình 5-20); (3) Frontend API: 4 methods backup; (4) App: `setInterval` 5 phút auto-save + 30 phút backup; (5) ProfileModal: nút Backup (hiện số lượng) mở rộng danh sách, xóa từng bản, thanh cấu hình retention slider 5-20, nút dọn dẹp.
- **Các file ảnh hưởng:** `app/models/user.py`, `app/api/v1/projects.py`, `app/static/js/services/api.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: mở dự án Cloud → đợi 30p → backup tự động tạo; slider 5-20 lưu vào localStorage; nút 🧹 DỌN BACKUP giữ lại đúng số lượng.
---
### [2026-07-30 20:36] Task: Fix project load crash — loadAudioBuffersForTracks scoped inside ProfileModal
- **Tóm tắt thay đổi:** `loadAudioBuffersForTracks` defined inside `ProfileModal`, but `restoreLastSessionProject` in App component called it → ReferenceError on startup + silent catch → project not restored. Fix: move function to App component, pass as prop to ProfileModal.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`