feat: My Computer auto-load cây + Favorites thư mục
This commit is contained in:
+110
-13
@@ -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 cấu trúc: expand từng thư mục con cấp 1 để hiển 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ư mục 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ố gắng tìm handle trong cây đã load để mở trực 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) {
|
||||||
|
// Duyệt lại từ root handle tới 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
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user