refactor(pro): simplify PluginMarketplace component (503→310 lines)

This commit is contained in:
Hibryda 2026-03-17 02:22:20 +01:00
parent 5300c09157
commit 19771237c9

View file

@ -1,31 +1,23 @@
// SPDX-License-Identifier: LicenseRef-Commercial
<script lang="ts">
import {
proMarketplaceFetchCatalog,
proMarketplaceInstalled,
proMarketplaceInstall,
proMarketplaceUninstall,
proMarketplaceCheckUpdates,
proMarketplaceUpdate,
type CatalogPlugin,
type InstalledPlugin,
proMarketplaceFetchCatalog, proMarketplaceInstalled, proMarketplaceInstall,
proMarketplaceUninstall, proMarketplaceCheckUpdates, proMarketplaceUpdate,
type CatalogPlugin, type InstalledPlugin,
} from './pro-bridge';
type Tab = 'browse' | 'installed';
let tab = $state<Tab>('browse');
let search = $state('');
let toast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
// Browse state
let catalog = $state<CatalogPlugin[]>([]);
let catalogLoading = $state(true);
let catalogError = $state<string | null>(null);
let selectedPlugin = $state<CatalogPlugin | null>(null);
let installing = $state<Set<string>>(new Set());
// Installed state
let installed = $state<InstalledPlugin[]>([]);
let installedLoading = $state(true);
let installedError = $state<string | null>(null);
@ -35,16 +27,12 @@
let confirmUninstall = $state<string | null>(null);
let installedIds = $derived(new Set(installed.map((p) => p.id)));
let filtered = $derived(
catalog.filter((p) => {
if (!search) return true;
const q = search.toLowerCase();
return (
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
(p.tags ?? []).some((t) => t.toLowerCase().includes(q))
);
return p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q)
|| (p.tags ?? []).some((t) => t.toLowerCase().includes(q));
})
);
@ -54,116 +42,86 @@
toastTimer = setTimeout(() => (toast = null), 3000);
}
function mutSet(s: Set<string>, id: string, add: boolean): Set<string> {
const n = new Set(s);
add ? n.add(id) : n.delete(id);
return n;
}
async function loadCatalog() {
catalogLoading = true;
catalogError = null;
try {
catalog = await proMarketplaceFetchCatalog();
} catch (e) {
catalogError = e instanceof Error ? e.message : String(e);
} finally {
catalogLoading = false;
}
catalogLoading = true; catalogError = null;
try { catalog = await proMarketplaceFetchCatalog(); }
catch (e) { catalogError = e instanceof Error ? e.message : String(e); }
finally { catalogLoading = false; }
}
async function loadInstalled() {
installedLoading = true;
installedError = null;
try {
installed = await proMarketplaceInstalled();
} catch (e) {
installedError = e instanceof Error ? e.message : String(e);
} finally {
installedLoading = false;
}
installedLoading = true; installedError = null;
try { installed = await proMarketplaceInstalled(); }
catch (e) { installedError = e instanceof Error ? e.message : String(e); }
finally { installedLoading = false; }
}
async function handleInstall(pluginId: string) {
installing = new Set([...installing, pluginId]);
async function handleInstall(id: string) {
installing = mutSet(installing, id, true);
try {
const result = await proMarketplaceInstall(pluginId);
installed = [...installed, result];
showToast(`Installed ${result.name} v${result.version}`);
} catch (e) {
showToast(`Install failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
const next = new Set(installing);
next.delete(pluginId);
installing = next;
}
const r = await proMarketplaceInstall(id);
installed = [...installed, r];
showToast(`Installed ${r.name} v${r.version}`);
} catch (e) { showToast(`Install failed: ${e instanceof Error ? e.message : String(e)}`); }
finally { installing = mutSet(installing, id, false); }
}
async function handleUninstall(pluginId: string) {
async function handleUninstall(id: string) {
confirmUninstall = null;
uninstalling = new Set([...uninstalling, pluginId]);
const name = installed.find((p) => p.id === pluginId)?.name ?? pluginId;
uninstalling = mutSet(uninstalling, id, true);
const name = installed.find((p) => p.id === id)?.name ?? id;
try {
await proMarketplaceUninstall(pluginId);
installed = installed.filter((p) => p.id !== pluginId);
await proMarketplaceUninstall(id);
installed = installed.filter((p) => p.id !== id);
showToast(`Uninstalled ${name}`);
} catch (e) {
showToast(`Uninstall failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
const next = new Set(uninstalling);
next.delete(pluginId);
uninstalling = next;
}
} catch (e) { showToast(`Uninstall failed: ${e instanceof Error ? e.message : String(e)}`); }
finally { uninstalling = mutSet(uninstalling, id, false); }
}
async function handleCheckUpdates() {
checking = true;
try {
const updated = await proMarketplaceCheckUpdates();
installed = updated;
const count = updated.filter((p) => p.hasUpdate).length;
showToast(count > 0 ? `${count} update(s) available` : 'All plugins up to date');
} catch (e) {
showToast(`Check failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
checking = false;
}
const u = await proMarketplaceCheckUpdates();
installed = u;
const c = u.filter((p) => p.hasUpdate).length;
showToast(c > 0 ? `${c} update(s) available` : 'All plugins up to date');
} catch (e) { showToast(`Check failed: ${e instanceof Error ? e.message : String(e)}`); }
finally { checking = false; }
}
async function handleUpdate(pluginId: string) {
updating = new Set([...updating, pluginId]);
async function handleUpdate(id: string) {
updating = mutSet(updating, id, true);
try {
const result = await proMarketplaceUpdate(pluginId);
installed = installed.map((p) => (p.id === pluginId ? result : p));
showToast(`Updated ${result.name} to v${result.version}`);
} catch (e) {
showToast(`Update failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
const next = new Set(updating);
next.delete(pluginId);
updating = next;
}
const r = await proMarketplaceUpdate(id);
installed = installed.map((p) => (p.id === id ? r : p));
showToast(`Updated ${r.name} to v${r.version}`);
} catch (e) { showToast(`Update failed: ${e instanceof Error ? e.message : String(e)}`); }
finally { updating = mutSet(updating, id, false); }
}
function fmtDownloads(n: number | null): string {
function fmtDl(n: number | null): string {
if (n == null) return '';
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
function stars(rating: number | null): string {
if (rating == null) return '';
const full = Math.round(rating);
return '\u2605'.repeat(full) + '\u2606'.repeat(5 - full);
function stars(r: number | null): string {
if (r == null) return '';
const f = Math.round(r);
return '\u2605'.repeat(f) + '\u2606'.repeat(5 - f);
}
$effect(() => {
loadCatalog();
loadInstalled();
});
$effect(() => { loadCatalog(); loadInstalled(); });
</script>
<div class="mp-root">
<!-- Toast -->
{#if toast}
<div class="mp-toast">{toast}</div>
{/if}
{#if toast}<div class="mp-toast">{toast}</div>{/if}
<!-- Tabs -->
<div class="mp-tabs">
<button class="mp-tab" class:active={tab === 'browse'} onclick={() => (tab = 'browse')}>Browse</button>
<button class="mp-tab" class:active={tab === 'installed'} onclick={() => (tab = 'installed')}>
@ -171,64 +129,44 @@
</button>
</div>
<!-- Browse Tab -->
{#if tab === 'browse'}
<div class="mp-search">
<input type="text" placeholder="Search plugins..." bind:value={search} class="mp-search-input" />
<input type="text" placeholder="Search plugins..." bind:value={search} class="mp-input" />
</div>
{#if catalogLoading}
<div class="mp-state">Loading catalog...</div>
{:else if catalogError}
<div class="mp-state mp-error">{catalogError}</div>
<div class="mp-state mp-err">{catalogError}</div>
{:else if filtered.length === 0}
<div class="mp-state">{search ? 'No plugins match your search' : 'No plugins available'}</div>
{:else}
<div class="mp-grid">
{#each filtered as plugin (plugin.id)}
<div class="mp-card" onclick={() => (selectedPlugin = selectedPlugin?.id === plugin.id ? null : plugin)}>
<div class="mp-card-header">
<span class="mp-card-name">{plugin.name}</span>
<span class="mp-card-version">v{plugin.version}</span>
{#each filtered as p (p.id)}
<div class="mp-card" onclick={() => (selectedPlugin = selectedPlugin?.id === p.id ? null : p)}>
<div class="mp-row">
<span class="mp-name">{p.name}</span>
<span class="mp-ver">v{p.version}</span>
</div>
<div class="mp-card-author">by {plugin.author}</div>
<div class="mp-card-desc">{plugin.description}</div>
<div class="mp-card-meta">
{#if plugin.tags}
<div class="mp-card-tags">
{#each plugin.tags.slice(0, 3) as t}
<span class="mp-tag">{t}</span>
{/each}
</div>
{/if}
<div class="mp-card-stats">
{#if plugin.downloads != null}<span>{fmtDownloads(plugin.downloads)} dl</span>{/if}
{#if plugin.rating != null}<span class="mp-stars">{stars(plugin.rating)}</span>{/if}
<div class="mp-author">by {p.author}</div>
<div class="mp-desc">{p.description}</div>
<div class="mp-meta">
{#if p.tags}<div class="mp-tags">{#each p.tags.slice(0, 3) as t}<span class="mp-tag">{t}</span>{/each}</div>{/if}
<div class="mp-stats">
{#if p.downloads != null}<span>{fmtDl(p.downloads)} dl</span>{/if}
{#if p.rating != null}<span class="mp-stars">{stars(p.rating)}</span>{/if}
</div>
</div>
{#if plugin.permissions.length > 0}
<div class="mp-card-perms">
{#each plugin.permissions.slice(0, 3) as perm}
<span class="mp-perm">{perm}</span>
{/each}
{#if plugin.permissions.length > 3}
<span class="mp-perm">+{plugin.permissions.length - 3}</span>
{/if}
{#if p.permissions.length > 0}
<div class="mp-perms">
{#each p.permissions.slice(0, 3) as pm}<span class="mp-perm">{pm}</span>{/each}
{#if p.permissions.length > 3}<span class="mp-perm">+{p.permissions.length - 3}</span>{/if}
</div>
{/if}
<div class="mp-card-actions">
<button
class="mp-btn mp-btn-install"
disabled={installedIds.has(plugin.id) || installing.has(plugin.id)}
onclick={(e) => { e.stopPropagation(); handleInstall(plugin.id); }}
>
{#if installing.has(plugin.id)}
Installing...
{:else if installedIds.has(plugin.id)}
Installed
{:else}
Install
{/if}
<div class="mp-actions">
<button class="mp-btn primary" disabled={installedIds.has(p.id) || installing.has(p.id)}
onclick={(e) => { e.stopPropagation(); handleInstall(p.id); }}>
{installing.has(p.id) ? 'Installing...' : installedIds.has(p.id) ? 'Installed' : 'Install'}
</button>
</div>
</div>
@ -236,35 +174,34 @@
</div>
{/if}
<!-- Detail Panel -->
{#if selectedPlugin}
{@const sp = selectedPlugin}
<div class="mp-detail">
<div class="mp-detail-header">
<h3 class="mp-detail-name">{selectedPlugin.name}</h3>
<button class="mp-btn-close" onclick={() => (selectedPlugin = null)}>x</button>
<div class="mp-detail-hdr">
<h3 class="mp-detail-name">{sp.name}</h3>
<button class="mp-x" onclick={() => (selectedPlugin = null)}>x</button>
</div>
<p class="mp-detail-desc">{selectedPlugin.description}</p>
<div class="mp-detail-fields">
<div class="mp-field"><span class="mp-field-label">Version</span> {selectedPlugin.version}</div>
<div class="mp-field"><span class="mp-field-label">Author</span> {selectedPlugin.author}</div>
{#if selectedPlugin.license}<div class="mp-field"><span class="mp-field-label">License</span> {selectedPlugin.license}</div>{/if}
{#if selectedPlugin.sizeBytes != null}<div class="mp-field"><span class="mp-field-label">Size</span> {(selectedPlugin.sizeBytes / 1024).toFixed(0)} KB</div>{/if}
{#if selectedPlugin.minAgorVersion}<div class="mp-field"><span class="mp-field-label">Min Version</span> {selectedPlugin.minAgorVersion}</div>{/if}
<p class="mp-detail-desc">{sp.description}</p>
<div class="mp-fields">
<span><b>Version</b> {sp.version}</span>
<span><b>Author</b> {sp.author}</span>
{#if sp.license}<span><b>License</b> {sp.license}</span>{/if}
{#if sp.sizeBytes != null}<span><b>Size</b> {(sp.sizeBytes / 1024).toFixed(0)} KB</span>{/if}
{#if sp.minAgorVersion}<span><b>Min Ver</b> {sp.minAgorVersion}</span>{/if}
</div>
{#if selectedPlugin.permissions.length > 0}
<div class="mp-detail-section">Permissions</div>
<div class="mp-detail-perms">{#each selectedPlugin.permissions as p}<span class="mp-perm">{p}</span>{/each}</div>
{#if sp.permissions.length > 0}
<div class="mp-section-hd">Permissions</div>
<div class="mp-perms">{#each sp.permissions as pm}<span class="mp-perm">{pm}</span>{/each}</div>
{/if}
<div class="mp-detail-links">
{#if selectedPlugin.homepage}<a href={selectedPlugin.homepage} target="_blank" rel="noopener" class="mp-link">Homepage</a>{/if}
{#if selectedPlugin.repository}<a href={selectedPlugin.repository} target="_blank" rel="noopener" class="mp-link">Repository</a>{/if}
<div class="mp-links">
{#if sp.homepage}<a href={sp.homepage} target="_blank" rel="noopener" class="mp-link">Homepage</a>{/if}
{#if sp.repository}<a href={sp.repository} target="_blank" rel="noopener" class="mp-link">Repository</a>{/if}
</div>
</div>
{/if}
<!-- Installed Tab -->
{:else}
<div class="mp-installed-bar">
<div class="mp-bar">
<button class="mp-btn" onclick={handleCheckUpdates} disabled={checking}>
{checking ? 'Checking...' : 'Check for Updates'}
</button>
@ -273,44 +210,39 @@
{#if installedLoading}
<div class="mp-state">Loading installed plugins...</div>
{:else if installedError}
<div class="mp-state mp-error">{installedError}</div>
<div class="mp-state mp-err">{installedError}</div>
{:else if installed.length === 0}
<div class="mp-state">No plugins installed. Browse the catalog to get started.</div>
{:else}
<div class="mp-installed-list">
{#each installed as plugin (plugin.id)}
<div class="mp-installed-item">
<div class="mp-installed-info">
<div class="mp-installed-top">
<span class="mp-card-name">{plugin.name}</span>
<span class="mp-card-version">v{plugin.version}</span>
{#if plugin.hasUpdate && plugin.latestVersion}
<span class="mp-update-badge">v{plugin.latestVersion} available</span>
{/if}
<div class="mp-list">
{#each installed as p (p.id)}
<div class="mp-item">
<div class="mp-item-info">
<div class="mp-row">
<span class="mp-name">{p.name}</span>
<span class="mp-ver">v{p.version}</span>
{#if p.hasUpdate && p.latestVersion}<span class="mp-upd-badge">v{p.latestVersion} available</span>{/if}
</div>
<div class="mp-card-author">by {plugin.author}</div>
<div class="mp-card-desc">{plugin.description}</div>
{#if plugin.permissions.length > 0}
<div class="mp-card-perms">
{#each plugin.permissions as perm}<span class="mp-perm">{perm}</span>{/each}
</div>
<div class="mp-author">by {p.author}</div>
<div class="mp-desc">{p.description}</div>
{#if p.permissions.length > 0}
<div class="mp-perms">{#each p.permissions as pm}<span class="mp-perm">{pm}</span>{/each}</div>
{/if}
</div>
<div class="mp-installed-actions">
{#if plugin.hasUpdate}
<button class="mp-btn mp-btn-install" disabled={updating.has(plugin.id)} onclick={() => handleUpdate(plugin.id)}>
{updating.has(plugin.id) ? 'Updating...' : 'Update'}
<div class="mp-item-acts">
{#if p.hasUpdate}
<button class="mp-btn primary" disabled={updating.has(p.id)} onclick={() => handleUpdate(p.id)}>
{updating.has(p.id) ? 'Updating...' : 'Update'}
</button>
{/if}
{#if confirmUninstall === plugin.id}
<button class="mp-btn mp-btn-danger" onclick={() => handleUninstall(plugin.id)}>Confirm</button>
{#if confirmUninstall === p.id}
<button class="mp-btn danger" onclick={() => handleUninstall(p.id)}>Confirm</button>
<button class="mp-btn" onclick={() => (confirmUninstall = null)}>Cancel</button>
{:else}
<button
class="mp-btn mp-btn-danger"
disabled={uninstalling.has(plugin.id)}
onclick={() => (confirmUninstall = plugin.id)}
>{uninstalling.has(plugin.id) ? 'Removing...' : 'Uninstall'}</button>
<button class="mp-btn danger" disabled={uninstalling.has(p.id)}
onclick={() => (confirmUninstall = p.id)}>
{uninstalling.has(p.id) ? 'Removing...' : 'Uninstall'}
</button>
{/if}
</div>
</div>
@ -321,183 +253,58 @@
</div>
<style>
.mp-root {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
height: 100%;
overflow-y: auto;
color: var(--ctp-text);
font-family: var(--ui-font-family, system-ui, sans-serif);
font-size: var(--ui-font-size, 0.8125rem);
}
.mp-toast {
position: fixed;
top: 0.75rem;
right: 0.75rem;
padding: 0.5rem 1rem;
background: var(--ctp-surface1);
color: var(--ctp-text);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
font-size: 0.75rem;
z-index: 999;
animation: mp-fade-in 0.15s ease-out;
}
@keyframes mp-fade-in { from { opacity: 0; transform: translateY(-0.5rem); } }
.mp-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--ctp-surface0);
padding-bottom: 0.25rem;
}
.mp-tab {
padding: 0.375rem 0.75rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--ctp-subtext0);
cursor: pointer;
font-size: 0.8125rem;
}
.mp-root { position: relative; display: flex; flex-direction: column; gap: 0.75rem; padding: 0.75rem; height: 100%; overflow-y: auto; color: var(--ctp-text); font-family: var(--ui-font-family, system-ui, sans-serif); font-size: var(--ui-font-size, 0.8125rem); }
.mp-toast { position: fixed; top: 0.75rem; right: 0.75rem; padding: 0.5rem 1rem; background: var(--ctp-surface1); color: var(--ctp-text); border: 1px solid var(--ctp-surface2); border-radius: 0.375rem; font-size: 0.75rem; z-index: 999; animation: mp-fi 0.15s ease-out; }
@keyframes mp-fi { from { opacity: 0; transform: translateY(-0.5rem); } }
.mp-tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--ctp-surface0); padding-bottom: 0.25rem; }
.mp-tab { padding: 0.375rem 0.75rem; background: none; border: none; border-bottom: 2px solid transparent; color: var(--ctp-subtext0); cursor: pointer; font-size: 0.8125rem; }
.mp-tab:hover { color: var(--ctp-text); }
.mp-tab.active { color: var(--ctp-blue); border-bottom-color: var(--ctp-blue); }
.mp-search { display: flex; }
.mp-search-input {
flex: 1;
padding: 0.375rem 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
color: var(--ctp-text);
font-size: 0.8125rem;
outline: none;
}
.mp-search-input::placeholder { color: var(--ctp-overlay0); }
.mp-search-input:focus { border-color: var(--ctp-blue); }
.mp-input { flex: 1; padding: 0.375rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.375rem; color: var(--ctp-text); font-size: 0.8125rem; outline: none; }
.mp-input::placeholder { color: var(--ctp-overlay0); }
.mp-input:focus { border-color: var(--ctp-blue); }
.mp-state { padding: 2rem; text-align: center; color: var(--ctp-subtext0); }
.mp-error { color: var(--ctp-red); }
.mp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.5rem;
}
.mp-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
cursor: pointer;
transition: border-color 0.1s;
}
.mp-err { color: var(--ctp-red); }
.mp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: 0.5rem; }
.mp-card { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.375rem; cursor: pointer; transition: border-color 0.1s; }
.mp-card:hover { border-color: var(--ctp-blue); }
.mp-card-header { display: flex; align-items: baseline; gap: 0.375rem; }
.mp-card-name { font-weight: 600; color: var(--ctp-text); }
.mp-card-version { font-size: 0.6875rem; color: var(--ctp-overlay0); font-family: monospace; }
.mp-card-author { font-size: 0.6875rem; color: var(--ctp-subtext0); }
.mp-card-desc {
font-size: 0.75rem;
color: var(--ctp-subtext1);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mp-card-meta { display: flex; justify-content: space-between; align-items: center; gap: 0.25rem; }
.mp-card-tags { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.mp-tag {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
color: var(--ctp-blue);
border-radius: 0.25rem;
}
.mp-card-stats { display: flex; gap: 0.5rem; font-size: 0.6875rem; color: var(--ctp-overlay1); }
.mp-row { display: flex; align-items: baseline; gap: 0.375rem; }
.mp-name { font-weight: 600; color: var(--ctp-text); }
.mp-ver { font-size: 0.6875rem; color: var(--ctp-overlay0); font-family: monospace; }
.mp-author { font-size: 0.6875rem; color: var(--ctp-subtext0); }
.mp-desc { font-size: 0.75rem; color: var(--ctp-subtext1); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mp-meta { display: flex; justify-content: space-between; align-items: center; gap: 0.25rem; }
.mp-tags { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.mp-tag { font-size: 0.625rem; padding: 0.0625rem 0.375rem; background: color-mix(in srgb, var(--ctp-blue) 15%, transparent); color: var(--ctp-blue); border-radius: 0.25rem; }
.mp-stats { display: flex; gap: 0.5rem; font-size: 0.6875rem; color: var(--ctp-overlay1); }
.mp-stars { color: var(--ctp-yellow); letter-spacing: 0.05em; }
.mp-card-perms { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.mp-perm {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
color: var(--ctp-peach);
border-radius: 0.25rem;
}
.mp-card-actions { margin-top: 0.25rem; display: flex; justify-content: flex-end; }
.mp-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
background: var(--ctp-surface0);
color: var(--ctp-subtext0);
cursor: pointer;
font-size: 0.75rem;
}
.mp-perms { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.mp-perm { font-size: 0.625rem; padding: 0.0625rem 0.375rem; background: color-mix(in srgb, var(--ctp-peach) 12%, transparent); color: var(--ctp-peach); border-radius: 0.25rem; }
.mp-actions { margin-top: 0.25rem; display: flex; justify-content: flex-end; }
.mp-btn { padding: 0.25rem 0.75rem; border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; background: var(--ctp-surface0); color: var(--ctp-subtext0); cursor: pointer; font-size: 0.75rem; }
.mp-btn:hover:not(:disabled) { background: var(--ctp-surface1); color: var(--ctp-text); }
.mp-btn:disabled { opacity: 0.4; cursor: default; }
.mp-btn-install {
background: var(--ctp-blue);
color: var(--ctp-base);
border-color: var(--ctp-blue);
}
.mp-btn-install:hover:not(:disabled) { opacity: 0.9; background: var(--ctp-blue); color: var(--ctp-base); }
.mp-btn-danger { color: var(--ctp-red); border-color: var(--ctp-red); background: none; }
.mp-btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
color: var(--ctp-red);
}
.mp-btn-close {
background: none;
border: none;
color: var(--ctp-overlay0);
cursor: pointer;
font-size: 1rem;
padding: 0.125rem 0.375rem;
}
.mp-btn-close:hover { color: var(--ctp-text); }
.mp-detail {
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mp-detail-header { display: flex; justify-content: space-between; align-items: center; }
.mp-btn.primary { background: var(--ctp-blue); color: var(--ctp-base); border-color: var(--ctp-blue); }
.mp-btn.primary:hover:not(:disabled) { opacity: 0.9; background: var(--ctp-blue); color: var(--ctp-base); }
.mp-btn.danger { color: var(--ctp-red); border-color: var(--ctp-red); background: none; }
.mp-btn.danger:hover:not(:disabled) { background: color-mix(in srgb, var(--ctp-red) 12%, transparent); color: var(--ctp-red); }
.mp-x { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 1rem; padding: 0.125rem 0.375rem; }
.mp-x:hover { color: var(--ctp-text); }
.mp-detail { background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.375rem; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
.mp-detail-hdr { display: flex; justify-content: space-between; align-items: center; }
.mp-detail-name { margin: 0; font-size: 1rem; font-weight: 600; color: var(--ctp-text); }
.mp-detail-desc { font-size: 0.8125rem; color: var(--ctp-subtext1); margin: 0; line-height: 1.5; }
.mp-detail-fields { display: flex; flex-wrap: wrap; gap: 0.375rem 1rem; font-size: 0.75rem; color: var(--ctp-subtext1); }
.mp-field-label { color: var(--ctp-subtext0); margin-right: 0.25rem; }
.mp-detail-section { font-size: 0.6875rem; color: var(--ctp-subtext0); text-transform: uppercase; letter-spacing: 0.04em; }
.mp-detail-perms { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.mp-detail-links { display: flex; gap: 0.75rem; }
.mp-fields { display: flex; flex-wrap: wrap; gap: 0.375rem 1rem; font-size: 0.75rem; color: var(--ctp-subtext1); }
.mp-fields b { color: var(--ctp-subtext0); font-weight: 500; margin-right: 0.25rem; }
.mp-section-hd { font-size: 0.6875rem; color: var(--ctp-subtext0); text-transform: uppercase; letter-spacing: 0.04em; }
.mp-links { display: flex; gap: 0.75rem; }
.mp-link { font-size: 0.75rem; color: var(--ctp-blue); text-decoration: none; }
.mp-link:hover { text-decoration: underline; }
.mp-installed-bar { display: flex; justify-content: flex-end; }
.mp-installed-list { display: flex; flex-direction: column; gap: 0.375rem; }
.mp-installed-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
}
.mp-installed-info { flex: 1; display: flex; flex-direction: column; gap: 0.25rem; }
.mp-installed-top { display: flex; align-items: baseline; gap: 0.375rem; }
.mp-update-badge {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
color: var(--ctp-green);
border-radius: 0.25rem;
}
.mp-installed-actions { display: flex; gap: 0.375rem; flex-shrink: 0; align-items: center; }
.mp-bar { display: flex; justify-content: flex-end; }
.mp-list { display: flex; flex-direction: column; gap: 0.375rem; }
.mp-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; padding: 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.375rem; }
.mp-item-info { flex: 1; display: flex; flex-direction: column; gap: 0.25rem; }
.mp-upd-badge { font-size: 0.625rem; padding: 0.0625rem 0.375rem; background: color-mix(in srgb, var(--ctp-green) 15%, transparent); color: var(--ctp-green); border-radius: 0.25rem; }
.mp-item-acts { display: flex; gap: 0.375rem; flex-shrink: 0; align-items: center; }
</style>