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