feat(pro): add commercial Svelte components
AnalyticsDashboard (period selector, summary cards, SVG bar chart, model table), SessionExporter (session/project report generation, clipboard copy), AccountSwitcher (account list, active indicator, hot-switch). All use Svelte 5 runes, --ctp-* theme vars, plugin:agor-pro IPC.
This commit is contained in:
parent
fc6b306a5c
commit
a98d061b04
3 changed files with 738 additions and 0 deletions
229
src/lib/commercial/AccountSwitcher.svelte
Normal file
229
src/lib/commercial/AccountSwitcher.svelte
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
<script lang="ts">
|
||||
import {
|
||||
proListAccounts,
|
||||
proSetActiveAccount,
|
||||
type AccountProfile,
|
||||
} from './pro-bridge';
|
||||
|
||||
let accounts = $state<AccountProfile[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let switching = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
accounts = await proListAccounts();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTo(profileId: string) {
|
||||
switching = profileId;
|
||||
error = null;
|
||||
try {
|
||||
await proSetActiveAccount(profileId);
|
||||
accounts = accounts.map((a) => ({
|
||||
...a,
|
||||
isActive: a.id === profileId,
|
||||
}));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
switching = null;
|
||||
}
|
||||
}
|
||||
|
||||
function truncatePath(path: string, maxLen = 40): string {
|
||||
if (path.length <= maxLen) return path;
|
||||
return '...' + path.slice(path.length - maxLen + 3);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="switcher-root">
|
||||
<div class="header">Accounts</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="state-msg">Loading accounts...</div>
|
||||
{:else if error}
|
||||
<div class="state-msg error">{error}</div>
|
||||
{:else if accounts.length === 0}
|
||||
<div class="state-msg">No accounts configured.</div>
|
||||
{:else}
|
||||
<div class="account-list">
|
||||
{#each accounts as account (account.id)}
|
||||
<div class="account-row" class:active={account.isActive}>
|
||||
<div class="account-indicator" class:is-active={account.isActive}></div>
|
||||
<div class="account-info">
|
||||
<div class="account-name">{account.displayName}</div>
|
||||
{#if account.email}
|
||||
<div class="account-email">{account.email}</div>
|
||||
{/if}
|
||||
<div class="account-meta">
|
||||
<span class="provider-badge">{account.provider}</span>
|
||||
<span class="config-dir" title={account.configDir}>{truncatePath(account.configDir)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-action">
|
||||
{#if account.isActive}
|
||||
<span class="active-label">Active</span>
|
||||
{:else}
|
||||
<button
|
||||
class="switch-btn"
|
||||
disabled={switching !== null}
|
||||
onclick={() => switchTo(account.id)}
|
||||
>
|
||||
{switching === account.id ? 'Switching...' : 'Switch'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.switcher-root {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family, system-ui, sans-serif);
|
||||
font-size: var(--ui-font-size, 0.8125rem);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.state-msg.error {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.account-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.account-row.active {
|
||||
border-color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.account-indicator {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-surface2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-indicator.is-active {
|
||||
background: var(--ctp-green);
|
||||
}
|
||||
|
||||
.account-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.account-name {
|
||||
font-weight: 500;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.account-email {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.provider-badge {
|
||||
padding: 0.0625rem 0.3125rem;
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-blue);
|
||||
border-radius: 0.1875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.config-dir {
|
||||
color: var(--ctp-overlay0);
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.active-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-green);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.switch-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-subtext0);
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.switch-btn:hover:not(:disabled) {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.switch-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
303
src/lib/commercial/AnalyticsDashboard.svelte
Normal file
303
src/lib/commercial/AnalyticsDashboard.svelte
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import {
|
||||
proAnalyticsSummary,
|
||||
proAnalyticsDaily,
|
||||
proAnalyticsModelBreakdown,
|
||||
type AnalyticsSummary,
|
||||
type DailyStats,
|
||||
type ModelBreakdown,
|
||||
} from './pro-bridge';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
let { projectId }: Props = $props();
|
||||
|
||||
const PERIODS = [7, 14, 30, 90] as const;
|
||||
type Period = (typeof PERIODS)[number];
|
||||
|
||||
let days = $state<Period>(30);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let summary = $state<AnalyticsSummary | null>(null);
|
||||
let daily = $state<DailyStats[]>([]);
|
||||
let models = $state<ModelBreakdown[]>([]);
|
||||
|
||||
let maxDailyCost = $derived(Math.max(...daily.map((d) => d.costUsd), 0.001));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [s, d, m] = await Promise.all([
|
||||
proAnalyticsSummary(projectId, days),
|
||||
proAnalyticsDaily(projectId, days),
|
||||
proAnalyticsModelBreakdown(projectId, days),
|
||||
]);
|
||||
summary = s;
|
||||
daily = d;
|
||||
models = m;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Re-run when projectId or days changes
|
||||
void projectId;
|
||||
void days;
|
||||
load();
|
||||
});
|
||||
|
||||
function fmt(n: number, decimals = 2): string {
|
||||
return n.toFixed(decimals);
|
||||
}
|
||||
|
||||
function fmtK(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="analytics-root">
|
||||
<div class="period-bar">
|
||||
{#each PERIODS as p}
|
||||
<button
|
||||
class="period-btn"
|
||||
class:active={days === p}
|
||||
onclick={() => (days = p)}
|
||||
>{p}d</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="state-msg">Loading analytics...</div>
|
||||
{:else if error}
|
||||
<div class="state-msg error">{error}</div>
|
||||
{:else if summary}
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<span class="card-label">Total Cost</span>
|
||||
<span class="card-value">${fmt(summary.totalCostUsd)}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="card-label">Sessions</span>
|
||||
<span class="card-value">{summary.totalSessions}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="card-label">Avg $/Session</span>
|
||||
<span class="card-value">${fmt(summary.avgCostPerSession)}</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="card-label">Total Tokens</span>
|
||||
<span class="card-value">{fmtK(summary.totalTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if daily.length > 0}
|
||||
<div class="section-title">Daily Cost</div>
|
||||
<div class="chart-container">
|
||||
<svg viewBox="0 0 {daily.length * 20} 100" class="bar-chart" preserveAspectRatio="none">
|
||||
{#each daily as d, i}
|
||||
{@const h = Math.max((d.costUsd / maxDailyCost) * 90, 1)}
|
||||
<rect
|
||||
x={i * 20 + 2}
|
||||
y={100 - h}
|
||||
width="16"
|
||||
height={h}
|
||||
rx="2"
|
||||
class="bar"
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
<div class="chart-labels">
|
||||
{#each daily as d, i}
|
||||
{#if i === 0 || i === daily.length - 1 || i === Math.floor(daily.length / 2)}
|
||||
<span class="chart-label" style:left="{(i / Math.max(daily.length - 1, 1)) * 100}%">{d.date.slice(5)}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if models.length > 0}
|
||||
<div class="section-title">Model Breakdown</div>
|
||||
<table class="model-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th>Sessions</th>
|
||||
<th>Cost</th>
|
||||
<th>Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each models as m}
|
||||
<tr>
|
||||
<td class="model-name">{m.model}</td>
|
||||
<td>{m.sessionCount}</td>
|
||||
<td>${fmt(m.totalCostUsd)}</td>
|
||||
<td>{fmtK(m.totalTokens)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.analytics-root {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family, system-ui, sans-serif);
|
||||
font-size: var(--ui-font-size, 0.8125rem);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.period-bar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-subtext0);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.period-btn:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-base);
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.state-msg.error {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-subtext0);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
background: var(--ctp-surface0);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
padding: 0.5rem 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
width: 100%;
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
.bar {
|
||||
fill: var(--ctp-blue);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chart-labels {
|
||||
position: relative;
|
||||
height: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.model-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.model-table th {
|
||||
text-align: left;
|
||||
padding: 0.375rem 0.5rem;
|
||||
color: var(--ctp-subtext0);
|
||||
border-bottom: 1px solid var(--ctp-surface1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.model-table td {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.model-table tr:hover td {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-family: monospace;
|
||||
color: var(--ctp-blue);
|
||||
}
|
||||
</style>
|
||||
206
src/lib/commercial/SessionExporter.svelte
Normal file
206
src/lib/commercial/SessionExporter.svelte
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
<script lang="ts">
|
||||
import {
|
||||
proExportSession,
|
||||
proExportProjectSummary,
|
||||
type SessionReport,
|
||||
type ProjectSummaryReport,
|
||||
} from './pro-bridge';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
let { projectId, sessionId }: Props = $props();
|
||||
|
||||
type Mode = 'session' | 'project';
|
||||
const SUMMARY_PERIODS = [7, 30, 90] as const;
|
||||
type SummaryPeriod = (typeof SUMMARY_PERIODS)[number];
|
||||
|
||||
let mode = $state<Mode>(sessionId ? 'session' : 'project');
|
||||
let summaryDays = $state<SummaryPeriod>(30);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let markdown = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
async function generate() {
|
||||
loading = true;
|
||||
error = null;
|
||||
markdown = null;
|
||||
try {
|
||||
if (mode === 'session' && sessionId) {
|
||||
const report: SessionReport = await proExportSession(projectId, sessionId);
|
||||
markdown = report.markdown;
|
||||
} else {
|
||||
const report: ProjectSummaryReport = await proExportProjectSummary(projectId, summaryDays);
|
||||
markdown = report.markdown;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!markdown) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
error = 'Failed to copy to clipboard';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="exporter-root">
|
||||
<div class="mode-bar">
|
||||
<button
|
||||
class="mode-btn"
|
||||
class:active={mode === 'session'}
|
||||
disabled={!sessionId}
|
||||
onclick={() => (mode = 'session')}
|
||||
>Session</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
class:active={mode === 'project'}
|
||||
onclick={() => (mode = 'project')}
|
||||
>Project Summary</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'project'}
|
||||
<div class="period-bar">
|
||||
{#each SUMMARY_PERIODS as p}
|
||||
<button
|
||||
class="period-btn"
|
||||
class:active={summaryDays === p}
|
||||
onclick={() => (summaryDays = p)}
|
||||
>{p}d</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-primary" onclick={generate} disabled={loading || (mode === 'session' && !sessionId)}>
|
||||
{loading ? 'Generating...' : 'Generate Report'}
|
||||
</button>
|
||||
{#if markdown}
|
||||
<button class="btn-secondary" onclick={copyToClipboard}>
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="state-msg error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if markdown}
|
||||
<pre class="preview">{markdown}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.exporter-root {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family, system-ui, sans-serif);
|
||||
font-size: var(--ui-font-size, 0.8125rem);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mode-bar, .period-bar {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mode-btn, .period-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-subtext0);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.mode-btn:hover:not(:disabled), .period-btn:hover {
|
||||
background: var(--ctp-surface1);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.mode-btn.active, .period-btn.active {
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-base);
|
||||
border-color: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.mode-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-base);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
border-color: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.state-msg.error {
|
||||
color: var(--ctp-red);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ctp-subtext1);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-y: auto;
|
||||
max-height: 24rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue