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 [computerMode, setComputerMode] = React.useState('server');
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() {
var saved = localStorage.getItem('studio_media_explorer_synth');
return saved ? JSON.parse(saved) : null;
@@ -9359,7 +9363,11 @@ const MediaExplorerPanel = ({ height }) => {
setComputerPath('root');
setComputerFiles([]);
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;
} catch (e) {
if (e && e.name === 'AbortError') return;
@@ -9386,7 +9394,7 @@ const MediaExplorerPanel = ({ height }) => {
const browseClientDir = async (entry) => {
const handle = entry && entry.handle;
if (!handle || !handle.entries) return;
if (!handle || !handle.entries) return null;
const dirs = [];
const files = [];
const parentPath = entry.path;
@@ -9411,22 +9419,23 @@ const MediaExplorerPanel = ({ height }) => {
setCurrentTime(0);
stopMediaPlayback();
setComputerTree(prev => ({ ...prev, [parentPath]: { handle, parent: entry.parent || null, dirs, expanded: true } }));
return { dirs, files };
} catch (e) {
window.showToast && window.showToast('Không thể đọc thư mục: ' + e.message, 'error');
return null;
}
};
const browseComputerDir = async (entry) => {
if (!entry) return;
if (!entry) return null;
if (computerMode === 'client' || entry.handle) {
if (entry.handle && entry.handle.entries) {
const parentInfo = computerTree[entry.path];
await browseClientDir({ ...entry, parent: parentInfo ? parentInfo.parent : null });
return;
return await browseClientDir({ ...entry, parent: parentInfo ? parentInfo.parent : null });
}
}
const path = entry.path || entry;
if (!path) return;
if (!path) return null;
try {
const resp = await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
@@ -9437,8 +9446,10 @@ const MediaExplorerPanel = ({ height }) => {
setCurrentTime(0);
stopMediaPlayback();
setComputerTree(prev => ({ ...prev, [path]: { dirs: data.dirs || [], expanded: true } }));
return { dirs: data.dirs || [], files: data.files || [] };
} catch (e) {
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;
const path = entry.path || entry;
const node = computerTree[path];
if (node && node.expanded) {
setComputerTree(prev => ({ ...prev, [path]: { ...prev[path], expanded: false } }));
if (node && node.expanded) { setComputerTree(prev => ({ ...prev, [path]: { ...prev[path], expanded: false } }));
} else {
await browseComputerDir(entry);
}
};
const goComputerParent = () => {
if (!computerPath) return;
// Favorites: thư mc yêu thích
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) {
const node = computerTree[computerPath];
const parentHandle = node && node.parent;
@@ -9831,15 +9891,18 @@ const MediaExplorerPanel = ({ height }) => {
const expanded = node && node.expanded;
const dirs = node ? node.dirs : [];
const pad = 12 + depth * 12;
const fav = isFavorite(entry);
return (
<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'}`}
style={{ paddingLeft: pad }}
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 ${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>
{expanded && dirs.map(d => renderComputerNode(d, depth + 1, false))}
</React.Fragment>
@@ -9906,7 +9969,7 @@ const MediaExplorerPanel = ({ height }) => {
</div>
{/* 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 */}
<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' }}>
@@ -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}>
<i className="fa-solid fa-computer text-[11px] text-slate-600"></i> My Computer
</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 && (
<div className="pl-3 space-y-0.5">
{computerRoots.map(root => renderComputerNode(root, 0, true))}
@@ -9941,6 +10022,22 @@ const MediaExplorerPanel = ({ height }) => {
title="Kéo để thay đổi chiều rộng"></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 */}
<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">