0bd4e5498e
- 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
641 lines
19 KiB
Vue
641 lines
19 KiB
Vue
<script setup lang="ts">
|
|
// 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="dash">
|
|
<!-- Greeting + presence -->
|
|
<header class="head">
|
|
<div>
|
|
<Eyebrow>{{ dateEyebrow }}</Eyebrow>
|
|
<h1>{{ greet }}, {{ firstName }}.</h1>
|
|
</div>
|
|
<EnduserPresenceSelector v-model="presence" />
|
|
</header>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
/* 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;
|
|
}
|
|
.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;
|
|
}
|
|
.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;
|
|
}
|
|
|
|
/* 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>
|