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(() => {
|
||||
|
||||
Reference in New Issue
Block a user