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:
+162
-17
@@ -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: giống 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
@@ -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) => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user