feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-collab.jsx `IntegrationsScreen` (lines
|
||||
// 440-575) with IntegrationTile (589) and IntegrationDetail (622).
|
||||
// 4 tabs: Marketplace · Connected · Webhooks · API tokens.
|
||||
|
||||
|
||||
import { integrations, integrationCategories, type Integration } from '~/data/workspace'
|
||||
|
||||
const tab = ref<'marketplace' | 'connected' | 'webhooks' | 'api'>('marketplace')
|
||||
const cat = ref<typeof integrationCategories[number]>('All')
|
||||
const open = ref<Integration | null>(null)
|
||||
const buildCustomOpen = ref(false)
|
||||
const newWebhookOpen = ref(false)
|
||||
const newTokenOpen = ref(false)
|
||||
const disconnectOpen = ref(false)
|
||||
const revokeToken = ref<{ name: string; suffix: string } | null>(null)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
function connectedAction(i: Integration, id: string) {
|
||||
if (id === 'configure') open.value = i
|
||||
else if (id === 'logs') toast.info(`Logs for ${i.name}`)
|
||||
else if (id === 'sync') toast.info(`Syncing ${i.name} now`)
|
||||
else if (id === 'disconnect') { open.value = i; disconnectOpen.value = true }
|
||||
}
|
||||
const connectedItems = [
|
||||
{ id: 'configure', label: 'Configure', icon: 'brush' as const },
|
||||
{ id: 'logs', label: 'View logs', icon: 'file' as const },
|
||||
{ id: 'sync', label: 'Sync now', icon: 'refresh' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'disconnect', label: 'Disconnect', icon: 'plug' as const, danger: true },
|
||||
]
|
||||
|
||||
function confirmDisconnect() {
|
||||
const name = open.value?.name
|
||||
disconnectOpen.value = false
|
||||
open.value = null
|
||||
toast.warn(`${name} disconnected`)
|
||||
}
|
||||
function confirmRevoke() {
|
||||
const name = revokeToken.value?.name
|
||||
revokeToken.value = null
|
||||
toast.bad(`${name} revoked`)
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (tab.value === 'connected') return integrations.filter((i) => i.connected)
|
||||
if (cat.value === 'All') return integrations
|
||||
return integrations.filter((i) => i.cat === cat.value)
|
||||
})
|
||||
|
||||
const connectedCount = computed(() => integrations.filter((i) => i.connected).length)
|
||||
|
||||
const apiTokens = [
|
||||
{ name: 'CI deploy token', prefix: 'dz_live_', suffix: 'a91f', scope: 'users:read · billing:read', created: '14 Feb 2026', lastUsed: '2 min ago' },
|
||||
{ name: 'Monitoring scrape', prefix: 'dz_live_', suffix: '88ce', scope: 'metrics:read', created: '02 Mar 2026', lastUsed: '14 sec ago' },
|
||||
{ name: 'Old migration · revoke', prefix: 'dz_live_', suffix: '441b', scope: 'admin:*', created: '11 Jan 2026', lastUsed: '24 d ago' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Marketplace"
|
||||
title="Integrations"
|
||||
subtitle="Connect dezky to the tools your team already uses."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" @click="buildCustomOpen = true">
|
||||
<template #leading><UiIcon name="plug" :size="14" /></template>
|
||||
Build custom · API
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="tab-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'marketplace', label: 'Marketplace', count: integrations.length },
|
||||
{ value: 'connected', label: 'Connected', count: connectedCount },
|
||||
{ value: 'webhooks', label: 'Webhooks' },
|
||||
{ value: 'api', label: 'API tokens' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace -->
|
||||
<div v-if="tab === 'marketplace'" class="content">
|
||||
<div class="cat-row">
|
||||
<button
|
||||
v-for="c in integrationCategories"
|
||||
:key="c"
|
||||
class="pill"
|
||||
:class="{ active: cat === c }"
|
||||
@click="cat = c"
|
||||
>
|
||||
{{ c }}
|
||||
<span v-if="c === 'Accounting'" class="dk-flag" :class="{ active: cat === c }">DK</span>
|
||||
</button>
|
||||
<div class="spacer" />
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input placeholder="Search integrations…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-grid">
|
||||
<button v-for="i in filtered" :key="i.id" class="tile" @click="open = i">
|
||||
<div class="tile-head">
|
||||
<div class="i-icon" :style="{ background: i.color, color: i.accent }">{{ i.icon }}</div>
|
||||
<Badge v-if="i.connected" tone="ok" dot>connected</Badge>
|
||||
<Badge v-else-if="i.danish" tone="info">DK</Badge>
|
||||
</div>
|
||||
<div class="tile-body">
|
||||
<div class="tile-name-row">
|
||||
<span class="tile-name">{{ i.name }}</span>
|
||||
<Mono dim>· {{ i.cat }}</Mono>
|
||||
</div>
|
||||
<div class="tile-desc">{{ i.desc }}</div>
|
||||
</div>
|
||||
<div class="tile-foot">
|
||||
<Mono dim>{{ i.kind }}</Mono>
|
||||
<span v-if="i.connected" class="users">{{ i.users }} users</span>
|
||||
<span v-else class="connect">Connect</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected -->
|
||||
<div v-else-if="tab === 'connected'" class="content">
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Integration</th><th>Type</th><th>Users</th><th>Status</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in filtered" :key="i.id">
|
||||
<td>
|
||||
<div class="conn-cell">
|
||||
<div class="i-icon small" :style="{ background: i.color, color: i.accent }">{{ i.icon }}</div>
|
||||
<div>
|
||||
<div class="conn-name">{{ i.name }}</div>
|
||||
<Mono dim>{{ i.cat }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono>{{ i.kind }}</Mono></td>
|
||||
<td><Mono>{{ i.users || 0 }}</Mono></td>
|
||||
<td><Badge tone="ok" dot>connected</Badge></td>
|
||||
<td class="right">
|
||||
<UiButton size="sm" variant="ghost" @click="open = i">Configure</UiButton>
|
||||
<AdminKebabMenu :items="connectedItems" :icon-size="13" @select="(id) => connectedAction(i, id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Webhooks -->
|
||||
<div v-else-if="tab === 'webhooks'" class="content">
|
||||
<div class="empty-card">
|
||||
<UiIcon name="plug" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="empty-title">No webhooks yet</div>
|
||||
<div class="empty-body">Webhooks let external services react to events in dezky (user.created, file.shared, billing.charged, etc.).</div>
|
||||
<UiButton variant="primary" @click="newWebhookOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New webhook
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API tokens -->
|
||||
<div v-else class="content api">
|
||||
<div class="row">
|
||||
<div class="lead">API tokens authenticate scripts and external services to your workspace. Treat them like passwords.</div>
|
||||
<UiButton variant="primary" @click="newTokenOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Generate token
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Token</th><th>Scope</th><th>Created</th><th>Last used</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in apiTokens" :key="t.name">
|
||||
<td>
|
||||
<div class="tok-name">{{ t.name }}</div>
|
||||
<Mono dim>{{ t.prefix }}····{{ t.suffix }}</Mono>
|
||||
</td>
|
||||
<td><Mono dim>{{ t.scope }}</Mono></td>
|
||||
<td><Mono dim>{{ t.created }}</Mono></td>
|
||||
<td><Mono dim>{{ t.lastUsed }}</Mono></td>
|
||||
<td class="right">
|
||||
<UiButton size="sm" variant="danger" @click="revokeToken = { name: t.name, suffix: t.suffix }">
|
||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
||||
Revoke
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Detail side panel -->
|
||||
<SidePanel :open="!!open" :eyebrow="open?.cat || ''" :title="open?.name || ''" width="lg" @close="open = null">
|
||||
<div v-if="open" class="detail">
|
||||
<div class="detail-head">
|
||||
<div class="i-icon big" :style="{ background: open.color, color: open.accent }">{{ open.icon }}</div>
|
||||
<div class="detail-meta">
|
||||
<div class="detail-name">{{ open.name }}</div>
|
||||
<Mono dim>{{ open.cat }} · {{ open.kind }}</Mono>
|
||||
<div style="margin-top: 8px">
|
||||
<Badge v-if="open.connected" tone="ok" dot>connected · {{ open.users }} users</Badge>
|
||||
<Badge v-else tone="neutral">not connected</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-desc">{{ open.desc }}</div>
|
||||
|
||||
<template v-if="!open.connected">
|
||||
<Eyebrow>What this integration does</Eyebrow>
|
||||
<div class="bullets">
|
||||
<div v-for="b in [
|
||||
`Provisions users from dezky into ${open.name} on invite`,
|
||||
`Single sign-on via Authentik · removes ${open.name} passwords`,
|
||||
`Group sync · dezky groups become ${open.name} teams`,
|
||||
'Audit trail · sign-ins logged in your global audit log',
|
||||
]" :key="b" class="bullet">
|
||||
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
|
||||
<span>{{ b }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Eyebrow>Configuration</Eyebrow>
|
||||
<div class="cfg">
|
||||
<div class="cfg-row">
|
||||
<Mono dim>SSO endpoint</Mono>
|
||||
<Mono>https://sso.dezky.com/{{ open.id }}</Mono>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<Mono dim>Last sign-in</Mono>
|
||||
<span>2 minutes ago · anne@dezky.com</span>
|
||||
</div>
|
||||
<div class="cfg-row">
|
||||
<Mono dim>Last sync</Mono>
|
||||
<span>5 minutes ago · 11 users</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<template v-if="open?.connected">
|
||||
<UiButton variant="danger" @click="disconnectOpen = true">
|
||||
<template #leading><UiIcon name="plug" :size="13" /></template>
|
||||
Disconnect
|
||||
</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="toast.info(`Logs for ${open?.name}`)">View logs</UiButton>
|
||||
<UiButton variant="primary" @click="open = null; toast.ok('Settings saved')">Save changes</UiButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UiButton variant="ghost" @click="open = null">Cancel</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="primary" @click="toast.ok(`${open?.name} connected`); open = null">
|
||||
<template #leading><UiIcon name="plug" :size="13" /></template>
|
||||
Connect {{ open?.name }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</template>
|
||||
</SidePanel>
|
||||
|
||||
<!-- Build custom API modal stub -->
|
||||
<Modal :open="buildCustomOpen" eyebrow="Integrations · custom" title="Build a custom integration" size="md" @close="buildCustomOpen = false">
|
||||
<div class="form-stack">
|
||||
<div class="lead">
|
||||
Use dezky's REST API + webhooks to wire any system into your workspace. Token-scoped,
|
||||
rate-limited, and audit-logged.
|
||||
</div>
|
||||
<label class="field"><Eyebrow>Integration name</Eyebrow><input class="input" placeholder="Acme finance bridge" /></label>
|
||||
<label class="field"><Eyebrow>Description</Eyebrow><input class="input" placeholder="Posts invoice events to /accounts" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="buildCustomOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="buildCustomOpen = false; tab = 'api'; toast.info('Generate a token to start')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Continue
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- New webhook modal -->
|
||||
<Modal :open="newWebhookOpen" eyebrow="Integrations · webhooks" title="New webhook" size="md" @close="newWebhookOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Endpoint URL</Eyebrow><input class="input" placeholder="https://example.com/dezky" /></label>
|
||||
<label class="field"><Eyebrow>Events</Eyebrow><input class="input" placeholder="user.created, file.shared" /></label>
|
||||
<label class="field"><Eyebrow>Signing secret</Eyebrow><input class="input" value="auto-generated · copy after save" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="newWebhookOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="newWebhookOpen = false; toast.ok('Webhook created')">Create webhook</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- New API token modal -->
|
||||
<Modal :open="newTokenOpen" eyebrow="Integrations · API tokens" title="New API token" size="md" @close="newTokenOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Token name</Eyebrow><input class="input" placeholder="CI deploy token" /></label>
|
||||
<label class="field"><Eyebrow>Scopes</Eyebrow><input class="input" placeholder="users:read · billing:read" /></label>
|
||||
<label class="field"><Eyebrow>Expires</Eyebrow>
|
||||
<select class="input"><option>30 days</option><option>90 days</option><option>1 year</option><option>Never</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="newTokenOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="newTokenOpen = false; toast.ok('Token created — copy now, it will not be shown again')">
|
||||
<template #leading><UiIcon name="key" :size="13" /></template>
|
||||
Generate token
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Confirm disconnect -->
|
||||
<ConfirmDialog
|
||||
:open="disconnectOpen"
|
||||
eyebrow="Integration"
|
||||
:title="`Disconnect ${open?.name || ''}?`"
|
||||
confirm-label="Disconnect"
|
||||
tone="danger"
|
||||
@close="disconnectOpen = false"
|
||||
@confirm="confirmDisconnect"
|
||||
>
|
||||
Existing user sessions in {{ open?.name }} will keep working until they expire, but new
|
||||
sign-ins and provisioning will stop immediately.
|
||||
</ConfirmDialog>
|
||||
|
||||
<!-- Confirm revoke API token -->
|
||||
<ConfirmDialog
|
||||
:open="!!revokeToken"
|
||||
eyebrow="API token"
|
||||
:title="`Revoke ${revokeToken?.name || ''}?`"
|
||||
confirm-label="Revoke token"
|
||||
tone="danger"
|
||||
@close="revokeToken = null"
|
||||
@confirm="confirmRevoke"
|
||||
>
|
||||
Any script using <Mono>dz_live_····{{ revokeToken?.suffix }}</Mono> will fail
|
||||
authentication immediately. This cannot be undone.
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 20px 40px 64px 40px; }
|
||||
|
||||
.cat-row { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.pill {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.pill.active { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||
.dk-flag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-mute);
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.dk-flag.active { background: var(--accent); color: var(--accent-fg); }
|
||||
.spacer { flex: 1; }
|
||||
.input-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 240px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
|
||||
|
||||
.tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 168px;
|
||||
transition: border-color 120ms;
|
||||
}
|
||||
.tile:hover { border-color: var(--text); }
|
||||
.tile-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.tile-body { flex: 1; }
|
||||
.tile-name-row { display: flex; align-items: center; gap: 6px; }
|
||||
.tile-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; letter-spacing: -0.015em; }
|
||||
.tile-desc { font-size: 12px; color: var(--text-mute); margin-top: 6px; line-height: 1.5; }
|
||||
.tile-foot { display: flex; align-items: center; justify-content: space-between; }
|
||||
.users { font-size: 12px; color: var(--text-dim); }
|
||||
.connect {
|
||||
font-size: 12px;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent);
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.i-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.i-icon.small { width: 32px; height: 32px; font-size: 16px; }
|
||||
.i-icon.big { width: 56px; height: 56px; font-size: 28px; }
|
||||
|
||||
.tbl { width: 100%; border-collapse: collapse; }
|
||||
.tbl th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl .right { text-align: right; display: flex; gap: 4px; justify-content: flex-end; }
|
||||
tr td.right { display: table-cell; text-align: right; }
|
||||
|
||||
.conn-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.conn-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.empty-card {
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
|
||||
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
|
||||
|
||||
.content.api { max-width: 900px; }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
||||
.tok-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.detail { padding-bottom: 24px; }
|
||||
.detail-head { display: flex; align-items: center; gap: 14px; }
|
||||
.detail-meta { flex: 1; }
|
||||
.detail-name { font-family: var(--font-display); font-weight: 600; font-size: 20px; letter-spacing: -0.015em; }
|
||||
.detail-desc { margin-top: 16px; font-size: 13px; color: var(--text-dim); line-height: 1.6; }
|
||||
|
||||
.bullets { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
||||
.bullet { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; }
|
||||
.cfg { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
||||
.cfg-row { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; }
|
||||
|
||||
/* Modal form helpers */
|
||||
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
|
||||
.input:focus { border-color: var(--text); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user