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,380 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-collab.jsx `MeetingsScreen` (lines 71-260)
|
||||
// with Rooms / Recordings / Settings tabs and source's sample data.
|
||||
|
||||
|
||||
import { meetingRooms, meetingRecordings } from '~/data/workspace'
|
||||
|
||||
const tab = ref<'rooms' | 'recordings' | 'settings'>('rooms')
|
||||
const newRoomOpen = ref(false)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
function roomAction(name: string, alias: string, id: string) {
|
||||
if (id === 'start') toast.info(`Joining ${name}…`)
|
||||
else if (id === 'copy') {
|
||||
navigator.clipboard?.writeText(`meet.dezky.com/${alias}`).catch(() => {})
|
||||
toast.ok('Room link copied', `meet.dezky.com/${alias}`)
|
||||
}
|
||||
else if (id === 'edit') toast.info(`Edit ${name}`)
|
||||
else if (id === 'history') toast.info(`Meeting history for ${name}`)
|
||||
else if (id === 'delete') toast.bad(`${name} deleted`)
|
||||
}
|
||||
const roomItems = [
|
||||
{ id: 'start', label: 'Start meeting', icon: 'video' as const },
|
||||
{ id: 'copy', label: 'Copy room link', icon: 'copy' as const },
|
||||
{ id: 'edit', label: 'Edit room…', icon: 'brush' as const },
|
||||
{ id: 'history', label: 'Meeting history', icon: 'file' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete room', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function recAction(title: string, id: string) {
|
||||
if (id === 'play') toast.info(`Playing "${title}"`)
|
||||
else if (id === 'download') toast.info(`Downloading "${title}"`)
|
||||
else if (id === 'share') toast.ok('Share link copied')
|
||||
else if (id === 'transcript') toast.info('Opening transcript')
|
||||
else if (id === 'hold') toast.warn(`Legal hold placed on "${title}"`)
|
||||
else if (id === 'delete') toast.bad(`"${title}" deleted`)
|
||||
}
|
||||
const recItems = [
|
||||
{ id: 'play', label: 'Play', icon: 'video' as const },
|
||||
{ id: 'download', label: 'Download MP4', icon: 'download' as const },
|
||||
{ id: 'share', label: 'Copy share link', icon: 'copy' as const },
|
||||
{ id: 'transcript', label: 'Open transcript', icon: 'file' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'hold', label: 'Place legal hold', icon: 'shield' as const },
|
||||
{ id: 'delete', label: 'Delete recording', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
const totalSize = computed(() =>
|
||||
meetingRecordings.reduce((s, r) => s + parseInt(r.size), 0),
|
||||
)
|
||||
|
||||
const defaults: Array<{ l: string; v: boolean; d: string }> = [
|
||||
{ l: 'Require lobby', v: true, d: 'Participants wait until host admits them.' },
|
||||
{ l: 'End-to-end encryption', v: true, d: 'Available 1:1 and small group rooms.' },
|
||||
{ l: 'Allow guest links', v: true, d: 'External participants can join via link.' },
|
||||
{ l: 'Recording on by default', v: false, d: 'Override per room when needed.' },
|
||||
{ l: 'Transcription', v: true, d: 'Auto-generate transcripts in Danish + English.' },
|
||||
]
|
||||
|
||||
const recordingPolicy = ref<'off' | 'auto' | 'manual'>('auto')
|
||||
const recordingOptions = [
|
||||
{ v: 'off' as const, label: 'Disable recording org-wide', d: 'Hosts cannot record. Useful for regulated environments.' },
|
||||
{ v: 'auto' as const, label: 'Allow · keep in Drev · 365 d', d: 'Recordings auto-save to /Recordings folder. Auto-delete after 365 days unless on legal hold.' },
|
||||
{ v: 'manual' as const, label: 'Allow · host downloads only', d: 'Recordings are not stored on the platform. Host gets a download link valid for 24h.' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="Møder · Jitsi"
|
||||
title="Meeting settings"
|
||||
subtitle="Persistent rooms, recordings, and default meeting policy for your workspace."
|
||||
/>
|
||||
<div class="tab-wrap">
|
||||
<Tabs
|
||||
v-model="tab"
|
||||
:items="[
|
||||
{ value: 'rooms', label: 'Rooms', count: meetingRooms.length },
|
||||
{ value: 'recordings', label: 'Recordings', count: meetingRecordings.length },
|
||||
{ value: 'settings', label: 'Settings' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<template v-if="tab === 'rooms'">
|
||||
<div class="row">
|
||||
<div class="lead">Persistent rooms keep a stable URL — meeting recordings and chat history stay tied to the room.</div>
|
||||
<UiButton variant="primary" @click="newRoomOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New room
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Room</th><th>Type</th><th>Schedule</th><th>Owner</th><th>Recording</th><th class="right">Members</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in meetingRooms" :key="r.id">
|
||||
<td>
|
||||
<div class="room-cell">
|
||||
<div class="room-icon"><UiIcon name="video" :size="14" /></div>
|
||||
<div>
|
||||
<div class="room-name">
|
||||
<span>{{ r.name }}</span>
|
||||
<UiIcon v-if="r.protected" name="shield" :size="11" stroke="var(--text-mute)" />
|
||||
</div>
|
||||
<Mono dim>meet.dezky.com/{{ r.alias }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge :tone="r.type === 'recurring' ? 'info' : 'neutral'">{{ r.type }}</Badge></td>
|
||||
<td class="meta">{{ r.when }}</td>
|
||||
<td>
|
||||
<div class="owner-cell">
|
||||
<Avatar :name="r.owner" :size="20" />
|
||||
<span>{{ r.owner }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Badge :tone="r.recording === 'auto' ? 'ok' : r.recording === 'off' ? 'neutral' : 'warn'">{{ r.recording }}</Badge></td>
|
||||
<td class="right"><Mono>{{ r.members }}</Mono></td>
|
||||
<td class="right"><AdminKebabMenu :items="roomItems" @select="(id) => roomAction(r.name, r.alias, id)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'recordings'">
|
||||
<div class="rec-toolbar">
|
||||
<div class="input-search">
|
||||
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
|
||||
<input placeholder="Search by title, host, room…" />
|
||||
</div>
|
||||
<button class="chip"><Eyebrow>Retention:</Eyebrow> <span>All</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<button class="chip"><Eyebrow>Host:</Eyebrow> <span>Anyone</span> <UiIcon name="chevDown" :size="12" stroke="var(--text-mute)" /></button>
|
||||
<div class="spacer" />
|
||||
<Mono dim>{{ meetingRecordings.length }} recordings · {{ totalSize }} MB</Mono>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr><th>Recording</th><th>Recorded</th><th>Host</th><th>Views</th><th>Retention</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in meetingRecordings" :key="r.id">
|
||||
<td>
|
||||
<div class="rec-cell">
|
||||
<div class="rec-thumb"><UiIcon name="video" :size="13" /></div>
|
||||
<div>
|
||||
<div class="rec-title">{{ r.title }}</div>
|
||||
<Mono dim>{{ r.dur }} · {{ r.size }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono dim>{{ r.date }}</Mono></td>
|
||||
<td>
|
||||
<div class="owner-cell">
|
||||
<Avatar :name="r.host" :size="20" />
|
||||
<span>{{ r.host }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono>{{ r.views }}</Mono></td>
|
||||
<td><Badge :tone="r.retention === 'forever' ? 'invert' : r.retention === '365 d' ? 'info' : 'neutral'" dot>{{ r.retention }}{{ r.legal ? ' · hold' : '' }}</Badge></td>
|
||||
<td class="right"><AdminKebabMenu :items="recItems" @select="(id) => recAction(r.title, id)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="settings">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Defaults</Eyebrow>
|
||||
<div class="card-title">New room defaults</div>
|
||||
<div class="card-sub">What every new room inherits unless the creator overrides.</div>
|
||||
</div>
|
||||
<div class="defaults">
|
||||
<div v-for="r in defaults" :key="r.l" class="def-row">
|
||||
<button class="toggle" :class="{ on: r.v }"><span /></button>
|
||||
<div class="def-meta">
|
||||
<div class="def-label">{{ r.l }}</div>
|
||||
<div class="def-d">{{ r.d }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Recording policy</Eyebrow>
|
||||
<div class="card-title">Where recordings live</div>
|
||||
</div>
|
||||
<div class="radio-big">
|
||||
<label v-for="o in recordingOptions" :key="o.v" :class="{ active: recordingPolicy === o.v }">
|
||||
<span class="radio-dot"><span v-if="recordingPolicy === o.v" /></span>
|
||||
<input type="radio" :value="o.v" v-model="recordingPolicy" />
|
||||
<div>
|
||||
<div class="radio-label">{{ o.label }}</div>
|
||||
<div class="radio-d">{{ o.d }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Limits</Eyebrow>
|
||||
<div class="card-title">Capacity & quality</div>
|
||||
</div>
|
||||
<div class="limits">
|
||||
<div v-for="[k, v] in [
|
||||
['Max participants per room', '50'],
|
||||
['Default video resolution', '720p · adaptive'],
|
||||
['Recording resolution', '1080p'],
|
||||
]" :key="k">
|
||||
<Eyebrow>{{ k }}</Eyebrow>
|
||||
<div class="limit-v">{{ v }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Modal :open="newRoomOpen" eyebrow="Meetings · rooms" title="New room" size="md" @close="newRoomOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Name</Eyebrow><input class="input" placeholder="Engineering standup" /></label>
|
||||
<label class="field"><Eyebrow>Alias</Eyebrow><input class="input" placeholder="eng-standup" /></label>
|
||||
<label class="field"><Eyebrow>Owner</Eyebrow><input class="input" value="Anne Baslund" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="newRoomOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="newRoomOpen = false">Create room</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 20px 40px 64px 40px; }
|
||||
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
||||
|
||||
.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; }
|
||||
.meta { font-size: 12px; }
|
||||
|
||||
.room-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.room-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.room-name { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; }
|
||||
.owner-cell { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
|
||||
.rec-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.input-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 320px;
|
||||
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); }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip span { font-weight: 500; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.rec-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.rec-thumb {
|
||||
width: 64px;
|
||||
height: 36px;
|
||||
border-radius: 5px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rec-title { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.settings { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
|
||||
.card-head { margin-bottom: 14px; }
|
||||
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.defaults { display: flex; flex-direction: column; gap: 14px; }
|
||||
.def-row { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
|
||||
.def-row:last-child { padding-bottom: 0; border-bottom: none; }
|
||||
.def-meta { flex: 1; }
|
||||
.def-label { font-size: 13px; font-weight: 500; }
|
||||
.def-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.toggle {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
transition: left 120ms;
|
||||
}
|
||||
.toggle.on { background: var(--text); }
|
||||
.toggle.on span { left: 16px; background: var(--accent); }
|
||||
|
||||
.radio-big { display: flex; flex-direction: column; gap: 8px; }
|
||||
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||
.radio-big label.active { border-color: var(--text); background: var(--bg); }
|
||||
.radio-big input { display: none; }
|
||||
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
|
||||
.radio-big label.active .radio-dot { border-color: var(--text); }
|
||||
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
|
||||
.radio-label { font-size: 14px; font-weight: 500; }
|
||||
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
||||
|
||||
.limits { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.limit-v { font-family: var(--font-display); font-weight: 600; font-size: 18px; margin-top: 6px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user