644 lines
25 KiB
TypeScript
644 lines
25 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
searchLibraries,
|
|
installLibrary,
|
|
getInstalledLibraries,
|
|
getCustomLibraries,
|
|
deleteCustomLibrary,
|
|
} from '../../services/libraryService';
|
|
import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService';
|
|
import { trackInstallLibrary } from '../../utils/analytics';
|
|
import {
|
|
BUILTIN_MPY_MODULES,
|
|
FEATURED_MPY_PACKAGES,
|
|
fetchMpyPackage,
|
|
packageFilesInGroup,
|
|
searchMpyPackages,
|
|
writeFilesIntoGroup,
|
|
} from '../../services/micropythonLibs';
|
|
import type { MpyPackage } from '../../services/micropythonLibs';
|
|
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
|
import { boardDisplayName } from '../../types/board';
|
|
import './LibraryManagerModal.css';
|
|
|
|
interface LibraryManagerModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/** Case/separator-insensitive name match, matching the backend's _norm_lib_name
|
|
* so the UI's "in project" state agrees with what the compiler scopes. */
|
|
const normLib = (s: string): string => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
|
|
/** One row of the single unified list. A search result and an installed/custom
|
|
* library both normalise to this so they render identically. */
|
|
interface LibRow {
|
|
name: string;
|
|
version: string;
|
|
author: string;
|
|
desc: string;
|
|
installed: boolean;
|
|
custom: boolean;
|
|
releases?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Library Manager — ONE list, no tabs. Each row is state-aware:
|
|
* + Add to project (installs if needed, then declares it on this board)
|
|
* In project ✓ (click to remove from this board's libraries.json)
|
|
* Uninstall / Remove (free the cache / remove your custom upload)
|
|
*
|
|
* The per-board manifest (board.libraries) IS the compile scope and is what the
|
|
* read-only `libraries.json` file in the explorer shows. This modal is the only
|
|
* place that edits it.
|
|
*/
|
|
export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen, onClose }) => {
|
|
const { t } = useTranslation();
|
|
|
|
// Per-board manifest: the libraries.json edited here belongs to the ACTIVE
|
|
// board, so two boards in one project can scope to different libraries.
|
|
const boards = useSimulatorStore((s) => s.boards);
|
|
const activeBoardId = useSimulatorStore((s) => s.activeBoardId);
|
|
const updateBoard = useSimulatorStore((s) => s.updateBoard);
|
|
const activeBoard = boards.find((b) => b.id === activeBoardId) ?? boards[0];
|
|
const manifestLibs = activeBoard?.libraries ?? null;
|
|
const declared = manifestLibs ?? [];
|
|
|
|
// ── MicroPython mode (issue #214) ─────────────────────────────────────────
|
|
// Same modal, different backing: a MicroPython library is a .py file in
|
|
// the project, so search hits the micropython-lib index (backend proxy)
|
|
// and "Add to project" writes the package's files into this board's file
|
|
// group — no libraries.json, no arduino-cli.
|
|
const isMpy = activeBoard?.languageMode === 'micropython';
|
|
const [mpyResults, setMpyResults] = useState<MpyPackage[]>([]);
|
|
// Bumped after add/remove so the "In project" pills re-derive from the
|
|
// file group (which lives outside React state).
|
|
const [mpyTick, setMpyTick] = useState(0);
|
|
|
|
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 [busyLib, setBusyLib] = useState<string | null>(null); // install/uninstall in flight
|
|
const [selectedVersions, setSelectedVersions] = useState<Record<string, string>>({});
|
|
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(
|
|
null,
|
|
);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const setLibraries = useCallback(
|
|
(libs: string[] | null) => {
|
|
if (!activeBoard) return;
|
|
updateBoard(activeBoard.id, { libraries: libs && libs.length ? libs : undefined });
|
|
},
|
|
[activeBoard, updateBoard],
|
|
);
|
|
|
|
const inManifest = useCallback(
|
|
(name: string): boolean => declared.some((l) => normLib(l) === normLib(name)),
|
|
[declared],
|
|
);
|
|
const addToManifest = useCallback(
|
|
(name: string) => {
|
|
const clean = name.trim();
|
|
if (!clean) return;
|
|
const cur = manifestLibs ?? [];
|
|
if (cur.some((l) => normLib(l) === normLib(clean))) return;
|
|
setLibraries([...cur, clean]);
|
|
},
|
|
[manifestLibs, setLibraries],
|
|
);
|
|
const removeFromManifest = useCallback(
|
|
(name: string) => {
|
|
const cur = manifestLibs ?? [];
|
|
const next = cur.filter((l) => normLib(l) !== normLib(name));
|
|
setLibraries(next.length ? next : null);
|
|
},
|
|
[manifestLibs, setLibraries],
|
|
);
|
|
|
|
const isInstalled = useCallback(
|
|
(name: string): boolean =>
|
|
installedLibraries.some((il) => normLib(il.library?.name || il.name || '') === normLib(name)),
|
|
[installedLibraries],
|
|
);
|
|
|
|
const fetchInstalled = useCallback(async () => {
|
|
setLoadingInstalled(true);
|
|
try {
|
|
// The shared index libs PLUS the user's per-user custom uploads (which
|
|
// live in their per-user store). Custom first; de-duped by name.
|
|
const [libs, custom] = await Promise.all([getInstalledLibraries(), getCustomLibraries()]);
|
|
const customNames = new Set(custom.map((c) => (c.name || '').toLowerCase()));
|
|
setInstalledLibraries([
|
|
...custom,
|
|
...libs.filter((l) => !customNames.has((l.library?.name || l.name || '').toLowerCase())),
|
|
]);
|
|
} catch (e: unknown) {
|
|
setStatusMsg({
|
|
type: 'error',
|
|
text: e instanceof Error ? e.message : 'Failed to load installed libraries',
|
|
});
|
|
} finally {
|
|
setLoadingInstalled(false);
|
|
}
|
|
}, []);
|
|
|
|
// Reset transient state when the modal closes.
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setSearchQuery('');
|
|
setSearchResults([]);
|
|
setStatusMsg(null);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Load the installed/custom list whenever the modal opens.
|
|
useEffect(() => {
|
|
if (isOpen) fetchInstalled();
|
|
}, [isOpen, fetchInstalled]);
|
|
|
|
// Escape closes it, like any dialog. The overlay already closes on a
|
|
// backdrop click, but the toolbar button that OPENED this sits underneath
|
|
// the overlay — so someone who installs a library and reaches for it to get
|
|
// back to their sketch clicks a button that cannot be hit, and the editor
|
|
// reads as frozen. Escape is the exit everyone tries next.
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => document.removeEventListener('keydown', onKey);
|
|
}, [isOpen, onClose]);
|
|
|
|
// Search the index (debounced). With an empty query we BROWSE the installed
|
|
// list instead, so don't fire a search.
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
if (!searchQuery.trim()) {
|
|
setSearchResults([]);
|
|
setLoadingSearch(false);
|
|
return;
|
|
}
|
|
debounceRef.current = setTimeout(async () => {
|
|
setLoadingSearch(true);
|
|
setStatusMsg(null);
|
|
try {
|
|
if (isMpy) setMpyResults(await searchMpyPackages(searchQuery));
|
|
else setSearchResults(await searchLibraries(searchQuery));
|
|
} catch (e: unknown) {
|
|
setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Search failed' });
|
|
if (isMpy) setMpyResults([]);
|
|
else setSearchResults([]);
|
|
} finally {
|
|
setLoadingSearch(false);
|
|
}
|
|
}, 400);
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, [searchQuery, isOpen, isMpy]);
|
|
|
|
// A custom .zip upload (pro) lands in the user's per-user store; auto-declare
|
|
// it on the active board and refresh the list. The upload BUTTON is injected
|
|
// into .lib-modal-header by the pro overlay (libraryUploadInjector).
|
|
useEffect(() => {
|
|
const onUploaded = (e: Event) => {
|
|
const name = (e as CustomEvent).detail?.library;
|
|
if (name) addToManifest(name);
|
|
fetchInstalled();
|
|
};
|
|
window.addEventListener('velxio-custom-library-installed', onUploaded);
|
|
return () => window.removeEventListener('velxio-custom-library-installed', onUploaded);
|
|
}, [addToManifest, fetchInstalled]);
|
|
|
|
// ── actions ────────────────────────────────────────────────────────────────
|
|
const install = useCallback(
|
|
async (name: string): Promise<boolean> => {
|
|
setBusyLib(name);
|
|
setStatusMsg(null);
|
|
try {
|
|
const result = await installLibrary(name, selectedVersions[name]);
|
|
if (result.success) {
|
|
trackInstallLibrary(name);
|
|
fetchInstalled();
|
|
return true;
|
|
}
|
|
setStatusMsg({ type: 'error', text: result.error || `Failed to install "${name}"` });
|
|
return false;
|
|
} catch (e: unknown) {
|
|
setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Installation failed' });
|
|
return false;
|
|
} finally {
|
|
setBusyLib(null);
|
|
}
|
|
},
|
|
[selectedVersions, fetchInstalled],
|
|
);
|
|
|
|
// Primary action: install if needed, then declare on THIS board.
|
|
const addToProject = useCallback(
|
|
async (row: LibRow) => {
|
|
if (!row.installed) {
|
|
const ok = await install(row.name);
|
|
if (!ok) return;
|
|
}
|
|
addToManifest(row.name);
|
|
setStatusMsg({ type: 'success', text: `"${row.name}" added to ${activeBoard ? boardDisplayName(activeBoard) : 'this board'}.` });
|
|
},
|
|
[install, addToManifest, activeBoard],
|
|
);
|
|
|
|
// A CUSTOM lib lives in the user's per-user store, so removing it hits the
|
|
// per-user delete endpoint and also drops it from the manifest.
|
|
const removeCustom = useCallback(
|
|
async (name: string) => {
|
|
setBusyLib(name);
|
|
setStatusMsg(null);
|
|
try {
|
|
const result = await deleteCustomLibrary(name);
|
|
if (result.success) {
|
|
setStatusMsg({ type: 'success', text: `Removed your custom "${name}".` });
|
|
removeFromManifest(name);
|
|
fetchInstalled();
|
|
} else {
|
|
setStatusMsg({ type: 'error', text: result.error || `Failed to remove "${name}"` });
|
|
}
|
|
} finally {
|
|
setBusyLib(null);
|
|
}
|
|
},
|
|
[removeFromManifest, fetchInstalled],
|
|
);
|
|
|
|
// ── unified rows: search results when typing, else the installed/custom list ─
|
|
const rows: LibRow[] = useMemo(() => {
|
|
if (searchQuery.trim()) {
|
|
return searchResults.map((lib) => ({
|
|
name: lib.name || 'Unknown',
|
|
version: lib.latest?.version || lib.version || '',
|
|
author: lib.latest?.author || lib.author || '',
|
|
desc: lib.latest?.sentence || lib.sentence || '',
|
|
installed: isInstalled(lib.name || ''),
|
|
custom: false,
|
|
releases: lib.releases,
|
|
}));
|
|
}
|
|
return installedLibraries.map((lib) => ({
|
|
name: lib.library?.name || lib.name || 'Unknown',
|
|
version: lib.library?.version || lib.version || '',
|
|
author: lib.library?.author || lib.author || '',
|
|
desc: lib.library?.sentence || lib.sentence || '',
|
|
installed: true,
|
|
custom: !!lib.custom,
|
|
}));
|
|
}, [searchQuery, searchResults, installedLibraries, isInstalled]);
|
|
|
|
// ── MicroPython actions ───────────────────────────────────────────────────
|
|
const mpyGroupId = activeBoard?.activeFileGroupId ?? null;
|
|
const addMpyPackage = useCallback(
|
|
async (name: string) => {
|
|
if (!mpyGroupId) return;
|
|
setBusyLib(name);
|
|
setStatusMsg(null);
|
|
try {
|
|
const files = await fetchMpyPackage(name);
|
|
const written = writeFilesIntoGroup(mpyGroupId, files);
|
|
trackInstallLibrary(`mpy:${name}`);
|
|
setStatusMsg({
|
|
type: 'success',
|
|
text: `"${name}" added to the workspace: ${written.join(', ')}. main.py can import it now.`,
|
|
});
|
|
setMpyTick((n) => n + 1);
|
|
} catch (e: unknown) {
|
|
const detail =
|
|
(e as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
|
setStatusMsg({
|
|
type: 'error',
|
|
text: detail || (e instanceof Error ? e.message : `Failed to add "${name}"`),
|
|
});
|
|
} finally {
|
|
setBusyLib(null);
|
|
}
|
|
},
|
|
[mpyGroupId],
|
|
);
|
|
|
|
interface MpyRow {
|
|
name: string;
|
|
version: string;
|
|
desc: string;
|
|
builtin: boolean;
|
|
inProject: boolean;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- mpyTick re-derives from the file group
|
|
const mpyRows: MpyRow[] = useMemo(() => {
|
|
if (!isMpy || !mpyGroupId) return [];
|
|
const inProj = (name: string) => packageFilesInGroup(mpyGroupId, name);
|
|
if (searchQuery.trim()) {
|
|
const needle = searchQuery.trim().toLowerCase();
|
|
const builtins = BUILTIN_MPY_MODULES.filter((b) => b.name.includes(needle)).map((b) => ({
|
|
name: b.name,
|
|
version: '',
|
|
desc: b.blurb,
|
|
builtin: true,
|
|
inProject: false,
|
|
}));
|
|
return builtins.concat(
|
|
mpyResults.map((p) => ({
|
|
name: p.name,
|
|
version: p.version,
|
|
desc: p.description,
|
|
builtin: false,
|
|
inProject: inProj(p.name),
|
|
})),
|
|
);
|
|
}
|
|
return FEATURED_MPY_PACKAGES.map((f) => ({
|
|
name: f.name,
|
|
version: '',
|
|
desc: f.blurb,
|
|
builtin: false,
|
|
inProject: inProj(f.name),
|
|
})).concat(
|
|
BUILTIN_MPY_MODULES.map((b) => ({
|
|
name: b.name,
|
|
version: '',
|
|
desc: b.blurb,
|
|
builtin: true,
|
|
inProject: false,
|
|
})),
|
|
);
|
|
}, [isMpy, mpyGroupId, searchQuery, mpyResults, mpyTick]);
|
|
|
|
if (!isOpen) return null;
|
|
const browsing = !searchQuery.trim();
|
|
|
|
// Portaled to <body>: the toolbar ancestor has container-type
|
|
// (container queries), which turns it into the containing block for
|
|
// position:fixed and shrank the overlay to the toolbar's box.
|
|
return createPortal(
|
|
<div className="lib-modal-overlay" onClick={onClose}>
|
|
<div className="lib-modal" onClick={(e) => e.stopPropagation()}>
|
|
{/* Header — the pro custom-upload button injects into .lib-modal-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>{t('editor.libraryManager.title')}</span>
|
|
{activeBoard && (
|
|
<span
|
|
title={
|
|
isMpy
|
|
? 'MicroPython: a library is a .py file in this board\'s workspace'
|
|
: 'Libraries apply to this board (its libraries.json = compile scope)'
|
|
}
|
|
style={{
|
|
marginLeft: 8,
|
|
fontSize: 11,
|
|
color: '#a5d6a7',
|
|
background: '#1b3a1e',
|
|
border: '1px solid #2e7d32',
|
|
borderRadius: 10,
|
|
padding: '1px 8px',
|
|
}}
|
|
>
|
|
{boardDisplayName(activeBoard)}
|
|
{isMpy ? ' · MicroPython' : ` · ${declared.length}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button className="lib-close-btn" onClick={onClose}>
|
|
<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>
|
|
|
|
{/* Search */}
|
|
<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={t('editor.libraryManager.filterPlaceholder')}
|
|
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>
|
|
|
|
{/* Status */}
|
|
{statusMsg && <div className={`lib-status ${statusMsg.type}`}>{statusMsg.text}</div>}
|
|
|
|
{/* Single unified list */}
|
|
<div className="lib-content">
|
|
<div className="lib-list">
|
|
{isMpy && browsing && (
|
|
<div className="lib-empty" style={{ padding: '10px 14px 4px' }}>
|
|
<p className="lib-empty-sub" style={{ margin: 0 }}>
|
|
A MicroPython library is a .py file in the project — adding one
|
|
writes it into this board's workspace, next to main.py. Search
|
|
the official micropython-lib index above.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{isMpy &&
|
|
mpyRows.map((row) => {
|
|
const busy = busyLib === row.name;
|
|
return (
|
|
<div key={`mpy-${row.name}`} className="lib-item">
|
|
<div className="lib-item-info">
|
|
<div className="lib-item-header">
|
|
<span className="lib-item-name">{row.name}</span>
|
|
{row.builtin && (
|
|
<span
|
|
style={{
|
|
fontSize: 10,
|
|
color: '#8ecae6',
|
|
background: '#12303f',
|
|
borderRadius: 6,
|
|
padding: '0 6px',
|
|
}}
|
|
>
|
|
built into the firmware
|
|
</span>
|
|
)}
|
|
</div>
|
|
{row.desc && <p className="lib-item-desc">{row.desc}</p>}
|
|
</div>
|
|
<div className="lib-item-actions">
|
|
{row.version && <span className="lib-item-version">{row.version}</span>}
|
|
{row.builtin ? (
|
|
<span className="lib-item-version" title="No install needed — just import it">
|
|
import {row.name}
|
|
</span>
|
|
) : row.inProject ? (
|
|
<span
|
|
className="lib-uninstall-btn"
|
|
style={{ color: '#a5d6a7', borderColor: '#2e7d32', cursor: 'default' }}
|
|
title="Its .py file is in the workspace — edit or delete it from the file explorer"
|
|
>
|
|
In project ✓
|
|
</span>
|
|
) : (
|
|
<button
|
|
className="lib-install-btn"
|
|
onClick={() => addMpyPackage(row.name)}
|
|
disabled={busy}
|
|
title="Write this package's .py files into the board's workspace"
|
|
>
|
|
{busy ? '…' : '+ Add to project'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{isMpy && !browsing && !loadingSearch && mpyRows.length === 0 && (
|
|
<div className="lib-empty">
|
|
<p>{t('editor.libraryManager.noResultsFor', { query: searchQuery })}</p>
|
|
<p className="lib-empty-sub">
|
|
Not in micropython-lib? Paste the driver as a .py file in the
|
|
workspace — that is all an install does.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{!isMpy && browsing && loadingInstalled && (
|
|
<div className="lib-empty">
|
|
<p>{t('editor.libraryManager.loadingInstalled')}</p>
|
|
</div>
|
|
)}
|
|
{!isMpy && !loadingSearch && rows.length === 0 && !(browsing && loadingInstalled) && (
|
|
<div className="lib-empty">
|
|
<p>
|
|
{browsing
|
|
? t('editor.libraryManager.noneInstalled')
|
|
: searchQuery
|
|
? t('editor.libraryManager.noResultsFor', { query: searchQuery })
|
|
: t('editor.libraryManager.noResults')}
|
|
</p>
|
|
{browsing && <p className="lib-empty-sub">Search above to find and add a library.</p>}
|
|
</div>
|
|
)}
|
|
{!isMpy && rows.map((row, i) => {
|
|
const inProj = inManifest(row.name);
|
|
const busy = busyLib === row.name;
|
|
return (
|
|
<div key={`${row.name}-${i}`} className="lib-item">
|
|
<div className="lib-item-info">
|
|
<div className="lib-item-header">
|
|
<span className="lib-item-name">{row.name}</span>
|
|
{row.custom && (
|
|
<span
|
|
style={{
|
|
fontSize: 10,
|
|
color: '#ffd60a',
|
|
background: '#3a330f',
|
|
borderRadius: 6,
|
|
padding: '0 6px',
|
|
}}
|
|
>
|
|
custom
|
|
</span>
|
|
)}
|
|
{row.author && (
|
|
<span className="lib-item-author">
|
|
{t('editor.libraryManager.byAuthor', { author: row.custom ? 'you' : row.author })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{row.desc && <p className="lib-item-desc">{row.desc}</p>}
|
|
</div>
|
|
<div className="lib-item-actions">
|
|
{row.version && <span className="lib-item-version">{row.version}</span>}
|
|
{!row.installed && row.releases && Object.keys(row.releases).length > 1 && (
|
|
<select
|
|
className="lib-version-select"
|
|
value={selectedVersions[row.name] ?? row.version}
|
|
onChange={(e) => setSelectedVersions((p) => ({ ...p, [row.name]: e.target.value }))}
|
|
>
|
|
{Object.keys(row.releases).map((ver) => (
|
|
<option key={ver} value={ver}>
|
|
{ver}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{inProj ? (
|
|
<button
|
|
className="lib-uninstall-btn"
|
|
style={{ color: '#a5d6a7', borderColor: '#2e7d32' }}
|
|
onClick={() => removeFromManifest(row.name)}
|
|
title="Remove from this board (libraries.json)"
|
|
>
|
|
In project ✓
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="lib-install-btn"
|
|
onClick={() => addToProject(row)}
|
|
disabled={busy}
|
|
title="Install if needed and add to this board"
|
|
>
|
|
{busy ? '…' : '+ Add to project'}
|
|
</button>
|
|
)}
|
|
{/* Only CUSTOM uploads can be removed (per-user store). Index
|
|
libraries live in the shared content-addressed cache — you
|
|
add/remove them from THIS project, but never "uninstall" a
|
|
copy everyone shares, so no Uninstall button for them. */}
|
|
{row.custom && (
|
|
<button
|
|
className="lib-uninstall-btn"
|
|
onClick={() => removeCustom(row.name)}
|
|
disabled={busy}
|
|
title="Remove your custom upload"
|
|
>
|
|
{busy ? '…' : 'Remove'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
};
|