2026-03-05 08:05:23 +07:00
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
|
|
import { searchLibraries, installLibrary, getInstalledLibraries } from '../../services/libraryService';
|
|
|
|
|
import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService';
|
|
|
|
|
import './LibraryManagerModal.css';
|
|
|
|
|
|
|
|
|
|
interface LibraryManagerModalProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Tab = 'search' | 'installed';
|
|
|
|
|
|
|
|
|
|
export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen, onClose }) => {
|
|
|
|
|
const [activeTab, setActiveTab] = useState<Tab>('search');
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [searchResults, setSearchResults] = useState<ArduinoLibrary[]>([]);
|
|
|
|
|
const [installedLibraries, setInstalledLibraries] = useState<InstalledLibrary[]>([]);
|
|
|
|
|
const [loadingSearch, setLoadingSearch] = useState(false);
|
|
|
|
|
const [loadingInstalled, setLoadingInstalled] = useState(false);
|
|
|
|
|
const [installingLib, setInstallingLib] = useState<string | null>(null);
|
|
|
|
|
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
|
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
const fetchInstalled = useCallback(async () => {
|
|
|
|
|
setLoadingInstalled(true);
|
|
|
|
|
try {
|
|
|
|
|
const libs = await getInstalledLibraries();
|
|
|
|
|
setInstalledLibraries(libs);
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Failed to load installed libraries' });
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingInstalled(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-05 11:23:59 +07:00
|
|
|
// Fetch installed list when modal opens (to cross-reference in search tab)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
fetchInstalled();
|
|
|
|
|
}
|
|
|
|
|
}, [isOpen, fetchInstalled]);
|
|
|
|
|
|
|
|
|
|
// Also refresh when switching to the installed tab
|
2026-03-05 08:05:23 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (isOpen && activeTab === 'installed') {
|
|
|
|
|
fetchInstalled();
|
|
|
|
|
}
|
|
|
|
|
}, [isOpen, activeTab, fetchInstalled]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!searchQuery.trim()) {
|
|
|
|
|
setSearchResults([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
|
|
|
debounceRef.current = setTimeout(async () => {
|
|
|
|
|
setLoadingSearch(true);
|
|
|
|
|
setStatusMsg(null);
|
|
|
|
|
try {
|
|
|
|
|
const results = await searchLibraries(searchQuery);
|
|
|
|
|
setSearchResults(results);
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Search failed' });
|
|
|
|
|
setSearchResults([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingSearch(false);
|
|
|
|
|
}
|
|
|
|
|
}, 500);
|
|
|
|
|
|
|
|
|
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
|
|
|
}, [searchQuery]);
|
|
|
|
|
|
|
|
|
|
const handleInstall = async (libName: string) => {
|
|
|
|
|
setInstallingLib(libName);
|
|
|
|
|
setStatusMsg(null);
|
|
|
|
|
try {
|
|
|
|
|
const result = await installLibrary(libName);
|
|
|
|
|
if (result.success) {
|
|
|
|
|
setStatusMsg({ type: 'success', text: `"${libName}" installed successfully!` });
|
2026-03-05 11:23:59 +07:00
|
|
|
fetchInstalled(); // Refresh installed list so search tab reflects new state
|
2026-03-05 08:05:23 +07:00
|
|
|
} else {
|
|
|
|
|
setStatusMsg({ type: 'error', text: result.error || `Failed to install "${libName}"` });
|
|
|
|
|
}
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Installation failed' });
|
|
|
|
|
} finally {
|
|
|
|
|
setInstallingLib(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
setStatusMsg(null);
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
setSearchResults([]);
|
|
|
|
|
onClose();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!isOpen) return null;
|
|
|
|
|
|
2026-03-05 11:23:59 +07:00
|
|
|
const isInstalled = (libName: string): boolean =>
|
|
|
|
|
installedLibraries.some(
|
|
|
|
|
(il) => (il.library?.name || il.name || '').toLowerCase() === libName.toLowerCase()
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-05 08:05:23 +07:00
|
|
|
const getLibName = (lib: ArduinoLibrary): string => lib.name || 'Unknown';
|
|
|
|
|
const getLibVersion = (lib: ArduinoLibrary): string => lib.latest?.version || lib.version || '';
|
|
|
|
|
const getLibAuthor = (lib: ArduinoLibrary): string => lib.latest?.author || lib.author || '';
|
|
|
|
|
const getLibDesc = (lib: ArduinoLibrary): string => lib.latest?.sentence || lib.sentence || '';
|
|
|
|
|
|
|
|
|
|
const getInstalledName = (lib: InstalledLibrary): string =>
|
|
|
|
|
lib.library?.name || lib.name || 'Unknown';
|
|
|
|
|
const getInstalledVersion = (lib: InstalledLibrary): string =>
|
|
|
|
|
lib.library?.version || lib.version || '';
|
|
|
|
|
const getInstalledAuthor = (lib: InstalledLibrary): string =>
|
|
|
|
|
lib.library?.author || lib.author || '';
|
|
|
|
|
const getInstalledDesc = (lib: InstalledLibrary): string =>
|
|
|
|
|
lib.library?.sentence || lib.sentence || '';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="lib-modal-overlay" onClick={handleClose}>
|
|
|
|
|
<div className="lib-modal" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="lib-modal-header">
|
|
|
|
|
<div className="lib-modal-title">
|
|
|
|
|
<svg className="lib-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
|
|
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
|
|
|
|
<path d="m3.3 7 8.7 5 8.7-5" />
|
|
|
|
|
<path d="M12 22V12" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span>LIBRARY MANAGER</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button className="lib-close-btn" onClick={handleClose}>
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
|
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
|
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Tabs */}
|
|
|
|
|
<div className="lib-tabs">
|
|
|
|
|
<button
|
|
|
|
|
className={`lib-tab ${activeTab === 'search' ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setActiveTab('search')}
|
|
|
|
|
>
|
|
|
|
|
Search
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className={`lib-tab ${activeTab === 'installed' ? 'active' : ''}`}
|
|
|
|
|
onClick={() => setActiveTab('installed')}
|
|
|
|
|
>
|
|
|
|
|
Installed
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Status bar */}
|
|
|
|
|
{statusMsg && (
|
|
|
|
|
<div className={`lib-status ${statusMsg.type}`}>
|
|
|
|
|
{statusMsg.type === 'success' ? (
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
|
|
|
|
<polyline points="20 6 9 17 4 12" />
|
|
|
|
|
</svg>
|
|
|
|
|
) : (
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
|
|
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
|
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
{statusMsg.text}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Search Tab */}
|
|
|
|
|
{activeTab === 'search' && (
|
|
|
|
|
<div className="lib-content">
|
|
|
|
|
<div className="lib-search-bar">
|
|
|
|
|
<svg className="lib-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
|
|
|
<circle cx="11" cy="11" r="8" />
|
|
|
|
|
<path d="m21 21-4.3-4.3" />
|
|
|
|
|
</svg>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Filter your search..."
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
{loadingSearch && (
|
|
|
|
|
<svg className="lib-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
|
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="lib-list">
|
|
|
|
|
{!searchQuery.trim() && (
|
|
|
|
|
<div className="lib-empty">
|
|
|
|
|
<p>Type a library name to search</p>
|
|
|
|
|
<p className="lib-empty-sub">e.g. "ArduinoJson", "Servo", "LiquidCrystal"</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{searchQuery.trim() && searchResults.length === 0 && !loadingSearch && (
|
|
|
|
|
<div className="lib-empty">
|
|
|
|
|
<p>No libraries found for "{searchQuery}"</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{searchResults.map((lib, i) => (
|
|
|
|
|
<div key={i} className="lib-item">
|
|
|
|
|
<div className="lib-item-info">
|
|
|
|
|
<div className="lib-item-header">
|
|
|
|
|
<span className="lib-item-name">{getLibName(lib)}</span>
|
|
|
|
|
{getLibAuthor(lib) && (
|
|
|
|
|
<span className="lib-item-author">by {getLibAuthor(lib)}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{getLibDesc(lib) && (
|
|
|
|
|
<p className="lib-item-desc">{getLibDesc(lib)}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="lib-item-actions">
|
|
|
|
|
{getLibVersion(lib) && (
|
|
|
|
|
<span className="lib-item-version">{getLibVersion(lib)}</span>
|
|
|
|
|
)}
|
2026-03-05 11:23:59 +07:00
|
|
|
{isInstalled(getLibName(lib)) ? (
|
|
|
|
|
<span className="lib-item-version lib-installed-badge">
|
|
|
|
|
INSTALLED
|
|
|
|
|
<svg style={{ display: 'inline', marginLeft: '4px', verticalAlign: 'middle' }} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
className="lib-install-btn"
|
|
|
|
|
onClick={() => handleInstall(getLibName(lib))}
|
|
|
|
|
disabled={installingLib !== null}
|
|
|
|
|
>
|
|
|
|
|
{installingLib === getLibName(lib) ? (
|
|
|
|
|
<span className="lib-installing">Installing...</span>
|
|
|
|
|
) : (
|
|
|
|
|
'INSTALL'
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-03-05 08:05:23 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Installed Tab */}
|
|
|
|
|
{activeTab === 'installed' && (
|
|
|
|
|
<div className="lib-content">
|
|
|
|
|
<div className="lib-list">
|
|
|
|
|
{loadingInstalled && (
|
|
|
|
|
<div className="lib-empty">
|
|
|
|
|
<p>Loading installed libraries...</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{!loadingInstalled && installedLibraries.length === 0 && (
|
|
|
|
|
<div className="lib-empty">
|
|
|
|
|
<p>No libraries installed yet.</p>
|
|
|
|
|
<p className="lib-empty-sub">Use the Search tab to install libraries.</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{installedLibraries.map((lib, i) => (
|
|
|
|
|
<div key={i} className="lib-item">
|
|
|
|
|
<div className="lib-item-info">
|
|
|
|
|
<div className="lib-item-header">
|
|
|
|
|
<span className="lib-item-name">{getInstalledName(lib)}</span>
|
|
|
|
|
{getInstalledAuthor(lib) && (
|
|
|
|
|
<span className="lib-item-author">by {getInstalledAuthor(lib)}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{getInstalledDesc(lib) && (
|
|
|
|
|
<p className="lib-item-desc">{getInstalledDesc(lib)}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="lib-item-actions">
|
|
|
|
|
{getInstalledVersion(lib) && (
|
|
|
|
|
<span className="lib-item-version lib-installed-badge">
|
|
|
|
|
{getInstalledVersion(lib)}
|
|
|
|
|
<svg style={{ display: 'inline', marginLeft: '4px', verticalAlign: 'middle' }} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|