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:
Hibryda 2026-03-17 01:53:22 +01:00
parent fc6b306a5c
commit a98d061b04
3 changed files with 738 additions and 0 deletions

View 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>

View 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>

View 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>