Files
dezky/apps/portal/pages/admin/meetings.vue
T
Ronni Baslund 0bd4e5498e 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
2026-05-28 20:00:33 +02:00

381 lines
15 KiB
Vue

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