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:
+610
-156
@@ -1,186 +1,640 @@
|
||||
<script setup lang="ts">
|
||||
// Post-login landing. Auth middleware (nuxt-oidc-auth) gates access — anonymous
|
||||
// visitors get bounced to /login by the customLoginPage config in nuxt.config.ts.
|
||||
const { user, logout } = useOidcAuth()
|
||||
// End-user dashboard. Faithfully ports project/platform-screens.jsx
|
||||
// `EndUserDashboard` — same layout, same spacing tokens, same copy.
|
||||
|
||||
|
||||
import type { IconName } from '~/components/UiIcon.vue'
|
||||
import { appTiles, currentUser, todayAgenda, recentFiles, needsAttention } from '~/data/enduser'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const presence = ref<'available' | 'meeting' | 'focus' | 'away'>('available')
|
||||
|
||||
// Date eyebrow ("Monday, 25 May") + dynamic greeting that follows the source's
|
||||
// hour-bucket rules.
|
||||
const now = new Date()
|
||||
const dateEyebrow = now.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
const firstName = currentUser.name.split(' ')[0]
|
||||
const greet = (() => {
|
||||
const h = now.getHours()
|
||||
if (h < 5) return 'Still up'
|
||||
if (h < 12) return 'Good morning'
|
||||
if (h < 17) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
})()
|
||||
|
||||
const previewFile = ref<typeof recentFiles[number] | null>(null)
|
||||
const joining = ref<typeof todayAgenda[number] | null>(null)
|
||||
const joinMic = ref(true)
|
||||
const joinCam = ref(true)
|
||||
watch(joining, (v) => { if (v) { joinMic.value = true; joinCam.value = true } })
|
||||
|
||||
function openApp(name: string) {
|
||||
toast.info(`Opening ${name}…`)
|
||||
}
|
||||
|
||||
const APP_ICONS: Record<string, IconName> = {
|
||||
mail: 'mail', drev: 'folder', moder: 'video', chat: 'chat',
|
||||
}
|
||||
|
||||
// Tone → icon tint colour for the pending-task icon boxes.
|
||||
function attentionIconStyle(tone: string) {
|
||||
if (tone === 'bad') return { background: 'rgba(226, 48, 48, 0.12)', color: 'var(--bad)' }
|
||||
if (tone === 'warn') return { background: 'rgba(232, 154, 31, 0.12)', color: 'var(--warn)' }
|
||||
return { background: 'rgba(10, 10, 10, 0.08)', color: 'var(--text)' }
|
||||
}
|
||||
|
||||
function fireAttention(item: typeof needsAttention[number]) {
|
||||
if (item.target === 'security') return router.push('/security')
|
||||
if (item.target === 'file') {
|
||||
previewFile.value = { id: 'attn-q3', name: 'Q3 forecast.xlsx', path: 'Drev · /Finance', updated: 'yesterday', size: '482 KB' }
|
||||
return
|
||||
}
|
||||
toast.ok(`${item.cta} · ${item.title}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<span class="brand-tile">
|
||||
<NodeMark :size="22" />
|
||||
</span>
|
||||
<span class="brand-name">dezky</span>
|
||||
</div>
|
||||
<div class="me">
|
||||
<span class="email">{{ user?.userInfo?.email || user?.userName }}</span>
|
||||
<button class="logout" @click="logout()">sign out</button>
|
||||
<div class="dash">
|
||||
<!-- Greeting + presence -->
|
||||
<header class="head">
|
||||
<div>
|
||||
<Eyebrow>{{ dateEyebrow }}</Eyebrow>
|
||||
<h1>{{ greet }}, {{ firstName }}.</h1>
|
||||
</div>
|
||||
<EnduserPresenceSelector v-model="presence" />
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Workspace · welcome</p>
|
||||
<h1>Hi, {{ user?.userInfo?.name || user?.userName }}.</h1>
|
||||
<p class="tagline">Sovereign workspace platform · all your services in one place.</p>
|
||||
</section>
|
||||
<!-- 4 app tiles -->
|
||||
<section class="tiles">
|
||||
<button v-for="t in appTiles" :key="t.key" class="tile" @click="openApp(t.name)">
|
||||
<span class="tile-icon">
|
||||
<UiIcon :name="APP_ICONS[t.key] ?? 'file'" :size="18" />
|
||||
</span>
|
||||
<div class="tile-body">
|
||||
<div class="tile-name">{{ t.name }}</div>
|
||||
<div class="tile-badge">{{ t.badge }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<a href="https://files.dezky.local" target="_blank" class="tile">
|
||||
<span class="tile-name">Files</span>
|
||||
<span class="tile-meta">OCIS · S3-backed storage</span>
|
||||
</a>
|
||||
<a href="https://mail.dezky.local/admin/" target="_blank" class="tile">
|
||||
<span class="tile-name">Mail</span>
|
||||
<span class="tile-meta">Stalwart · JMAP/IMAP/SMTP</span>
|
||||
</a>
|
||||
<a href="https://office.dezky.local" target="_blank" class="tile">
|
||||
<span class="tile-name">Office</span>
|
||||
<span class="tile-meta">Collabora · document editing</span>
|
||||
</a>
|
||||
<a href="https://auth.dezky.local" target="_blank" class="tile">
|
||||
<span class="tile-name">Identity</span>
|
||||
<span class="tile-meta">Authentik · SSO & access</span>
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
<!-- Today's meetings + recent files -->
|
||||
<section class="two-col">
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Today</Eyebrow>
|
||||
<div class="card-title">Meetings</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Opening calendar at cal.dezky.com')">
|
||||
View calendar
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, i) in todayAgenda"
|
||||
:key="m.id"
|
||||
class="agenda-row"
|
||||
:class="{ last: i === todayAgenda.length - 1 }"
|
||||
>
|
||||
<div class="agenda-time">{{ m.time }}</div>
|
||||
<div class="agenda-meta">
|
||||
<div class="agenda-title">{{ m.title }}</div>
|
||||
<div class="agenda-with">{{ m.with }}</div>
|
||||
</div>
|
||||
<div class="agenda-in">in {{ m.in }}</div>
|
||||
<UiButton size="sm" variant="primary" @click="joining = m">Join</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head no-action">
|
||||
<Eyebrow>Recent</Eyebrow>
|
||||
<div class="card-title">Files</div>
|
||||
</div>
|
||||
<button
|
||||
v-for="(f, i) in recentFiles.slice(0, 5)"
|
||||
:key="f.id"
|
||||
class="file-row"
|
||||
:class="{ last: i === 4 }"
|
||||
@click="previewFile = f"
|
||||
>
|
||||
<span class="file-icon"><UiIcon name="file" :size="14" /></span>
|
||||
<div class="file-text">
|
||||
<div class="file-name">{{ f.name }}</div>
|
||||
<div class="file-meta">{{ f.path }} · {{ f.updated }}</div>
|
||||
</div>
|
||||
<UiIcon name="chevRight" :size="12" stroke="var(--text-mute)" />
|
||||
</button>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Pending tasks -->
|
||||
<section class="block">
|
||||
<Card :pad="0">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<Eyebrow>Needs your attention</Eyebrow>
|
||||
<div class="card-title">Pending · {{ needsAttention.length }} items</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Opening full task list')">
|
||||
See all
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="attention">
|
||||
<div
|
||||
v-for="(t, i) in needsAttention"
|
||||
:key="t.id"
|
||||
class="att-row"
|
||||
:class="{
|
||||
'right-col': i % 2 === 1,
|
||||
'bottom': i >= needsAttention.length - 2,
|
||||
}"
|
||||
>
|
||||
<span class="att-icon" :style="attentionIconStyle(t.tone)">
|
||||
<UiIcon :name="(t.icon as IconName)" :size="14" />
|
||||
</span>
|
||||
<div class="att-text">
|
||||
<div class="att-title">{{ t.title }}</div>
|
||||
<Mono dim>{{ t.hint }}</Mono>
|
||||
</div>
|
||||
<UiButton
|
||||
size="sm"
|
||||
:variant="t.tone === 'bad' ? 'primary' : 'secondary'"
|
||||
@click="fireAttention(t)"
|
||||
>
|
||||
{{ t.cta }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- Announcement + system status -->
|
||||
<section class="two-col">
|
||||
<div class="announce">
|
||||
<div class="announce-text">
|
||||
<div class="announce-kicker">// announcement</div>
|
||||
<div class="announce-head">We're moving to single-sign-on next Monday. Set up your authenticator app this week.</div>
|
||||
<div class="announce-by">posted by Anne · 2h ago</div>
|
||||
</div>
|
||||
<UiButton variant="primary" @click="router.push('/security')">Set it up</UiButton>
|
||||
</div>
|
||||
|
||||
<Card :pad="0">
|
||||
<div class="card-head no-action">
|
||||
<Eyebrow>System</Eyebrow>
|
||||
<div class="card-title">All services operational</div>
|
||||
</div>
|
||||
<div class="services">
|
||||
<div v-for="s in ['Mail', 'Drev', 'Møder', 'Chat', 'Auth (SSO)']" :key="s" class="svc-row">
|
||||
<span class="svc-name">{{ s }}</span>
|
||||
<div class="svc-state">
|
||||
<StatusDot color="var(--ok)" :size="7" :glow="false" />
|
||||
<span>operational</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- File preview modal -->
|
||||
<Modal :open="previewFile !== null" eyebrow="Drev · preview" :title="previewFile?.name" size="md" @close="previewFile = null">
|
||||
<div class="preview">
|
||||
<div class="preview-stage">
|
||||
<span class="preview-icon"><UiIcon name="file" :size="28" /></span>
|
||||
<Mono dim>preview not available · open in Drev to view</Mono>
|
||||
</div>
|
||||
<div class="preview-meta">
|
||||
<dl>
|
||||
<div><dt>Location</dt><dd><Mono>{{ previewFile?.path }}</Mono></dd></div>
|
||||
<div><dt>Modified</dt><dd>{{ previewFile?.updated }}</dd></div>
|
||||
<div><dt>Size</dt><dd><Mono>{{ previewFile?.size ?? '2.4 MB' }}</Mono></dd></div>
|
||||
<div><dt>Shared with</dt><dd>3 people · Engineering team</dd></div>
|
||||
<div><dt>Permissions</dt><dd>You can edit</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<UiButton size="sm" variant="secondary" @click="toast.ok('Link copied')">
|
||||
<template #leading><UiIcon name="copy" :size="13" /></template>
|
||||
Copy link
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="toast.info('Opening sharing')">
|
||||
<template #leading><UiIcon name="users" :size="13" /></template>
|
||||
Manage access
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="toast.info('Starred')">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Star
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="previewFile = null">Close</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="toast.info('Downloading')">
|
||||
<template #leading><UiIcon name="download" :size="13" /></template>
|
||||
Download
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="toast.info('Opening in Drev')">
|
||||
<template #leading><UiIcon name="external" :size="13" /></template>
|
||||
Open in Drev
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Join meeting modal -->
|
||||
<Modal :open="joining !== null" eyebrow="Møder" :title="joining ? `Join · ${joining.title}` : ''" size="md" @close="joining = null">
|
||||
<div class="join">
|
||||
<div class="cam">
|
||||
<div class="cam-avatar">A</div>
|
||||
<div class="cam-label">camera preview</div>
|
||||
</div>
|
||||
<div class="join-info">
|
||||
<dl>
|
||||
<div><dt>Meeting</dt><dd>{{ joining?.title }}</dd></div>
|
||||
<div><dt>With</dt><dd>{{ joining?.with }}</dd></div>
|
||||
<div><dt>Starts</dt><dd>{{ joining?.time }} · in {{ joining?.in }}</dd></div>
|
||||
<div><dt>Room</dt><dd><Mono>meet.dezky.com/{{ joining?.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '') }}</Mono></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="join-toggles">
|
||||
<button class="toggle" :class="{ off: !joinMic }" @click="joinMic = !joinMic">
|
||||
<UiIcon :name="joinMic ? 'check' : 'x'" :size="14" :stroke="joinMic ? 'var(--ok)' : 'var(--bad)'" :stroke-width="2.5" />
|
||||
<div class="toggle-text">
|
||||
<div class="toggle-label">Microphone</div>
|
||||
<Mono dim>{{ joinMic ? 'unmuted' : 'muted' }}</Mono>
|
||||
</div>
|
||||
</button>
|
||||
<button class="toggle" :class="{ off: !joinCam }" @click="joinCam = !joinCam">
|
||||
<UiIcon :name="joinCam ? 'check' : 'x'" :size="14" :stroke="joinCam ? 'var(--ok)' : 'var(--bad)'" :stroke-width="2.5" />
|
||||
<div class="toggle-text">
|
||||
<div class="toggle-label">Camera</div>
|
||||
<Mono dim>{{ joinCam ? 'on' : 'off' }}</Mono>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="joining = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="joining = null; toast.ok('Joining meeting…')">
|
||||
<template #leading><UiIcon name="video" :size="13" /></template>
|
||||
Join now
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
/* Container — single column, 1400px max, balanced 32 top / 40 sides / 64 bottom. */
|
||||
.dash {
|
||||
padding: 32px 40px 64px 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Greeting strip. No subtitle line, no bottom border — the design lets the
|
||||
tiles below do the visual divide. */
|
||||
.head {
|
||||
margin-bottom: 32px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.head h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 44px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.05;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
/* App tiles — 4 col grid, 130 minHeight, dark inverted icon box. */
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 130px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
.bar {
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-tile {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: #0a0a0a;
|
||||
.tile:hover { border-color: var(--text); }
|
||||
.tile-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.logout {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
background: rgba(10, 10, 10, 0.04);
|
||||
}
|
||||
|
||||
.stage {
|
||||
flex: 1;
|
||||
padding: 48px 24px;
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 40px;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 12px 0 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
border-color: var(--border-hi);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.tile-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
/* Two-column blocks reused for meetings/files + announce/status */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.two-col:first-of-type { margin-top: 0; }
|
||||
|
||||
/* Stand-alone full-width sections between two-col rows. Source design uses
|
||||
marginTop: 16 between each top-level block after the tile grid. */
|
||||
.block { margin-top: 16px; }
|
||||
|
||||
/* Card head — eyebrow + 18px title + optional ghost action right */
|
||||
.card-head {
|
||||
padding: 20px 24px 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-head.no-action { display: block; }
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-top: 4px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Meetings — 4-col row: time / meta / "in X" / Join */
|
||||
.agenda-row {
|
||||
padding: 14px 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.agenda-row.last { border-bottom: none; }
|
||||
.agenda-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.agenda-title { font-size: 14px; font-weight: 500; }
|
||||
.agenda-with { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
|
||||
.agenda-in {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* Recent files — 28x28 icon, name + meta line, chev */
|
||||
.file-row {
|
||||
padding: 12px 24px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.file-row.last { border-bottom: none; }
|
||||
.file-row:hover { background: var(--row-hover); }
|
||||
.file-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-mute);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-text { flex: 1; min-width: 0; }
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.file-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Pending tasks — 2-col grid, dividers only between cells, none on last row */
|
||||
.attention { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; }
|
||||
.att-row {
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.att-row:not(.right-col) { border-right: 1px solid var(--border); }
|
||||
.att-row.bottom { border-bottom: none; }
|
||||
.att-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.att-text { flex: 1; min-width: 0; }
|
||||
.att-title { font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* Announcement — carbon background, two-up flex row with button on the right */
|
||||
.announce {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
.announce-text { min-width: 0; }
|
||||
.announce-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.announce-head {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-top: 8px;
|
||||
text-wrap: balance;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.announce-by {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* System services — 5 rows, mono name + green dot + 'operational' */
|
||||
.services {
|
||||
padding: 14px 24px 18px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.svc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.svc-name { font-family: var(--font-mono); }
|
||||
.svc-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Preview modal */
|
||||
.preview { display: flex; flex-direction: column; gap: 14px; }
|
||||
.preview-stage {
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--text-dim);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview-meta { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.preview-actions { display: flex; gap: 8px; }
|
||||
.preview-meta dl, .join-info dl {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.preview-meta dl > div, .join-info dl > div { display: flex; gap: 12px; }
|
||||
.preview-meta dt, .join-info dt {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
.preview-meta dd, .join-info dd { margin: 0; font-size: 13px; color: var(--text); }
|
||||
|
||||
/* Join meeting modal */
|
||||
.join { display: flex; flex-direction: column; gap: 16px; }
|
||||
.cam {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #0A0A0A, #1A1A1A);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cam-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 32px;
|
||||
}
|
||||
.cam-label {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
color: #F4F3EE;
|
||||
opacity: 0.6;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.join-info { padding: 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.join-toggles { display: flex; gap: 10px; }
|
||||
.toggle {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.toggle.off {
|
||||
background: rgba(226, 48, 48, 0.08);
|
||||
border-color: rgba(226, 48, 48, 0.3);
|
||||
}
|
||||
.toggle-text { display: flex; flex-direction: column; }
|
||||
.toggle-label { font-size: 13px; font-weight: 500; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user