@joewinke/jatui 0.2.19 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -74,6 +74,43 @@ Then import:
74
74
  |-----------|---------|-----------|
75
75
  | `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
76
76
 
77
+ ### Meetings (subpath: `jatui/meet`)
78
+
79
+ Real-time video-meeting UI. `livekit-client` is an **OPTIONAL peer dep**, lazily
80
+ imported (`loadLiveKit()`) only by container components and never at module scope —
81
+ so consumers who never render meeting UI don't install it (the d3/cobe/@xyflow
82
+ pattern). Import via the subpath, NOT the main barrel:
83
+
84
+ ```svelte
85
+ <script>
86
+ import { MeetingRoom, ControlBar, PreJoinLobby } from 'jatui/meet'
87
+ import type { MeetParticipant } from 'jatui/meet'
88
+ </script>
89
+ ```
90
+
91
+ The presentational components (`VideoTile`, `ControlBar`, `ParticipantList`,
92
+ `RecordingBadge`) are driven by plain `MeetParticipant`/`MeetRoomState` view-models
93
+ (no LiveKit types), so a route adapts real LiveKit tracks into them and demos mock
94
+ them with no server.
95
+
96
+ | Component | Purpose | Key Props |
97
+ |-----------|---------|-----------|
98
+ | `PreJoinLobby` | Device check before joining — camera/mic self-preview (browser-native, no LiveKit), display-name confirm, device pickers, optional consent gate (`consent` snippet slot). Emits `PreJoinResult` on join. | `displayName` (bindable), `roomName`, `consentRequired`, `joining`, `cameraDefault`, `micDefault`, `onJoin` |
99
+ | `MeetingRoom` | In-room layout shell — responsive grid, auto focus/filmstrip layout on screen-share. Snippet slots: `controls`, `panel`, `chrome`. | `participants`, `layout`, `focusedId`, `showPanel`, `controls`/`panel`/`chrome` (snippets) |
100
+ | `VideoTile` | One participant's video surface — attaches a `MediaStreamTrack`, camera-off avatar/initials, mic/share/connection badges, speaking ring. | `participant: MeetParticipant`, `mirror`, `size` |
101
+ | `ControlBar` | In-room action bar — mic/cam/screenshare/leave + host menu (lock, record, end-for-all). Real buttons, `aria-pressed`. | `local: MeetParticipant`, `isHost`, `room: MeetRoomState`, `controls: MeetLocalControls`, `hostActions: MeetHostActions` |
102
+ | `ParticipantList` | Roster sidebar — mic/camera state, per-participant host actions (mute, remove) on hover/focus. | `participants`, `isHost`, `hostActions`, `title` |
103
+ | `RecordingBadge` | Always-visible pulsing recording indicator with `aria-live`; renders nothing when off (consent-safe room chrome). | `recording`, `label`, `compact` |
104
+
105
+ **Types & loader:**
106
+ ```typescript
107
+ import type {
108
+ MeetParticipant, MeetRole, MeetRoomState, MeetLayout,
109
+ MeetHostActions, MeetLocalControls, PreJoinResult
110
+ } from 'jatui/meet'
111
+ import { loadLiveKit, isLiveKitAvailable, LiveKitNotInstalledError } from 'jatui/meet'
112
+ ```
113
+
77
114
  ### Layout
78
115
 
79
116
  | Component | Purpose | Key Props |
@@ -0,0 +1,176 @@
1
+ <script lang="ts">
2
+ /**
3
+ * ControlBar — the in-room action bar.
4
+ *
5
+ * Local controls (always): mute mic, toggle camera, screen share, leave.
6
+ * Host controls (when `isHost`): lock room, start/stop recording, end for
7
+ * all — surfaced as a "Host" dropdown. Per-participant host actions
8
+ * (mute/remove) live in ParticipantList, not here.
9
+ *
10
+ * All controls are real <button>s with aria-pressed / aria-label and a
11
+ * red "Leave" that stands apart. Presentational: it reflects the local
12
+ * participant + room state you pass and calls back on intent.
13
+ */
14
+ import type { MeetParticipant, MeetRoomState, MeetHostActions, MeetLocalControls } from './types.js';
15
+
16
+ interface Props {
17
+ /** The local participant (drives mic/cam/share pressed state). */
18
+ local: MeetParticipant;
19
+ /** Whether the local participant is a host (shows host controls). */
20
+ isHost?: boolean;
21
+ /** Current room state (recording/locked) for host toggles. */
22
+ room?: MeetRoomState;
23
+ /** Whether screen sharing is supported/allowed in this context. */
24
+ allowScreenShare?: boolean;
25
+ /** Local-control callbacks. */
26
+ controls?: MeetLocalControls;
27
+ /** Host-action callbacks. */
28
+ hostActions?: MeetHostActions;
29
+ class?: string;
30
+ }
31
+
32
+ let {
33
+ local,
34
+ isHost = false,
35
+ room = {},
36
+ allowScreenShare = true,
37
+ controls = {},
38
+ hostActions = {},
39
+ class: className = ''
40
+ }: Props = $props();
41
+
42
+ const recording = $derived(room.recording ?? false);
43
+ const locked = $derived(room.locked ?? false);
44
+ </script>
45
+
46
+ <div
47
+ class="flex items-center justify-center gap-2 bg-base-200/95 px-3 py-2 backdrop-blur-sm {className}"
48
+ style="border-radius: var(--jatui-radius-box);"
49
+ role="toolbar"
50
+ aria-label="Meeting controls"
51
+ >
52
+ <!-- Mic -->
53
+ <button
54
+ type="button"
55
+ class="btn btn-circle"
56
+ class:btn-error={local.micMuted}
57
+ class:btn-ghost={!local.micMuted}
58
+ aria-pressed={local.micMuted}
59
+ aria-label={local.micMuted ? 'Unmute microphone' : 'Mute microphone'}
60
+ onclick={() => controls.onToggleMic?.(!local.micMuted)}
61
+ >
62
+ {#if local.micMuted}
63
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
64
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 5.586A2 2 0 004 7v6a4 4 0 004 4m2.828-1.172A4 4 0 0016 13V9m0-4a2 2 0 00-3.414-1.414M3 3l18 18" />
65
+ </svg>
66
+ {:else}
67
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
68
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-14 0m7 7v3m0-3a4 4 0 004-4V5a4 4 0 00-8 0v6a4 4 0 004 4z" />
69
+ </svg>
70
+ {/if}
71
+ </button>
72
+
73
+ <!-- Camera -->
74
+ <button
75
+ type="button"
76
+ class="btn btn-circle"
77
+ class:btn-error={local.cameraOff}
78
+ class:btn-ghost={!local.cameraOff}
79
+ aria-pressed={local.cameraOff}
80
+ aria-label={local.cameraOff ? 'Turn camera on' : 'Turn camera off'}
81
+ onclick={() => controls.onToggleCamera?.(!local.cameraOff)}
82
+ >
83
+ {#if local.cameraOff}
84
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
85
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M3 3l18 18M5 7h6m4 0v0M5 7a2 2 0 00-2 2v6a2 2 0 002 2h8" />
86
+ </svg>
87
+ {:else}
88
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
89
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14v-4zM5 7h8a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z" />
90
+ </svg>
91
+ {/if}
92
+ </button>
93
+
94
+ <!-- Screen share -->
95
+ {#if allowScreenShare}
96
+ <button
97
+ type="button"
98
+ class="btn btn-circle"
99
+ class:btn-info={local.isScreenSharing}
100
+ class:btn-ghost={!local.isScreenSharing}
101
+ aria-pressed={local.isScreenSharing ?? false}
102
+ aria-label={local.isScreenSharing ? 'Stop sharing screen' : 'Share screen'}
103
+ onclick={() => controls.onToggleScreenShare?.(!local.isScreenSharing)}
104
+ >
105
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
106
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
107
+ </svg>
108
+ </button>
109
+ {/if}
110
+
111
+ {#if isHost}
112
+ <div class="mx-1 h-8 w-px bg-base-content/15"></div>
113
+
114
+ <!-- Host controls dropdown -->
115
+ <div class="dropdown dropdown-top dropdown-end">
116
+ <div
117
+ tabindex="0"
118
+ role="button"
119
+ class="btn btn-ghost gap-1.5"
120
+ aria-label="Host controls"
121
+ >
122
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
123
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
124
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
125
+ </svg>
126
+ <span class="hidden sm:inline">Host</span>
127
+ </div>
128
+ <ul
129
+ class="dropdown-content menu z-[1] mb-2 w-56 gap-0.5 bg-base-100 p-2 shadow-lg"
130
+ style="border-radius: var(--jatui-radius-box);"
131
+ >
132
+ <li>
133
+ <button type="button" onclick={() => hostActions.onToggleLock?.(!locked)}>
134
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
135
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
136
+ </svg>
137
+ {locked ? 'Unlock room' : 'Lock room'}
138
+ </button>
139
+ </li>
140
+ <li>
141
+ <button
142
+ type="button"
143
+ class={recording ? 'text-error' : ''}
144
+ onclick={() => hostActions.onToggleRecording?.(!recording)}
145
+ >
146
+ <span class="inline-block h-2.5 w-2.5 rounded-full" class:bg-error={recording} class:bg-base-content={!recording} style={recording ? '' : 'opacity:0.4'}></span>
147
+ {recording ? 'Stop recording' : 'Start recording'}
148
+ </button>
149
+ </li>
150
+ <li>
151
+ <button type="button" class="text-error" onclick={() => hostActions.onEndForAll?.()}>
152
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
153
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21h18M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
154
+ </svg>
155
+ End for all
156
+ </button>
157
+ </li>
158
+ </ul>
159
+ </div>
160
+ {/if}
161
+
162
+ <div class="mx-1 h-8 w-px bg-base-content/15"></div>
163
+
164
+ <!-- Leave -->
165
+ <button
166
+ type="button"
167
+ class="btn btn-error gap-2"
168
+ aria-label="Leave meeting"
169
+ onclick={() => controls.onLeave?.()}
170
+ >
171
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
172
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
173
+ </svg>
174
+ <span class="hidden sm:inline">Leave</span>
175
+ </button>
176
+ </div>
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ControlBar — the in-room action bar.
3
+ *
4
+ * Local controls (always): mute mic, toggle camera, screen share, leave.
5
+ * Host controls (when `isHost`): lock room, start/stop recording, end for
6
+ * all — surfaced as a "Host" dropdown. Per-participant host actions
7
+ * (mute/remove) live in ParticipantList, not here.
8
+ *
9
+ * All controls are real <button>s with aria-pressed / aria-label and a
10
+ * red "Leave" that stands apart. Presentational: it reflects the local
11
+ * participant + room state you pass and calls back on intent.
12
+ */
13
+ import type { MeetParticipant, MeetRoomState, MeetHostActions, MeetLocalControls } from './types.js';
14
+ interface Props {
15
+ /** The local participant (drives mic/cam/share pressed state). */
16
+ local: MeetParticipant;
17
+ /** Whether the local participant is a host (shows host controls). */
18
+ isHost?: boolean;
19
+ /** Current room state (recording/locked) for host toggles. */
20
+ room?: MeetRoomState;
21
+ /** Whether screen sharing is supported/allowed in this context. */
22
+ allowScreenShare?: boolean;
23
+ /** Local-control callbacks. */
24
+ controls?: MeetLocalControls;
25
+ /** Host-action callbacks. */
26
+ hostActions?: MeetHostActions;
27
+ class?: string;
28
+ }
29
+ declare const ControlBar: import("svelte").Component<Props, {}, "">;
30
+ type ControlBar = ReturnType<typeof ControlBar>;
31
+ export default ControlBar;
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ /**
3
+ * MeetingRoom — the in-room layout shell.
4
+ *
5
+ * Arranges participant tiles in a responsive grid, or a focus layout (one
6
+ * large speaker/screen-share + a filmstrip). Reserves snippet slots for the
7
+ * control bar, the participant panel, and room chrome (e.g. RecordingBadge)
8
+ * so the consuming route wires behaviour while jatui owns the layout.
9
+ *
10
+ * Presentational: it renders whatever {@link MeetParticipant}[] you pass, so
11
+ * a demo drives it with mocked participants — no LiveKit server needed. The
12
+ * `/meet/[token]` route adapts real LiveKit tracks into these view-models.
13
+ */
14
+ import type { Snippet } from 'svelte';
15
+ import type { MeetParticipant, MeetLayout } from './types.js';
16
+ import VideoTile from './VideoTile.svelte';
17
+
18
+ interface Props {
19
+ /** Participants to lay out (local + remotes). */
20
+ participants: MeetParticipant[];
21
+ /** Layout mode. `focus` auto-selects when someone shares a screen. */
22
+ layout?: MeetLayout;
23
+ /** Explicit focused participant id (overrides auto-focus). */
24
+ focusedId?: string;
25
+ /** Bottom control bar slot (usually <ControlBar />). */
26
+ controls?: Snippet;
27
+ /** Side/overlay participant panel slot (usually <ParticipantList />). */
28
+ panel?: Snippet;
29
+ /** Whether the side panel is shown. */
30
+ showPanel?: boolean;
31
+ /** Top-chrome slot — recording badge, room name, timer, lock icon. */
32
+ chrome?: Snippet;
33
+ class?: string;
34
+ }
35
+
36
+ let {
37
+ participants,
38
+ layout = 'grid',
39
+ focusedId,
40
+ controls,
41
+ panel,
42
+ showPanel = false,
43
+ chrome,
44
+ class: className = ''
45
+ }: Props = $props();
46
+
47
+ // A screen-share forces focus layout onto that participant unless overridden.
48
+ const sharer = $derived(participants.find((p) => p.isScreenSharing));
49
+ const effectiveLayout = $derived<MeetLayout>(sharer ? 'focus' : layout);
50
+
51
+ const focused = $derived(
52
+ participants.find((p) => p.id === focusedId) ??
53
+ sharer ??
54
+ participants.find((p) => p.isSpeaking) ??
55
+ participants[0]
56
+ );
57
+
58
+ const filmstrip = $derived(
59
+ effectiveLayout === 'focus' ? participants.filter((p) => p.id !== focused?.id) : []
60
+ );
61
+
62
+ // Grid column count grows with participant count — capped for readability.
63
+ const gridCols = $derived.by(() => {
64
+ const n = participants.length;
65
+ if (n <= 1) return 'grid-cols-1';
66
+ if (n <= 4) return 'grid-cols-2';
67
+ if (n <= 9) return 'grid-cols-2 sm:grid-cols-3';
68
+ return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4';
69
+ });
70
+ </script>
71
+
72
+ <div class="relative flex h-full min-h-0 flex-col bg-base-300 {className}">
73
+ <!-- Top chrome (recording badge, room name) -->
74
+ {#if chrome}
75
+ <div class="pointer-events-none absolute top-0 right-0 left-0 z-20 flex items-start justify-between p-3">
76
+ <div class="pointer-events-auto flex items-center gap-2">
77
+ {@render chrome()}
78
+ </div>
79
+ </div>
80
+ {/if}
81
+
82
+ <div class="flex min-h-0 flex-1">
83
+ <!-- Stage -->
84
+ <div class="relative min-w-0 flex-1 p-3">
85
+ {#if effectiveLayout === 'focus' && focused}
86
+ <div class="flex h-full min-h-0 flex-col gap-3">
87
+ <div class="min-h-0 flex-1">
88
+ <VideoTile participant={focused} mirror={focused.isLocal} class="h-full w-full" />
89
+ </div>
90
+ {#if filmstrip.length > 0}
91
+ <div class="flex shrink-0 gap-2 overflow-x-auto pb-1">
92
+ {#each filmstrip as p (p.id)}
93
+ <div class="w-40 shrink-0">
94
+ <VideoTile participant={p} mirror={p.isLocal} size="sm" />
95
+ </div>
96
+ {/each}
97
+ </div>
98
+ {/if}
99
+ </div>
100
+ {:else}
101
+ <div class="grid h-full min-h-0 auto-rows-fr gap-3 {gridCols}">
102
+ {#each participants as p (p.id)}
103
+ <VideoTile participant={p} mirror={p.isLocal} />
104
+ {/each}
105
+ </div>
106
+ {/if}
107
+ </div>
108
+
109
+ <!-- Side panel -->
110
+ {#if showPanel && panel}
111
+ <div class="w-72 shrink-0 border-l border-base-content/10 p-3">
112
+ {@render panel()}
113
+ </div>
114
+ {/if}
115
+ </div>
116
+
117
+ <!-- Bottom controls -->
118
+ {#if controls}
119
+ <div class="z-20 flex shrink-0 items-center justify-center p-3">
120
+ {@render controls()}
121
+ </div>
122
+ {/if}
123
+ </div>
@@ -0,0 +1,34 @@
1
+ /**
2
+ * MeetingRoom — the in-room layout shell.
3
+ *
4
+ * Arranges participant tiles in a responsive grid, or a focus layout (one
5
+ * large speaker/screen-share + a filmstrip). Reserves snippet slots for the
6
+ * control bar, the participant panel, and room chrome (e.g. RecordingBadge)
7
+ * so the consuming route wires behaviour while jatui owns the layout.
8
+ *
9
+ * Presentational: it renders whatever {@link MeetParticipant}[] you pass, so
10
+ * a demo drives it with mocked participants — no LiveKit server needed. The
11
+ * `/meet/[token]` route adapts real LiveKit tracks into these view-models.
12
+ */
13
+ import type { Snippet } from 'svelte';
14
+ import type { MeetParticipant, MeetLayout } from './types.js';
15
+ interface Props {
16
+ /** Participants to lay out (local + remotes). */
17
+ participants: MeetParticipant[];
18
+ /** Layout mode. `focus` auto-selects when someone shares a screen. */
19
+ layout?: MeetLayout;
20
+ /** Explicit focused participant id (overrides auto-focus). */
21
+ focusedId?: string;
22
+ /** Bottom control bar slot (usually <ControlBar />). */
23
+ controls?: Snippet;
24
+ /** Side/overlay participant panel slot (usually <ParticipantList />). */
25
+ panel?: Snippet;
26
+ /** Whether the side panel is shown. */
27
+ showPanel?: boolean;
28
+ /** Top-chrome slot — recording badge, room name, timer, lock icon. */
29
+ chrome?: Snippet;
30
+ class?: string;
31
+ }
32
+ declare const MeetingRoom: import("svelte").Component<Props, {}, "">;
33
+ type MeetingRoom = ReturnType<typeof MeetingRoom>;
34
+ export default MeetingRoom;
@@ -0,0 +1,133 @@
1
+ <script lang="ts">
2
+ /**
3
+ * ParticipantList — roster sidebar with per-participant host actions.
4
+ *
5
+ * Lists everyone in the room with mic/camera state; when `isHost`, each
6
+ * remote row exposes mute + remove actions. Presentational: driven by a
7
+ * plain {@link MeetParticipant}[] so demos need no server.
8
+ */
9
+ import type { MeetParticipant, MeetHostActions } from './types.js';
10
+
11
+ interface Props {
12
+ /** Everyone in the room, including the local participant. */
13
+ participants: MeetParticipant[];
14
+ /** Whether the viewer is a host (enables per-row actions). */
15
+ isHost?: boolean;
16
+ /** Host-action callbacks (mute/remove). */
17
+ hostActions?: MeetHostActions;
18
+ /** Panel heading. */
19
+ title?: string;
20
+ class?: string;
21
+ }
22
+
23
+ let {
24
+ participants,
25
+ isHost = false,
26
+ hostActions = {},
27
+ title = 'Participants',
28
+ class: className = ''
29
+ }: Props = $props();
30
+
31
+ function initials(name: string): string {
32
+ return (
33
+ name
34
+ .split(/\s+/)
35
+ .filter(Boolean)
36
+ .slice(0, 2)
37
+ .map((w) => w[0]?.toUpperCase() ?? '')
38
+ .join('') || '?'
39
+ );
40
+ }
41
+ </script>
42
+
43
+ <div class="flex flex-col bg-base-100 {className}" style="border-radius: var(--jatui-radius-box);">
44
+ <div class="flex items-center justify-between border-b border-base-content/10 px-4 py-3">
45
+ <h3 class="text-sm font-semibold">{title}</h3>
46
+ <span class="badge badge-sm badge-ghost">{participants.length}</span>
47
+ </div>
48
+
49
+ <ul class="flex-1 overflow-y-auto p-2">
50
+ {#each participants as p (p.id)}
51
+ <li
52
+ class="group flex items-center gap-3 px-2 py-2 hover:bg-base-200"
53
+ style="border-radius: var(--jatui-radius-field);"
54
+ >
55
+ <!-- Avatar / initials -->
56
+ <div class="relative shrink-0">
57
+ {#if p.avatarUrl}
58
+ <img src={p.avatarUrl} alt={p.name} class="h-9 w-9 rounded-full object-cover" />
59
+ {:else}
60
+ <div
61
+ class="flex h-9 w-9 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary"
62
+ >
63
+ {initials(p.name)}
64
+ </div>
65
+ {/if}
66
+ {#if p.isSpeaking}
67
+ <span
68
+ class="absolute -inset-0.5 rounded-full ring-2 ring-success"
69
+ aria-hidden="true"
70
+ ></span>
71
+ {/if}
72
+ </div>
73
+
74
+ <!-- Name + role -->
75
+ <div class="min-w-0 flex-1">
76
+ <div class="flex items-center gap-1.5">
77
+ <span class="truncate text-sm font-medium">{p.name}{p.isLocal ? ' (you)' : ''}</span>
78
+ {#if p.role === 'host'}
79
+ <span class="badge badge-xs badge-primary">Host</span>
80
+ {:else if p.role === 'observer'}
81
+ <span class="badge badge-xs badge-ghost">Observer</span>
82
+ {/if}
83
+ </div>
84
+ {#if p.isScreenSharing}
85
+ <span class="text-xs text-info">Sharing screen</span>
86
+ {/if}
87
+ </div>
88
+
89
+ <!-- Mic/cam status icons -->
90
+ <div class="flex items-center gap-1 text-base-content/50">
91
+ {#if p.micMuted}
92
+ <svg class="h-4 w-4 text-error" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-label="Muted" role="img">
93
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 5.586A2 2 0 004 7v6a4 4 0 004 4m2.828-1.172A4 4 0 0016 13V9m0-4a2 2 0 00-3.414-1.414M3 3l18 18" />
94
+ </svg>
95
+ {/if}
96
+ {#if p.cameraOff}
97
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-label="Camera off" role="img">
98
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M3 3l18 18M5 7h6M5 7a2 2 0 00-2 2v6a2 2 0 002 2h8" />
99
+ </svg>
100
+ {/if}
101
+ </div>
102
+
103
+ <!-- Host per-row actions -->
104
+ {#if isHost && !p.isLocal}
105
+ <div class="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
106
+ {#if !p.micMuted}
107
+ <button
108
+ type="button"
109
+ class="btn btn-ghost btn-xs btn-square"
110
+ aria-label={`Mute ${p.name}`}
111
+ onclick={() => hostActions.onMuteParticipant?.(p.id)}
112
+ >
113
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
114
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 5.586A2 2 0 004 7v6a4 4 0 004 4m2.828-1.172A4 4 0 0016 13V9m0-4a2 2 0 00-3.414-1.414M3 3l18 18" />
115
+ </svg>
116
+ </button>
117
+ {/if}
118
+ <button
119
+ type="button"
120
+ class="btn btn-ghost btn-xs btn-square text-error"
121
+ aria-label={`Remove ${p.name}`}
122
+ onclick={() => hostActions.onRemoveParticipant?.(p.id)}
123
+ >
124
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
125
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 6L6 18M6 6l12 12" />
126
+ </svg>
127
+ </button>
128
+ </div>
129
+ {/if}
130
+ </li>
131
+ {/each}
132
+ </ul>
133
+ </div>
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ParticipantList — roster sidebar with per-participant host actions.
3
+ *
4
+ * Lists everyone in the room with mic/camera state; when `isHost`, each
5
+ * remote row exposes mute + remove actions. Presentational: driven by a
6
+ * plain {@link MeetParticipant}[] so demos need no server.
7
+ */
8
+ import type { MeetParticipant, MeetHostActions } from './types.js';
9
+ interface Props {
10
+ /** Everyone in the room, including the local participant. */
11
+ participants: MeetParticipant[];
12
+ /** Whether the viewer is a host (enables per-row actions). */
13
+ isHost?: boolean;
14
+ /** Host-action callbacks (mute/remove). */
15
+ hostActions?: MeetHostActions;
16
+ /** Panel heading. */
17
+ title?: string;
18
+ class?: string;
19
+ }
20
+ declare const ParticipantList: import("svelte").Component<Props, {}, "">;
21
+ type ParticipantList = ReturnType<typeof ParticipantList>;
22
+ export default ParticipantList;