fix: refactor

This commit is contained in:
2026-07-20 10:39:07 +07:00
parent 3c77e98956
commit c8ebdb50b0
21 changed files with 2862 additions and 110 deletions
+528 -109
View File
@@ -9,6 +9,9 @@
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js"></script>
<script src="/static/js/services/api.js"></script>
<script src="/static/js/services/audioEngine.js"></script>
<script src="/static/js/services/storage.js"></script>
<style>
body {
background-color: #1a1a1a;
@@ -52,6 +55,20 @@
const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
(function handleSfsDeepLink() {
try {
const params = new URLSearchParams(window.location.search);
const sfsParam = params.get('sfs');
if (!sfsParam) return;
const decoded = JSON.parse(decodeURIComponent(sfsParam));
window.__pendingSfsProject = decoded; // consumed after auth in App
if (window.history.replaceState) {
window.history.replaceState({}, document.title, window.location.pathname);
}
} catch (e) { window.__pendingSfsProject = null; }
})();
// Storage for server-side file IDs mapped to track IDs
let serverFileIdMap = {};
@@ -690,11 +707,16 @@
};
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange, volumeNodes = [], panningNodes = [], fadeInLen = 0, fadeOutLen = 0, graphMode = null, onUpdateNodes, onUpdateFade, onModeToggle, selectedNodeTime, setSelectedNodeTime }) => {
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange, volumeNodes = [], panningNodes = [], fadeInLen = 0, fadeOutLen = 0, graphMode = null, onUpdateNodes, onUpdateFade, onModeToggle, selectedNodeTime, setSelectedNodeTime, channelInfo = null }) => {
const canvasRef = useRef(null);
const isStretchingRef = useRef(false);
const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 });
const isStereo = channelInfo ? channelInfo.isStereo : (buffer && buffer.numberOfChannels >= 2);
const channelLabel = channelInfo ? channelInfo.label : (isStereo ? 'STEREO' : 'MONO');
// Mono: force volume mode (panning not applicable)
const effectiveGraphMode = (!isStereo && graphMode === 'pan') ? null : graphMode;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
@@ -860,7 +882,7 @@
ctx.beginPath(); ctx.moveTo(xStart, panZeroY); ctx.lineTo(xStart + wClip, panZeroY); ctx.stroke();
// Horizontal grid lines (other value markers)
const isPanMode = graphMode === 'pan';
const isPanMode = effectiveGraphMode === 'pan';
if (isPanMode) {
for (let p = -100; p <= 100; p += 20) {
if (p === 0) continue;
@@ -919,7 +941,7 @@
const modeBtnH = 14;
const modeBtnX = wClip - modeBtnW - 4;
const modeBtnY = clipTop + clipHeight - modeBtnH - 2;
const isPanMode = graphMode === 'pan';
const isPanMode = effectiveGraphMode === 'pan';
ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)';
ctx.beginPath();
ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3);
@@ -930,6 +952,16 @@
ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10);
ctx.textAlign = 'start';
// Channel label (L / R for stereo, M for mono)
ctx.fillStyle = '#a1a1aa';
ctx.font = 'bold 8px monospace';
if (isStereo) {
ctx.fillText('L', xStart + 2, clipTop + clipHeight * 0.28);
ctx.fillText('R', xStart + 2, clipTop + clipHeight * 0.72);
} else {
ctx.fillText('M', xStart + 2, clipTop + clipHeight / 2);
}
// Draw waveform inside clip (speed-adjusted) with fade envelope applied
const drawXStart = Math.max(0, Math.floor(xStart));
const drawXEnd = Math.min(w, Math.ceil(xEnd));
@@ -1691,7 +1723,7 @@
</select>
</div>
</div>
);
);
};
// ── Graph Editor Canvas for Volume/Pan/Fade Automation ──
@@ -1856,32 +1888,290 @@
);
};
const createMockAudioBufferObj = (duration, sampleRate) => {
const frameCount = sampleRate * duration;
const data = new Float32Array(frameCount);
for (let i = 0; i < frameCount; i++) {
const t = i / sampleRate;
const env = Math.exp(-Math.pow(t - 1.5, 2) / 0.15) * 0.4 + Math.exp(-Math.pow(t - 1.5, 2) / 0.05) * 0.3;
const signal = Math.sin(2 * Math.PI * 120 * t) * Math.sin(2 * Math.PI * 8 * t) + (Math.random() - 0.5) * 0.15;
data[i] = signal * env;
}
return {
duration,
sampleRate,
numberOfChannels: 1,
getChannelData: (c) => data
const AuthModal = ({ isOpen, mode, forceMandatory, onClose, onSuccess }) => {
if (!isOpen) return null;
const [activeTab, setActiveTab] = useState(mode || 'login');
const [username, setUsername] = useState('admin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => { if (mode) setActiveTab(mode); }, [mode]);
const handleSubmit = async (e) => {
e.preventDefault(); setError(''); setLoading(true);
try {
if (activeTab === 'login') {
const targetUsername = username.trim() || 'admin';
const res = await window.SonicAPI.login(targetUsername, password.trim());
localStorage.setItem('sonic_token', res.access_token);
localStorage.setItem('sonic_user', JSON.stringify(res.user));
onSuccess(res.user, res.access_token);
} else if (activeTab === 'register') {
const res = await window.SonicAPI.register(username.trim(), email.trim(), password.trim());
localStorage.setItem('sonic_token', res.access_token);
localStorage.setItem('sonic_user', JSON.stringify(res.user));
onSuccess(res.user, res.access_token);
} else if (activeTab === 'force_change') {
const res = await window.SonicAPI.changePassword(oldPassword.trim(), newPassword.trim());
localStorage.setItem('sonic_token', res.access_token);
const user = JSON.parse(localStorage.getItem('sonic_user') || '{}');
user.must_change_password = false;
localStorage.setItem('sonic_user', JSON.stringify(user));
onSuccess(user, res.access_token);
}
} catch (err) {
setError(err.message || 'Thao tác không thành công');
} finally { setLoading(false); }
};
const isForceMode = activeTab === 'force_change';
const canClose = !forceMandatory && !isForceMode;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200">
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
<h3 className="text-lg font-bold text-teal-400">
{isForceMode ? '⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo' : (activeTab === 'login' ? '🔐 Đăng Nhập Hệ Thống' : '📝 Đăng Ký Tài Khoản')}
</h3>
{canClose && <button onClick={onClose} className="text-slate-400 hover:text-slate-200"></button>}
</div>
{error && (<div className="mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm">{error}</div>)}
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
{isForceMode ? (
<>
<p className="text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed">
🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục.
</p>
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu hiện tại (Mặc định: admin123)</label>
<input type="password" required value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
</div>
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu mới</label>
<input type="password" required value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
</div>
</>
) : (
<>
{activeTab === 'login' ? (
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Tên đăng nhập <span className="text-teal-400 font-normal">(Tùy chọn - Admin có thể bỏ trống)</span></label>
<input type="text" placeholder="Mặc định: admin" value={username} onChange={(e) => setUsername(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
</div>
) : (
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Tên đăng nhập</label>
<input type="text" required value={username} onChange={(e) => setUsername(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
</div>
)}
{activeTab === 'register' && (
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Email</label>
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
</div>
)}
<div>
<label className="block text-xs font-semibold mb-1 text-slate-400">Mật khẩu {activeTab === 'login' && <span className="text-amber-400 font-normal">(Lần đầu: admin123)</span>}</label>
<input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" />
</div>
</>
)}
<button type="submit" disabled={loading} className="w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150">
{loading ? 'Đang xác thực...' : (isForceMode ? 'Đổi Mật Khẩu Ngay' : (activeTab === 'login' ? 'Đăng Nhập System' : 'Tạo Tài Khoản Mới'))}
</button>
</form>
{!isForceMode && (
<div className="mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400">
{activeTab === 'login' ? (
<span>Chưa có tài khoản? <button onClick={() => setActiveTab('register')} className="text-teal-400 hover:underline">Đăng ký ngay</button></span>
) : (
<span>Đã có tài khoản? <button onClick={() => setActiveTab('login')} className="text-teal-400 hover:underline">Đăng nhập</button></span>
)}
</div>
)}
</div>
</div>
);
};
const ProfileModal = ({ isOpen, onClose }) => {
if (!isOpen) return null;
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);
useEffect(() => { if (isOpen) fetchProfile(); }, [isOpen]);
const fetchProfile = async () => {
try { const data = await window.SonicAPI.getProfile(); setProfile(data); }
catch (e) { setError(e.message || 'Không thể tải thông tin profile'); }
};
const handleChangePassword = async (e) => {
e.preventDefault(); setMsg(''); setError(''); setLoading(true);
try {
const res = await window.SonicAPI.changePassword(oldPassword, newPassword);
setMsg(res.message || 'Đổi mật khẩu thành công!');
setOldPassword(''); setNewPassword('');
} catch (err) { setError(err.message || 'Lỗi khi đổi mật khẩu'); }
finally { setLoading(false); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200">
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
<h3 className="text-lg font-bold text-teal-400">👤 Hồ Sơ Cá Nhân & Hạn Mức Quota</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-200"></button>
</div>
{profile && (
<div className="mt-4 space-y-4">
<div className="bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs">
<div><span className="text-slate-500 block">Tên người dùng</span><span className="font-bold text-teal-300 text-sm">{profile.username}</span></div>
<div><span className="text-slate-500 block">Vai trò</span><span className="uppercase font-semibold text-amber-400">{profile.role}</span></div>
<div><span className="text-slate-500 block">Email</span><span>{profile.email}</span></div>
<div><span className="text-slate-500 block">Dung lượng Quota</span><span className="font-semibold text-slate-200">{profile.quota.used_mb} MB / {profile.quota.storage_limit_mb} MB</span></div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-400">Tiến trình sử dụng bộ nhớ Server</span>
<span className="font-bold text-teal-400">{((profile.quota.used_mb / profile.quota.storage_limit_mb) * 100).toFixed(1)}%</span>
</div>
<div className="w-full h-2 bg-slate-800 rounded-full overflow-hidden">
<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)}%` }}></div>
</div>
</div>
<form onSubmit={handleChangePassword} className="pt-4 border-t border-[#383838] space-y-3">
<h4 className="text-xs font-bold text-slate-300 uppercase">Thay Đổi Mật Khẩu</h4>
{msg && <div className="p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs">{msg}</div>}
{error && <div className="p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs">{error}</div>}
<div>
<label className="block text-xs text-slate-400 mb-1">Mật khẩu cũ</label>
<input type="password" required 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" />
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">Mật khẩu mới</label>
<input type="password" required 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" />
</div>
<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'}
</button>
</form>
</div>
)}
</div>
</div>
);
};
const SystemManagerModal = ({ isOpen, onClose }) => {
if (!isOpen) return null;
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [editingQuotaUser, setEditingQuotaUser] = useState(null);
const [newQuotaMb, setNewQuotaMb] = useState(500);
useEffect(() => { if (isOpen) loadUsers(); }, [isOpen]);
const loadUsers = async () => {
setLoading(true); setError('');
try { const data = await window.SonicAPI.listUsers(); setUsers(data); }
catch (err) { setError(err.message || 'Không thể tải danh sách người dùng hệ thống'); }
finally { setLoading(false); }
};
const handleSaveQuota = async (userId) => {
try {
await window.SonicAPI.updateUserQuota(userId, parseInt(newQuotaMb));
setMsg('Đã cập nhật hạn mức Quota thành công!');
setEditingQuotaUser(null); loadUsers();
} catch (err) { setError(err.message || 'Lỗi cập nhật Quota'); }
};
const handleToggleRole = async (user) => {
const nextRole = user.role === 'admin' ? 'standard' : 'admin';
try {
await window.SonicAPI.updateUserRole(user.id, nextRole, user.is_active);
setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`);
loadUsers();
} catch (err) { setError(err.message || 'Lỗi cập nhật vai trò'); }
};
const handleDeleteUser = async (userId) => {
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?')) return;
try {
await window.SonicAPI.deleteUser(userId);
setMsg('Đã xóa người dùng thành công'); loadUsers();
} catch (err) { setError(err.message || 'Lỗi khi xóa người dùng'); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200">
<div className="flex justify-between items-center pb-4 border-b border-[#383838]">
<h3 className="text-lg font-bold text-amber-400">⚙️ Quản Lý Hệ Thống & Phân Quyền Admin</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-200"></button>
</div>
{msg && <div className="mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs">{msg}</div>}
{error && <div className="mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs">{error}</div>}
<div className="mt-4 overflow-x-auto max-h-96 no-scrollbar">
{loading ? (
<div className="py-8 text-center text-slate-400 text-xs">Đang tải thông tin hệ thống...</div>
) : (
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-[#383838] text-slate-400 bg-[#1e1e1e]">
<th className="p-3">Tên Người Dùng</th>
<th className="p-3">Email</th>
<th className="p-3">Vai Trò</th>
<th className="p-3">Dung Lượng Sử Dụng</th>
<th className="p-3">Hạn Mức Quota</th>
<th className="p-3 text-right">Thao Tác</th>
</tr>
</thead>
<tbody className="divide-y divide-[#333]">
{users.map(u => (
<tr key={u.id} className="hover:bg-[#2e2e2e]">
<td className="p-3 font-semibold text-teal-300">
{u.username}
{u.must_change_password && <span className="ml-2 text-[10px] bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded">Mật khẩu gốc</span>}
</td>
<td className="p-3 text-slate-300">{u.email}</td>
<td className="p-3 uppercase font-bold text-amber-400">{u.role}</td>
<td className="p-3">{u.used_mb} MB</td>
<td className="p-3">
{editingQuotaUser === u.id ? (
<div className="flex items-center space-x-1">
<input type="number" value={newQuotaMb} onChange={(e) => setNewQuotaMb(e.target.value)} className="w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200" />
<span>MB</span>
<button onClick={() => handleSaveQuota(u.id)} className="px-2 py-0.5 bg-teal-600 rounded text-[10px]">Lưu</button>
</div>
) : (
<span className="font-semibold">{u.quota_mb} MB</span>
)}
</td>
<td className="p-3 text-right space-x-2">
<button onClick={() => { setEditingQuotaUser(u.id); setNewQuotaMb(u.quota_mb); }} className="px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-[11px]">Sửa Quota</button>
<button onClick={() => handleToggleRole(u)} className="px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-[11px]">Đổi Role</button>
{u.role !== 'admin' && (
<button onClick={() => handleDeleteUser(u.id)} className="px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-[11px]">Xóa</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
};
const mockBuffer = createMockAudioBufferObj(3.0, 44100);
const App = () => {
// ── State Definitions ──
const [tracks, setTracks] = useState([
{
id: '1',
name: 'Creak_DeepWood2.wav',
buffer: mockBuffer,
name: 'Track 01',
buffer: null,
startTime: 0,
height: 96,
volumeDb: 0,
@@ -1891,14 +2181,7 @@
color: '#0f766e',
markers: [],
serverFileId: null,
clips: [
{
id: 'clip_1',
buffer: mockBuffer,
startTime: 0,
name: 'Creak_DeepWood2.wav'
}
]
clips: []
},
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
]);
@@ -2055,45 +2338,7 @@
const [subTabNormVal, setSubTabNormVal] = useState(0);
const [subTabGainVal, setSubTabGainVal] = useState(100);
const [subTabPitchVal, setSubTabPitchVal] = useState(0);
const [subTabs, setSubTabs] = useState([
{
id: 'subtab_1',
label: 'Edit_Creak_Deep',
trackId: '1',
clipId: 'clip_1',
startTime: 0,
endTime: 3.0,
buffer: mockBuffer,
effects: { normalizeDb: 0, gainDb: 0, pitch: 0, speedStretch: 100 },
currentTime: 1.0,
selectionStart: null,
selectionEnd: null,
isPlaying: false,
fadeInLen: 1.0,
fadeOutLen: 1.0,
graphMode: null, // Volume Mode
volumeNodes: [
{ time: 0.0, db: -18.0 },
{ time: 0.2, db: -15.0 },
{ time: 0.4, db: -11.0 },
{ time: 0.6, db: -7.0 },
{ time: 0.8, db: -4.0 },
{ time: 1.0, db: -2.0 },
{ time: 1.2, db: -0.5 },
{ time: 1.4, db: 0.5 },
{ time: 1.6, db: 1.5 },
{ time: 1.8, db: 2.0 },
{ time: 2.0, db: 1.8 },
{ time: 2.2, db: 1.2 },
{ time: 2.4, db: 0.0 },
{ time: 2.6, db: -3.0 },
{ time: 2.8, db: -8.0 },
{ time: 3.0, db: -15.0 }
],
panningNodes: [],
speed: 1.0
}
]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
@@ -2112,6 +2357,100 @@
fadeOutMs: 0,
});
// ── Auth / User State ──
const [currentUser, setCurrentUser] = useState(null);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'force_change'
const [isMandatoryLogin, setIsMandatoryLogin] = useState(false);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false);
useEffect(() => {
const checkAuthStatus = async () => {
const savedToken = localStorage.getItem('sonic_token');
if (!savedToken) {
setIsMandatoryLogin(true);
setAuthMode('login');
setAuthModalOpen(true);
return;
}
try {
const profile = await window.SonicAPI.getProfile();
setCurrentUser(profile);
if (profile.must_change_password) {
setIsMandatoryLogin(true);
setAuthMode('force_change');
setAuthModalOpen(true);
} else {
setIsMandatoryLogin(false);
setAuthModalOpen(false);
}
} catch (err) {
localStorage.removeItem('sonic_token');
localStorage.removeItem('sonic_user');
setCurrentUser(null);
setIsMandatoryLogin(true);
setAuthMode('login');
setAuthModalOpen(true);
}
};
checkAuthStatus();
}, []);
const loadPendingSfsProject = () => {
const proj = window.__pendingSfsProject;
if (!proj) return;
try {
const restored = (proj.tracks || []).map(t => ({ ...t, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null }));
if (restored.length > 0) { setTracks(restored); showToast(`Đã tải dự án "${proj.name}" từ liên kết .sfs thành công!`, "success"); }
} catch (e) { showToast("Lỗi tải dự án từ .sfs", "error"); }
finally { window.__pendingSfsProject = null; }
};
const handleAuthSuccess = (user) => {
setCurrentUser(user);
if (user.must_change_password) {
setIsMandatoryLogin(true);
setAuthMode('force_change');
setAuthModalOpen(true);
} else {
setIsMandatoryLogin(false);
setAuthModalOpen(false);
loadPendingSfsProject();
}
};
const handleLogout = () => {
localStorage.removeItem('sonic_token');
localStorage.removeItem('sonic_user');
setCurrentUser(null);
setIsMandatoryLogin(true);
setAuthMode('login');
setAuthModalOpen(true);
};
// ── Temp project auto-save (local + server) ──
useEffect(() => {
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
}));
window.SonicStorage.scheduleTempAutoSave(() => ({
id: 'temp_project',
name: 'Dự án tạm chưa lưu',
tracks: serializeSafe(tracks),
subTabs: (subTabs || []).map(s => ({
id: s.id, label: s.label, trackId: s.trackId, clipId: s.clipId,
startTime: s.startTime, endTime: s.endTime, speed: s.speed,
fadeInLen: s.fadeInLen || 0, fadeOutLen: s.fadeOutLen || 0,
graphMode: s.graphMode, volumeNodes: s.volumeNodes || [], panningNodes: s.panningNodes || [],
channelInfo: s.channelInfo ? { channels: s.channelInfo.channels, isStereo: s.channelInfo.isStereo, label: s.channelInfo.label } : null
}))
}));
}, [tracks, subTabs]);
// Lucide icons initialization
useEffect(() => {
setTimeout(() => {
@@ -2495,10 +2834,10 @@
}
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; }
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); handleImportSFS(); return; }
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; }
if (ctrl && !alt && e.key === 's') { e.preventDefault(); handleExportSFS(); return; }
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); handleExportSFS(); return; }
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; }
if (ctrl && alt && e.key === 'i') { e.preventDefault(); showToast('Import audio','info'); return; }
if (ctrl && !alt && e.key === 'e') { e.preventDefault(); openTempTab(); return; }
@@ -2628,8 +2967,12 @@
}
const ctx = getAudioContext();
const subBuffer = ctx.createBuffer(1, len, sr);
subBuffer.copyToChannel(t.buffer.getChannelData(0).subarray(startSample, endSample), 0);
const numChannels = t.buffer.numberOfChannels || 1;
const subBuffer = ctx.createBuffer(numChannels, len, sr);
for (let c = 0; c < numChannels; c++) {
subBuffer.copyToChannel(t.buffer.getChannelData(c).subarray(startSample, endSample), c);
}
const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer);
const tabId = 'subtab_' + Date.now();
const tabLabel = `Edit_${t.name.replace('.wav','').slice(0,10)}_${selLeft.toFixed(1)}s`;
@@ -2641,6 +2984,7 @@
startTime: selLeft,
endTime: selRight,
buffer: subBuffer,
channelInfo: subChannelInfo,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
currentTime: 0,
selectionStart: null,
@@ -2680,11 +3024,16 @@
return;
}
const sr = clip.buffer.sampleRate;
const len = clip.buffer.length;
const ctx = getAudioContext();
const subBuffer = ctx.createBuffer(1, len, sr);
subBuffer.copyToChannel(clip.buffer.getChannelData(0), 0);
const sr = clip.buffer.sampleRate;
const len = clip.buffer.length;
const numChannels = clip.buffer.numberOfChannels || 1;
const ctx = getAudioContext();
const subBuffer = ctx.createBuffer(numChannels, len, sr);
for (let c = 0; c < numChannels; c++) {
subBuffer.copyToChannel(clip.buffer.getChannelData(c), c);
}
const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer);
const tabId = 'subtab_' + Date.now();
const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`;
@@ -2696,8 +3045,9 @@
clipId: resolvedClipId,
startTime: clip.startTime,
endTime: clip.startTime + clip.buffer.duration,
buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
buffer: subBuffer,
channelInfo: subChannelInfo,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
currentTime: 0,
selectionStart: null,
selectionEnd: null,
@@ -4632,31 +4982,23 @@
// ── Load File on Track (with server upload) ──
const loadFileOnTrack = async (trackId, file) => {
if (!file) return;
const context = getAudioContext();
showToast(`Đang nạp file ${file.name}...`, 'info');
try {
// Upload to server
uploadToServer(file, trackId);
// Decode locally for playback
const reader = new FileReader();
reader.onload = async (e) => {
try {
const decodedBuffer = await context.decodeAudioData(e.target.result);
setTracks(prev => prev.map(t => t.id === trackId ? {
...t,
name: file.name,
buffer: decodedBuffer
} : t));
showToast(`Nạp file thành công: ${file.name}`, 'success');
} catch (err) {
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error');
}
};
reader.readAsArrayBuffer(file);
// Decode locally for playback + analyze channels (stereo/mono)
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
setTracks(prev => prev.map(t => t.id === trackId ? {
...t,
name: file.name,
buffer: decodedBuffer,
channelInfo: channelInfo
} : t));
showToast(`Nạp file thành công: ${file.name} (${channelInfo.label})`, 'success');
} catch (err) {
showToast("Lỗi: " + err.message, 'error');
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error');
}
};
@@ -4791,6 +5133,48 @@
}
};
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 => ({
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: '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");
} catch (err) { showToast(err.message || "Lỗi lưu Cloud", "error"); }
};
const handleExportSFS = () => {
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
}));
window.SonicStorage.exportProjectToSFS({ id: 'proj_' + Date.now(), name: 'Dự án SonicForge', tracks: serializeSafe(tracks) });
showToast("Đã xuất dự án (.sfs) thành công!", "success");
};
const handleImportSFS = () => {
const input = document.createElement('input');
input.type = 'file'; input.accept = '.sfs,application/json';
input.onchange = async (e) => {
if (!e.target.files[0]) return;
try {
const proj = await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]);
const restored = (proj.tracks || []).map(t => ({ ...t, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null }));
if (restored.length > 0) { setTracks(restored); showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success"); }
} catch (err) { showToast(err.message || "Lỗi mở tệp .sfs", "error"); }
};
input.click();
};
const clientSideExport = async (activeTracks) => {
setIsExporting(true);
showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info");
@@ -5317,16 +5701,18 @@
{[
{ label: 'File', items: [
{ label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') },
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') },
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') },
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => showToast('Saving to cloud...','info') },
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => handleImportSFS() },
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => handleExportSFS() },
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => handleExportSFS() },
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => handleSaveCloud() },
{ sep: true },
{ label: 'Import Audio...', icon: 'file-input', shortcut: 'Ctrl+Alt+I', action: () => { const input = document.createElement('input'); input.type='file'; input.accept='audio/*'; input.onchange=async (e)=>{ if(e.target.files[0]){ addNewTrack(); const newId=(tracks.length+1).toString(); setTimeout(()=>loadFileOnTrack(newId,e.target.files[0]),100); } }; input.click(); showToast('Import audio','info'); } },
{ label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() },
{ sep: true },
{ label: 'Logout', icon: 'log-out', action: () => showToast('Logged out','info') },
]},
{ label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() },
{ sep: true },
...(currentUser ? [{ label: 'Profile', icon: 'user', action: () => setProfileModalOpen(true) }] : []),
...(currentUser && currentUser.role === 'admin' ? [{ label: 'System Manager', icon: 'settings', action: () => setSystemManagerModalOpen(true) }] : []),
{ label: 'Logout', icon: 'log-out', action: () => handleLogout() },
]},
{ label: 'Edit', items: [
{ label: 'Insert New Track', icon: 'plus', shortcut: 'Ctrl+I', action: addNewTrack },
{ label: 'Insert Music to Track', icon: 'music', shortcut: 'Ctrl+Alt+I', action: () => showToast('Select music file to insert','info') },
@@ -5836,9 +6222,25 @@
{renderPanelContent(p)}
</div>
))}
</div>
);
};
{/* ── Auth & User Modals ── */}
<AuthModal
isOpen={authModalOpen}
mode={authMode}
forceMandatory={isMandatoryLogin}
onClose={() => setAuthModalOpen(false)}
onSuccess={handleAuthSuccess}
/>
<ProfileModal
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
<SystemManagerModal
isOpen={systemManagerModalOpen}
onClose={() => setSystemManagerModalOpen(false)}
/>
</div>
);
};
return (
<div ref={workspaceRef} className="flex-1 flex flex-col overflow-hidden select-none daw-bg relative">
@@ -6189,6 +6591,7 @@
fadeInLen={st.fadeInLen || 0}
fadeOutLen={st.fadeOutLen || 0}
graphMode={st.graphMode}
channelInfo={st.channelInfo}
selectedNodeTime={subTabSelectedNodeTime}
setSelectedNodeTime={setSubTabSelectedNodeTime}
onUpdateNodes={(nodes) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: nodes} : s))}
@@ -6386,6 +6789,22 @@
{toastMessage.text}
</div>
)}
{/* ── Auth & User Modals ── */}
<AuthModal
isOpen={authModalOpen}
mode={authMode}
forceMandatory={isMandatoryLogin}
onClose={() => setAuthModalOpen(false)}
onSuccess={handleAuthSuccess}
/>
<ProfileModal
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
<SystemManagerModal
isOpen={systemManagerModalOpen}
onClose={() => setSystemManagerModalOpen(false)}
/>
</div>
);
};