fix: refactor
This commit is contained in:
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 732 KiB |
+528
-109
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user