feat: My Computer auto-load cây + Favorites thư mục

This commit is contained in:
2026-08-02 18:30:19 +07:00
parent 9be6991222
commit 0b9079632a
4 changed files with 125 additions and 19 deletions
+110 -13
View File
@@ -9071,6 +9071,10 @@ const MediaExplorerPanel = ({ height }) => {
const [computerFiles, setComputerFiles] = React.useState([]); const [computerFiles, setComputerFiles] = React.useState([]);
const [computerMode, setComputerMode] = React.useState('server'); const [computerMode, setComputerMode] = React.useState('server');
const [clientRoot, setClientRoot] = React.useState(null); const [clientRoot, setClientRoot] = React.useState(null);
const [favorites, setFavorites] = React.useState(function() {
try { return JSON.parse(localStorage.getItem('studio_media_explorer_favorites_v1') || '[]'); } catch (e) { return []; }
}());
const [favContext, setFavContext] = React.useState(null);
const [synthInst, setSynthInst] = React.useState(function() { const [synthInst, setSynthInst] = React.useState(function() {
var saved = localStorage.getItem('studio_media_explorer_synth'); var saved = localStorage.getItem('studio_media_explorer_synth');
return saved ? JSON.parse(saved) : null; return saved ? JSON.parse(saved) : null;
@@ -9359,7 +9363,11 @@ const MediaExplorerPanel = ({ height }) => {
setComputerPath('root'); setComputerPath('root');
setComputerFiles([]); setComputerFiles([]);
setComputerTree({ root: { handle, parent: null, dirs: [], expanded: true } }); setComputerTree({ root: { handle, parent: null, dirs: [], expanded: true } });
await browseComputerDir(rootEntry); const browsed = await browseComputerDir(rootEntry);
// T đng load cu trúc: expand tng thư mc con cp 1 đ hin th cây
if (browsed && browsed.dirs) {
browsed.dirs.slice(0, 10).forEach(d => browseComputerDir(d));
}
return; return;
} catch (e) { } catch (e) {
if (e && e.name === 'AbortError') return; if (e && e.name === 'AbortError') return;
@@ -9386,7 +9394,7 @@ const MediaExplorerPanel = ({ height }) => {
const browseClientDir = async (entry) => { const browseClientDir = async (entry) => {
const handle = entry && entry.handle; const handle = entry && entry.handle;
if (!handle || !handle.entries) return; if (!handle || !handle.entries) return null;
const dirs = []; const dirs = [];
const files = []; const files = [];
const parentPath = entry.path; const parentPath = entry.path;
@@ -9411,22 +9419,23 @@ const MediaExplorerPanel = ({ height }) => {
setCurrentTime(0); setCurrentTime(0);
stopMediaPlayback(); stopMediaPlayback();
setComputerTree(prev => ({ ...prev, [parentPath]: { handle, parent: entry.parent || null, dirs, expanded: true } })); setComputerTree(prev => ({ ...prev, [parentPath]: { handle, parent: entry.parent || null, dirs, expanded: true } }));
return { dirs, files };
} catch (e) { } catch (e) {
window.showToast && window.showToast('Không thể đọc thư mục: ' + e.message, 'error'); window.showToast && window.showToast('Không thể đọc thư mục: ' + e.message, 'error');
return null;
} }
}; };
const browseComputerDir = async (entry) => { const browseComputerDir = async (entry) => {
if (!entry) return; if (!entry) return null;
if (computerMode === 'client' || entry.handle) { if (computerMode === 'client' || entry.handle) {
if (entry.handle && entry.handle.entries) { if (entry.handle && entry.handle.entries) {
const parentInfo = computerTree[entry.path]; const parentInfo = computerTree[entry.path];
await browseClientDir({ ...entry, parent: parentInfo ? parentInfo.parent : null }); return await browseClientDir({ ...entry, parent: parentInfo ? parentInfo.parent : null });
return;
} }
} }
const path = entry.path || entry; const path = entry.path || entry;
if (!path) return; if (!path) return null;
try { try {
const resp = await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`); const resp = await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
@@ -9437,8 +9446,10 @@ const MediaExplorerPanel = ({ height }) => {
setCurrentTime(0); setCurrentTime(0);
stopMediaPlayback(); stopMediaPlayback();
setComputerTree(prev => ({ ...prev, [path]: { dirs: data.dirs || [], expanded: true } })); setComputerTree(prev => ({ ...prev, [path]: { dirs: data.dirs || [], expanded: true } }));
return { dirs: data.dirs || [], files: data.files || [] };
} catch (e) { } catch (e) {
window.showToast && window.showToast('Không thể mở thư mục: ' + e.message, 'error'); window.showToast && window.showToast('Không thể mở thư mục: ' + e.message, 'error');
return null;
} }
}; };
@@ -9446,15 +9457,64 @@ const MediaExplorerPanel = ({ height }) => {
if (!entry) return; if (!entry) return;
const path = entry.path || entry; const path = entry.path || entry;
const node = computerTree[path]; const node = computerTree[path];
if (node && node.expanded) { if (node && node.expanded) { setComputerTree(prev => ({ ...prev, [path]: { ...prev[path], expanded: false } }));
setComputerTree(prev => ({ ...prev, [path]: { ...prev[path], expanded: false } }));
} else { } else {
await browseComputerDir(entry); await browseComputerDir(entry);
} }
}; };
const goComputerParent = () => { // Favorites: thư mc yêu thích
if (!computerPath) return; const isFavorite = (entry) => {
if (!entry) return false;
return (favorites || []).some(f => f.path === (entry.path || entry));
};
const toggleFavorite = (entry, e) => {
if (e && e.stopPropagation) e.stopPropagation();
if (!entry) return;
const path = entry.path || entry;
const name = entry.name || path.split('/').pop() || path;
setFavorites(prev => {
const exists = prev.some(f => f.path === path);
const next = exists ? prev.filter(f => f.path !== path) : [...prev, { path, name, is_dir: true }];
try { localStorage.setItem('studio_media_explorer_favorites_v1', JSON.stringify(next)); } catch (e2) {}
return next;
});
window.showToast && window.showToast('Đã ' + (isFavorite(entry) ? 'gỡ khỏi' : 'thêm vào') + ' Favorites: ' + name, 'info');
};
const openFavorite = (fav) => {
if (!fav) return;
setFolder('computer');
const entry = { path: fav.path, name: fav.name, is_dir: true };
// C gng tìm handle trong cây đã load đ m trc tiếp
const node = computerTree[fav.path];
if (node && node.handle) {
browseComputerDir({ ...entry, handle: node.handle });
} else if (fav.path === 'root' && clientRoot) {
browseComputerDir({ name: clientRoot.name, path: 'root', is_dir: true, handle: clientRoot });
} else if (fav.path && fav.path.startsWith('root/') && clientRoot) {
// Duyt li t root handle ti folder favorite
const segments = fav.path.split('/').slice(1);
let curHandle = clientRoot;
const walk = async (idx) => {
if (idx >= segments.length) {
browseComputerDir({ name: segments[idx - 1] || clientRoot.name, path: fav.path, is_dir: true, handle: curHandle });
return;
}
try {
const child = await curHandle.getDirectoryHandle(segments[idx]);
curHandle = child;
walk(idx + 1);
} catch (e) {
window.showToast && window.showToast('Không mở được thư mục favorite', 'error');
}
};
walk(0);
} else if (fav.path) {
browseComputerDir(entry);
}
};
const goComputerParent = () => { if (!computerPath) return;
if (computerMode === 'client' || clientRoot) { if (computerMode === 'client' || clientRoot) {
const node = computerTree[computerPath]; const node = computerTree[computerPath];
const parentHandle = node && node.parent; const parentHandle = node && node.parent;
@@ -9831,15 +9891,18 @@ const MediaExplorerPanel = ({ height }) => {
const expanded = node && node.expanded; const expanded = node && node.expanded;
const dirs = node ? node.dirs : []; const dirs = node ? node.dirs : [];
const pad = 12 + depth * 12; const pad = 12 + depth * 12;
const fav = isFavorite(entry);
return ( return (
<React.Fragment key={nodePath}> <React.Fragment key={nodePath}>
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${computerPath === nodePath ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} <div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${computerPath === nodePath ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`}
style={{ paddingLeft: pad }} style={{ paddingLeft: pad }}
onClick={() => browseComputerDir(entry)} onClick={() => browseComputerDir(entry)}
onDoubleClick={e => { e.stopPropagation(); toggleComputerDir(entry); }}> onDoubleClick={e => { e.stopPropagation(); toggleComputerDir(entry); }}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setFavContext(Object.assign({}, entry, { x: e.clientX, y: e.clientY })); }}>
<i className={`fa-solid ${expanded ? 'fa-minus' : 'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`} onClick={e => { e.stopPropagation(); toggleComputerDir(entry); }}></i> <i className={`fa-solid ${expanded ? 'fa-minus' : 'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`} onClick={e => { e.stopPropagation(); toggleComputerDir(entry); }}></i>
<i className={`fa-solid ${isRoot ? 'fa-hard-drive text-[#6ea8dc]' : 'fa-folder text-[#d9a752]'} shrink-0`}></i> <i className={`fa-solid ${isRoot ? 'fa-hard-drive text-[#6ea8dc]' : 'fa-folder text-[#d9a752]'} shrink-0`}></i>
<span className="truncate">{entry.name}</span> <span className="truncate flex-1">{entry.name}</span>
{fav && <i className="fa-solid fa-star text-amber-500 text-[9px] shrink-0"></i>}
</div> </div>
{expanded && dirs.map(d => renderComputerNode(d, depth + 1, false))} {expanded && dirs.map(d => renderComputerNode(d, depth + 1, false))}
</React.Fragment> </React.Fragment>
@@ -9906,7 +9969,7 @@ const MediaExplorerPanel = ({ height }) => {
</div> </div>
{/* 2. MIDDLE SPLIT VIEW */} {/* 2. MIDDLE SPLIT VIEW */}
<div className="flex-1 flex overflow-hidden min-h-0"> <div className="flex-1 flex overflow-hidden min-h-0 relative">
{/* DIRECTORY TREE */} {/* DIRECTORY TREE */}
<div className="flex bg-white border border-[#808080] m-1 mr-0 text-xs select-none shrink-0 overflow-hidden"> <div className="flex bg-white border border-[#808080] m-1 mr-0 text-xs select-none shrink-0 overflow-hidden">
<div className="overflow-y-auto p-1" style={{ width: treeWidth + 'px', minWidth: treeWidth + 'px', maxWidth: treeWidth + 'px' }}> <div className="overflow-y-auto p-1" style={{ width: treeWidth + 'px', minWidth: treeWidth + 'px', maxWidth: treeWidth + 'px' }}>
@@ -9916,6 +9979,24 @@ const MediaExplorerPanel = ({ height }) => {
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${folder === 'computer' ? 'bg-slate-300 text-slate-900 font-semibold' : 'hover:bg-slate-200 text-slate-800'}`} onClick={openMyComputer}> <div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${folder === 'computer' ? 'bg-slate-300 text-slate-900 font-semibold' : 'hover:bg-slate-200 text-slate-800'}`} onClick={openMyComputer}>
<i className="fa-solid fa-computer text-[11px] text-slate-600"></i> My Computer <i className="fa-solid fa-computer text-[11px] text-slate-600"></i> My Computer
</div> </div>
{favorites && favorites.length > 0 && (
<div className="pl-3 space-y-0.5">
<div className="flex items-center gap-1 px-1 py-0.5 font-semibold text-amber-700 rounded-sm">
<i className="fa-solid fa-star text-amber-500 text-[10px]"></i> Favorites
</div>
{favorites.map((fav, fi) => (
<div key={fav.path + fi}
className="flex items-center gap-1 px-1 py-0.5 cursor-pointer pl-4 rounded-sm hover:bg-amber-100 text-slate-800"
style={{ paddingLeft: 24 }}
onClick={() => openFavorite(fav)}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setFavContext(Object.assign({}, fav, { x: e.clientX, y: e.clientY })); }}>
<i className="fa-solid fa-folder text-[#d9a752] shrink-0"></i>
<span className="truncate flex-1">{fav.name}</span>
<i className="fa-solid fa-star text-amber-500 text-[9px] shrink-0"></i>
</div>
))}
</div>
)}
{folder === 'computer' && computerRoots && ( {folder === 'computer' && computerRoots && (
<div className="pl-3 space-y-0.5"> <div className="pl-3 space-y-0.5">
{computerRoots.map(root => renderComputerNode(root, 0, true))} {computerRoots.map(root => renderComputerNode(root, 0, true))}
@@ -9941,6 +10022,22 @@ const MediaExplorerPanel = ({ height }) => {
title="Kéo để thay đổi chiều rộng"></div> title="Kéo để thay đổi chiều rộng"></div>
</div> </div>
{/* CONTEXT MENU (right-click folder → Favorite) */}
{favContext && (
<div className="fixed z-[100] bg-white border border-[#808080] shadow-lg rounded-sm text-xs font-sans text-slate-800 min-w-[180px]"
style={{ left: favContext.x, top: favContext.y }}
onMouseLeave={() => setFavContext(null)}>
<div className={`px-2 py-1 cursor-pointer hover:bg-blue-100 flex items-center gap-1.5 ${isFavorite(favContext) ? 'text-amber-700' : ''}`}
onClick={() => { toggleFavorite(favContext); setFavContext(null); }}>
<i className={`fa-solid ${isFavorite(favContext) ? 'fa-star text-amber-500' : 'fa-star text-slate-400'} text-[11px]`}></i>
{isFavorite(favContext) ? 'Gỡ khỏi Favorites' : 'Thêm vào Favorites'}
</div>
<div className="px-2 py-1 cursor-pointer hover:bg-blue-100 flex items-center gap-1.5" onClick={() => { if (favContext) browseComputerDir(favContext); setFavContext(null); }}>
<i className="fa-solid fa-folder-open text-[#d9a752] text-[11px]"></i> Mở thư mục
</div>
</div>
)}
{/* FILE LIST */} {/* FILE LIST */}
<div className="flex-1 bg-white border border-[#808080] m-1 overflow-y-auto relative"> <div className="flex-1 bg-white border border-[#808080] m-1 overflow-y-auto relative">
<table className="w-full text-xs text-left border-collapse"> <table className="w-full text-xs text-left border-collapse">
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -24,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script> <script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608021936" defer></script> <script src="/static/js/app.precompiled.js?v=202608021956" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+5
View File
@@ -1144,3 +1144,8 @@
- **Tóm tắt thay đổi:** `drawCanvas` branch audio: bỏ `contentW = Math.max(w, dur*pxPerSec)` (giãn đầy frame) → `contentW = max(1, dur*pxPerSec)` với `pxPerBeat=42`, `pxPerSec = 42*bpm/60` (cùng tỉ lệ tempo với MIDI). Clip ngắn hiển thị đúng chiều rộng theo tempo (2s@120 → 168px), clip dài scroll; playhead theo tỉ lệ tempo (`t*pxPerSec`, khóa giữa khi scroll). Ruler cùng scale (bỏ stretch `Math.max(w, ...)`). Thêm `audioDuration` vào deps effect redraw. - **Tóm tắt thay đổi:** `drawCanvas` branch audio: bỏ `contentW = Math.max(w, dur*pxPerSec)` (giãn đầy frame) → `contentW = max(1, dur*pxPerSec)` với `pxPerBeat=42`, `pxPerSec = 42*bpm/60` (cùng tỉ lệ tempo với MIDI). Clip ngắn hiển thị đúng chiều rộng theo tempo (2s@120 → 168px), clip dài scroll; playhead theo tỉ lệ tempo (`t*pxPerSec`, khóa giữa khi scroll). Ruler cùng scale (bỏ stretch `Math.max(w, ...)`). Thêm `audioDuration` vào deps effect redraw.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html` - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Probe drawCanvas: 2s@120 → contentW=168, waveform maxX 167.8 < 300 (không giãn); 10s → 840 scroll; 2s@240 → 336 (rộng hơn theo tempo). Hard reload. - **Ghi chú/Test (nếu có):** Probe drawCanvas: 2s@120 → contentW=168, waveform maxX 167.8 < 300 (không giãn); 10s → 840 scroll; 2s@240 → 336 (rộng hơn theo tempo). Hard reload.
### [2026-08-02 19:56] Task: My Computer auto-load cây + Favorites thư mục
- **Tóm tắt thay đổi:** (1) Khi mở My Computer client-side (pick xong root), tự động browse + expand tối đa 10 thư mục con cấp 1 (`browseComputerDir`/`browseClientDir` giờ trả về `{dirs, files}` để auto-expand) → cây hiển thị cấu trúc ổ đĩa/thư mục ngay. (2) **Favorites**: node "Favorites" ngay dưới My Computer (hiện khi có favorite, icon `fa-star`); chuột phải folder trong cây (hoặc trong Favorites) → context menu "Thêm vào Favorites"/"Gỡ khỏi Favorites" + "Mở thư mục"; lưu vào `localStorage['studio_media_explorer_favorites_v1']` `[{path, name}]`; click favorite mở nhanh folder (tìm handle trong `computerTree`, hoặc walk lại từ `clientRoot` qua `getDirectoryHandle` theo segments path, fallback `browseComputerDir`); node folder có favorite hiện icon star.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: pick root → C: + Users + Program Files auto-load; right-click Users → menu → favorite saved localStorage + node Favorites hiện; click favorite → mở `root/Users`. Hard reload.