anentrypoint-design 0.0.385 → 0.0.387
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/dist/247420.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- package/src/page-html.js +14 -291
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Thin chrome bands around the main content column: the channel header, the
|
|
2
|
+
// narrow-viewport header with its menu/members toggles, the reply-quote bar,
|
|
3
|
+
// and the dismissible/actionable banner.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
import { CHANNEL_ICON_FOR } from './navigation.js';
|
|
8
|
+
const h = webjsx.createElement;
|
|
9
|
+
|
|
10
|
+
export function ChatHeader({ icon = '#', name, topic, toolbar = [] } = {}) {
|
|
11
|
+
return h('div', { class: 'cm-chat-header' },
|
|
12
|
+
h('span', { class: 'cm-chat-header-icon' }, icon),
|
|
13
|
+
h('span', { class: 'cm-chat-header-name' }, name),
|
|
14
|
+
topic ? h('span', { class: 'cm-chat-header-topic' }, topic) : null,
|
|
15
|
+
h('div', { class: 'cm-chat-header-toolbar' }, ...toolbar)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function MobileHeader({ title, channelType, channelName, onMenu, onMembers } = {}) {
|
|
20
|
+
const ICON_FOR = CHANNEL_ICON_FOR;
|
|
21
|
+
const titleNode = channelType
|
|
22
|
+
? [Icon(ICON_FOR[channelType] || 'hash', { size: 16 }), ' ' + (channelName || '')]
|
|
23
|
+
: [title || ''];
|
|
24
|
+
return h('div', { class: 'cm-mobile-header', role: 'banner' },
|
|
25
|
+
h('button', {
|
|
26
|
+
class: 'cm-mh-btn', type: 'button', onclick: onMenu,
|
|
27
|
+
title: 'Menu', 'aria-label': 'open navigation menu'
|
|
28
|
+
}, Icon('menu')),
|
|
29
|
+
h('span', { class: 'cm-mh-title' }, ...titleNode),
|
|
30
|
+
h('button', {
|
|
31
|
+
class: 'cm-mh-btn', type: 'button', onclick: onMembers,
|
|
32
|
+
title: 'Members', 'aria-label': 'show members'
|
|
33
|
+
}, Icon('members'))
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ReplyBar({ quotedMessage, quotedAuthor, onCancel } = {}) {
|
|
38
|
+
return h('div', { class: 'cm-reply-bar', role: 'status' },
|
|
39
|
+
h('span', { class: 'cm-rb-label' }, 'Replying to ',
|
|
40
|
+
h('strong', { class: 'cm-rb-author' }, quotedAuthor || 'unknown')
|
|
41
|
+
),
|
|
42
|
+
h('span', { class: 'cm-rb-preview', title: quotedMessage || '' }, quotedMessage || ''),
|
|
43
|
+
h('button', {
|
|
44
|
+
class: 'cm-rb-cancel', type: 'button', onclick: onCancel,
|
|
45
|
+
title: 'Cancel reply', 'aria-label': 'cancel reply'
|
|
46
|
+
}, Icon('x'))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Banner({ tone = 'info', message, visible, actionLabel, onAction, onClick } = {}) {
|
|
51
|
+
if (!visible || !message) return null;
|
|
52
|
+
return h('div', {
|
|
53
|
+
class: 'cm-banner tone-' + tone + (onClick ? ' clickable' : ''),
|
|
54
|
+
role: tone === 'error' || tone === 'warning' ? 'alert' : 'status',
|
|
55
|
+
onclick: onClick || null
|
|
56
|
+
},
|
|
57
|
+
h('span', { class: 'cm-banner-msg' }, message),
|
|
58
|
+
actionLabel ? h('button', {
|
|
59
|
+
class: 'cm-banner-action', type: 'button',
|
|
60
|
+
onclick: (e) => { e.stopPropagation(); onAction && onAction(e); }
|
|
61
|
+
}, actionLabel) : null
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Community navigation: the server rail and the channel sidebar it selects
|
|
2
|
+
// into — ServerIcon / ServerRail, ChannelItem (with its voice state and
|
|
3
|
+
// participant strip), ChannelCategory, and the composed ChannelSidebar.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
import { Avatar, avatarInitial } from '../content.js';
|
|
8
|
+
import { UserPanel } from './presence.js';
|
|
9
|
+
const h = webjsx.createElement;
|
|
10
|
+
|
|
11
|
+
// Channel-type -> line-icon name, shared by ChannelItem and MobileHeader.
|
|
12
|
+
export const CHANNEL_ICON_FOR = { voice: 'speaker', forum: 'forum', threaded: 'thread', announcement: 'megaphone', page: 'page', thread: 'thread', text: 'hash' };
|
|
13
|
+
|
|
14
|
+
export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
|
|
15
|
+
return h('div', {
|
|
16
|
+
class: 'cm-server-icon' + (active ? ' active' : ''),
|
|
17
|
+
onclick: onClick,
|
|
18
|
+
role: 'button',
|
|
19
|
+
'aria-label': name,
|
|
20
|
+
'aria-pressed': active ? 'true' : 'false',
|
|
21
|
+
tabindex: '0',
|
|
22
|
+
'data-id': id,
|
|
23
|
+
onkeydown: (e) => {
|
|
24
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
onClick && onClick(e);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
h('span', { class: 'cm-server-pill' }),
|
|
31
|
+
icon ? h('img', { src: icon, alt: name }) : Avatar({ name, shape: 'square', initialsCount: 2 }),
|
|
32
|
+
badge ? h('span', { class: 'cm-server-badge' }, badge > 99 ? '99+' : String(badge)) : null
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ServerRail({ servers = [], activeId, onSelect, onAdd } = {}) {
|
|
37
|
+
return h('div', { class: 'cm-server-rail', role: 'navigation', 'aria-label': 'servers' },
|
|
38
|
+
h('a', { class: 'cm-server-back', href: '../', title: 'Back', 'aria-label': 'back' }, Icon('chevron-left')),
|
|
39
|
+
h('div', { class: 'cm-server-sep', 'aria-hidden': 'true' }),
|
|
40
|
+
...servers.map(s => ServerIcon({ ...s, active: s.id === activeId, onClick: () => onSelect && onSelect(s.id) })),
|
|
41
|
+
onAdd ? h('button', { class: 'cm-server-add', type: 'button', onclick: onAdd, title: 'Add server', 'aria-label': 'add server' }, '+') : null
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ChannelItem({ id, name, type = 'text', active, voiceActive, voiceConnecting, badge, draggable, actions = [], participants = [], onClick, onContext } = {}) {
|
|
46
|
+
const ICON_FOR = CHANNEL_ICON_FOR;
|
|
47
|
+
const icon = Icon(ICON_FOR[type] || 'hash', { size: 15 });
|
|
48
|
+
const handleActionClick = (a, e) => { e.stopPropagation(); a.onClick && a.onClick(id, e); };
|
|
49
|
+
return h('div', { class: 'cm-channel-item-wrap', 'data-channel-wrap': id },
|
|
50
|
+
h('div', {
|
|
51
|
+
class: 'cm-channel-item' + (active ? ' active' : '') + (voiceActive ? ' voice-active' : '') + (voiceConnecting ? ' voice-connecting' : ''),
|
|
52
|
+
'data-id': id,
|
|
53
|
+
'data-type': type,
|
|
54
|
+
draggable: draggable ? 'true' : null,
|
|
55
|
+
onclick: onClick,
|
|
56
|
+
oncontextmenu: (e) => { e.preventDefault(); onContext && onContext(id, e.clientX, e.clientY); },
|
|
57
|
+
onkeydown: (e) => {
|
|
58
|
+
if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
61
|
+
onContext && onContext(id, rect.left, rect.top + rect.height);
|
|
62
|
+
}
|
|
63
|
+
if (draggable) {
|
|
64
|
+
if (e.ctrlKey && e.key === 'ArrowUp') {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
window.dispatchEvent(new CustomEvent('reorder', { detail: { id, direction: 'up' } }));
|
|
67
|
+
}
|
|
68
|
+
if (e.ctrlKey && e.key === 'ArrowDown') {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
window.dispatchEvent(new CustomEvent('reorder', { detail: { id, direction: 'down' } }));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
tabindex: '0',
|
|
75
|
+
role: 'option'
|
|
76
|
+
},
|
|
77
|
+
h('span', { class: 'cm-ch-icon' + (voiceActive ? ' voice-active-badge' : ''), 'data-voice-active': voiceActive ? 'true' : null }, icon),
|
|
78
|
+
voiceConnecting ? h('span', { class: 'cm-ch-spinner', title: 'Connecting…', 'aria-label': 'Connecting to voice channel…' }) : voiceActive ? h('span', { class: 'cm-ch-voice-badge', title: 'Voice active', 'aria-label': 'Voice channel active' }) : null,
|
|
79
|
+
h('span', { class: 'cm-ch-name' }, name),
|
|
80
|
+
badge ? h('span', { class: 'cm-ch-badge' }, badge > 99 ? '99+' : String(badge)) : null,
|
|
81
|
+
actions.length ? h('div', { class: 'cm-ch-actions' },
|
|
82
|
+
...actions.map(a => h('button', {
|
|
83
|
+
class: 'cm-ch-action-btn',
|
|
84
|
+
title: a.title || '',
|
|
85
|
+
'data-action': a.id || '',
|
|
86
|
+
onclick: (e) => handleActionClick(a, e)
|
|
87
|
+
}, a.icon || a.label || 'more'))
|
|
88
|
+
) : null
|
|
89
|
+
),
|
|
90
|
+
voiceActive && participants.length ? h('div', { class: 'cm-ch-voice-users' },
|
|
91
|
+
...participants.map(p => h('div', { class: 'cm-ch-voice-user' + (p.speaking ? ' speaking' : '') },
|
|
92
|
+
h('div', { class: 'cm-ch-voice-user-avatar', style: p.color ? `--avatar-bg:${p.color}` : null }, avatarInitial(p.identity)),
|
|
93
|
+
h('span', { class: 'cm-ch-voice-user-name' }, p.identity)
|
|
94
|
+
))
|
|
95
|
+
) : null
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ChannelCategory({ id, name, channels = [], collapsed, activeId, onToggle, onAddChannel, onChannelClick, onChannelContext, onContextMenu, extraButton, channelDraggable } = {}) {
|
|
100
|
+
return h('div', { class: 'cm-channel-category', 'data-category': id },
|
|
101
|
+
h('div', {
|
|
102
|
+
class: 'cm-category-header' + (collapsed ? ' collapsed' : ''),
|
|
103
|
+
onclick: () => onToggle && onToggle(id),
|
|
104
|
+
oncontextmenu: onContextMenu ? (e) => { e.preventDefault(); onContextMenu(id, e.clientX, e.clientY); } : null
|
|
105
|
+
},
|
|
106
|
+
h('span', { class: 'cm-cat-arrow' }, Icon('chevron-down')),
|
|
107
|
+
h('span', { class: 'cm-cat-name' }, name),
|
|
108
|
+
extraButton ? h('button', { class: 'cm-cat-extra', onclick: (e) => { e.stopPropagation(); extraButton.onClick && extraButton.onClick(id, e); }, 'aria-label': extraButton.title || 'Category action' }, extraButton.icon || extraButton.label || '+') : null,
|
|
109
|
+
onAddChannel ? h('button', { class: 'cm-cat-add', onclick: (e) => { e.stopPropagation(); onAddChannel(id); }, 'aria-label': 'Add channel to ' + name }, '+') : null
|
|
110
|
+
),
|
|
111
|
+
collapsed ? null : h('div', { class: 'cm-cat-channels' },
|
|
112
|
+
...channels.map(c => ChannelItem({
|
|
113
|
+
...c,
|
|
114
|
+
draggable: channelDraggable,
|
|
115
|
+
active: c.id === activeId,
|
|
116
|
+
onClick: () => onChannelClick && onChannelClick(c),
|
|
117
|
+
onContext: onChannelContext
|
|
118
|
+
}))
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Skeleton rows for a cold channel-list load — reuses the kit-wide .ds-skel
|
|
124
|
+
// shimmer primitive (sessions.js / files.js) rather than a bare spinner.
|
|
125
|
+
function ChannelListSkeleton({ rows = 6 } = {}) {
|
|
126
|
+
return h('div', { class: 'cm-channel-list cm-channel-skeleton', 'aria-hidden': 'true' },
|
|
127
|
+
...Array.from({ length: rows }, (_, i) => h('div', { key: 'csk' + i, class: 'cm-channel-item-skeleton' },
|
|
128
|
+
h('span', { class: 'ds-skel ds-skel-icon' }), h('span', { class: 'ds-skel ds-skel-title' }))));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function ChannelSidebar({ serverName, channels = [], categories = [], activeId, collapsedCats, onChannelClick, onCategoryToggle, onAddChannel, onChannelContext, userPanelProps, loading = false } = {}) {
|
|
132
|
+
const collapsed = collapsedCats || new Set();
|
|
133
|
+
const uncategorized = channels.filter(c => !c.categoryId || !categories.find(cat => cat.id === c.categoryId));
|
|
134
|
+
const sorted = [...categories].sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
135
|
+
return h('div', { class: 'cm-channel-sidebar' },
|
|
136
|
+
h('div', { class: 'cm-server-header' },
|
|
137
|
+
h('span', { class: 'cm-server-header-name' }, serverName || 'Server'),
|
|
138
|
+
),
|
|
139
|
+
loading ? ChannelListSkeleton() : h('div', { class: 'cm-channel-list' },
|
|
140
|
+
(sorted.length === 0 && uncategorized.length === 0)
|
|
141
|
+
? h('div', { class: 'cm-channel-empty', role: 'status' },
|
|
142
|
+
Icon('hash', { size: 20 }),
|
|
143
|
+
h('span', { class: 'cm-channel-empty-text' }, 'no channels yet — add one to get this server started'))
|
|
144
|
+
: null,
|
|
145
|
+
...sorted.map(cat => ChannelCategory({
|
|
146
|
+
id: cat.id,
|
|
147
|
+
name: cat.name,
|
|
148
|
+
channels: channels.filter(c => c.categoryId === cat.id).sort((a, b) => (a.position || 0) - (b.position || 0)),
|
|
149
|
+
collapsed: collapsed.has && collapsed.has(cat.id),
|
|
150
|
+
activeId,
|
|
151
|
+
onToggle: onCategoryToggle,
|
|
152
|
+
onAddChannel,
|
|
153
|
+
onChannelClick,
|
|
154
|
+
onChannelContext
|
|
155
|
+
})),
|
|
156
|
+
uncategorized.length ? ChannelCategory({
|
|
157
|
+
id: 'uncategorized',
|
|
158
|
+
name: 'CHANNELS',
|
|
159
|
+
channels: uncategorized,
|
|
160
|
+
activeId,
|
|
161
|
+
onChannelClick,
|
|
162
|
+
onChannelContext
|
|
163
|
+
}) : null
|
|
164
|
+
),
|
|
165
|
+
userPanelProps ? UserPanel(userPanelProps) : null
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Who-is-here surfaces: the speaking-state voice user chip, the local user's
|
|
2
|
+
// own mic/deafen/settings panel, the member roster, and the connected-voice
|
|
3
|
+
// control strip.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
import { avatarInitial } from '../content.js';
|
|
8
|
+
const h = webjsx.createElement;
|
|
9
|
+
|
|
10
|
+
export function VoiceUser({ identity, speaking, color } = {}) {
|
|
11
|
+
const initial = avatarInitial(identity);
|
|
12
|
+
return h('div', { class: 'cm-voice-user' + (speaking ? ' speaking' : '') },
|
|
13
|
+
h('div', { class: 'cm-voice-user-avatar', style: color ? `--avatar-bg:${color}` : null }, initial),
|
|
14
|
+
h('span', { class: 'cm-voice-user-name' }, identity)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function UserPanel({ name, tag, color, muted, deafened, onMute, onDeafen, onSettings } = {}) {
|
|
19
|
+
const initial = avatarInitial(name);
|
|
20
|
+
const handleSettings = (e) => {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
if (onSettings) {
|
|
23
|
+
// onSettings callback should open a drawer/modal with quick toggles
|
|
24
|
+
onSettings({
|
|
25
|
+
audioDevice: null, // controlled by consumer
|
|
26
|
+
micOn: !muted,
|
|
27
|
+
speakerOn: !deafened,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
return h('div', { class: 'cm-user-panel' },
|
|
32
|
+
h('div', { class: 'cm-user-avatar', style: color ? `--avatar-bg:${color}` : null },
|
|
33
|
+
h('span', { class: 'cm-user-status-dot' }),
|
|
34
|
+
initial
|
|
35
|
+
),
|
|
36
|
+
h('div', { class: 'cm-user-info' },
|
|
37
|
+
h('div', { class: 'cm-user-name' }, name || 'You'),
|
|
38
|
+
tag ? h('div', { class: 'cm-user-tag' }, tag) : null
|
|
39
|
+
),
|
|
40
|
+
h('div', { class: 'cm-user-controls' },
|
|
41
|
+
h('button', { class: 'cm-user-btn' + (muted ? ' muted' : ''), onclick: onMute, 'aria-label': muted ? 'Unmute microphone' : 'Mute microphone', 'aria-pressed': muted ? 'true' : 'false' }, Icon(muted ? 'mic-off' : 'mic')),
|
|
42
|
+
h('button', { class: 'cm-user-btn' + (deafened ? ' deafened' : ''), onclick: onDeafen, 'aria-label': deafened ? 'Undeafen' : 'Deafen', 'aria-pressed': deafened ? 'true' : 'false' }, Icon(deafened ? 'speaker-off' : 'speaker')),
|
|
43
|
+
h('button', { class: 'cm-user-btn', onclick: handleSettings, 'aria-label': 'Audio settings', title: 'Open audio settings' }, Icon('settings'))
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function MemberItem({ identity, name, color, status = 'online' } = {}) {
|
|
49
|
+
const initial = avatarInitial(name || identity);
|
|
50
|
+
return h('div', { class: 'cm-member-item' },
|
|
51
|
+
h('div', { class: 'cm-member-avatar', style: color ? `--avatar-bg:${color}` : null },
|
|
52
|
+
h('span', { class: 'cm-member-status' + (status === 'online' ? ' online' : '') }),
|
|
53
|
+
initial
|
|
54
|
+
),
|
|
55
|
+
h('span', { class: 'cm-member-name' }, name || identity)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Skeleton rows for a cold member-list load, matching FileSkeleton/session
|
|
60
|
+
// skeleton shape (icon + title placeholder), never a bare spinner.
|
|
61
|
+
function MemberListSkeleton({ rows = 6 } = {}) {
|
|
62
|
+
return h('div', { class: 'cm-member-list cm-member-skeleton open', 'aria-hidden': 'true' },
|
|
63
|
+
...Array.from({ length: rows }, (_, i) => h('div', { key: 'msk' + i, class: 'cm-member-item-skeleton' },
|
|
64
|
+
h('span', { class: 'ds-skel ds-skel-icon' }), h('span', { class: 'ds-skel ds-skel-title' }))));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function MemberList({ categories = [], open, loading = false } = {}) {
|
|
68
|
+
if (loading) return MemberListSkeleton();
|
|
69
|
+
const total = categories.reduce((n, cat) => n + (cat.members ? cat.members.length : 0), 0);
|
|
70
|
+
return h('div', { class: 'cm-member-list' + (open ? ' open' : '') },
|
|
71
|
+
total === 0
|
|
72
|
+
? h('div', { key: '_empty', class: 'cm-member-empty', role: 'status' },
|
|
73
|
+
Icon('members', { size: 20 }),
|
|
74
|
+
h('span', { class: 'cm-member-empty-text' }, 'no members in this channel yet'))
|
|
75
|
+
: null,
|
|
76
|
+
...categories.flatMap(cat => [
|
|
77
|
+
h('div', { class: 'cm-member-category', key: cat.label }, `${cat.label} — ${cat.members.length}`),
|
|
78
|
+
...cat.members.map((m, i) => MemberItem({ ...m, key: m.identity || i }))
|
|
79
|
+
])
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function VoiceStrip({ channelName, status, muted, deafened, onMute, onDeafen, onLeave, open } = {}) {
|
|
84
|
+
return h('div', { class: 'cm-voice-strip' + (open ? ' open' : ''), role: 'region', 'aria-label': 'voice controls' },
|
|
85
|
+
h('div', { class: 'cm-vs-label' },
|
|
86
|
+
h('span', { class: 'cm-vs-channel' }, Icon('speaker'), ' ' + (channelName || 'voice')),
|
|
87
|
+
h('span', { class: 'cm-vs-status' }, status || 'connected')
|
|
88
|
+
),
|
|
89
|
+
h('button', {
|
|
90
|
+
class: 'cm-vs-btn', type: 'button', onclick: onMute,
|
|
91
|
+
title: muted ? 'Unmute' : 'Mute',
|
|
92
|
+
'aria-label': muted ? 'unmute microphone' : 'mute microphone',
|
|
93
|
+
'aria-pressed': muted ? 'true' : 'false'
|
|
94
|
+
}, Icon(muted ? 'mic-off' : 'mic')),
|
|
95
|
+
h('button', {
|
|
96
|
+
class: 'cm-vs-btn', type: 'button', onclick: onDeafen,
|
|
97
|
+
title: deafened ? 'Undeafen' : 'Deafen',
|
|
98
|
+
'aria-label': deafened ? 'undeafen' : 'deafen',
|
|
99
|
+
'aria-pressed': deafened ? 'true' : 'false'
|
|
100
|
+
}, Icon(deafened ? 'speaker-off' : 'speaker')),
|
|
101
|
+
h('button', {
|
|
102
|
+
class: 'cm-vs-btn danger', type: 'button', onclick: onLeave,
|
|
103
|
+
title: 'Leave voice', 'aria-label': 'leave voice channel'
|
|
104
|
+
}, Icon('x'))
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// CommunityShell — the four-column community frame: server rail, channel
|
|
2
|
+
// sidebar, main content column, member list, plus the floating voice strip.
|
|
3
|
+
|
|
4
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
5
|
+
import { ServerRail, ChannelSidebar } from './navigation.js';
|
|
6
|
+
import { MemberList, VoiceStrip } from './presence.js';
|
|
7
|
+
const h = webjsx.createElement;
|
|
8
|
+
|
|
9
|
+
export function CommunityShell({ serverRailProps, sidebarProps, children, memberListProps, voiceStripProps } = {}) {
|
|
10
|
+
return h('div', { class: 'cm-shell' },
|
|
11
|
+
serverRailProps ? ServerRail(serverRailProps) : null,
|
|
12
|
+
sidebarProps ? ChannelSidebar(sidebarProps) : null,
|
|
13
|
+
h('div', { class: 'cm-main' }, ...(Array.isArray(children) ? children : [children])),
|
|
14
|
+
memberListProps ? MemberList(memberListProps) : null,
|
|
15
|
+
voiceStripProps ? VoiceStrip(voiceStripProps) : null
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The three non-chat channel content views — ThreadPanel, ForumView, and the
|
|
2
|
+
// sanitized-HTML PageView — plus the shared relative-time and list-skeleton
|
|
3
|
+
// helpers they render through.
|
|
4
|
+
|
|
5
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
6
|
+
import { Icon } from '../shell.js';
|
|
7
|
+
import { sanitizeHtml } from '../../markdown.js';
|
|
8
|
+
const h = webjsx.createElement;
|
|
9
|
+
|
|
10
|
+
// Clamp a count to a compact badge string (matches the rail's 99+ convention),
|
|
11
|
+
// so a runaway number never blows out a fixed-width badge or item row.
|
|
12
|
+
const clampCount = (n) => { const v = Number(n) || 0; return v > 99 ? '99+' : String(v); };
|
|
13
|
+
|
|
14
|
+
function fmtRelTime(ts) {
|
|
15
|
+
const t = Number(ts) || 0;
|
|
16
|
+
if (!t) return '';
|
|
17
|
+
const ms = t > 1e12 ? t : t * 1000;
|
|
18
|
+
const d = Math.max(0, Date.now() - ms);
|
|
19
|
+
const m = Math.floor(d / 60000);
|
|
20
|
+
if (m < 1) return 'now';
|
|
21
|
+
if (m < 60) return m + 'm';
|
|
22
|
+
const hr = Math.floor(m / 60);
|
|
23
|
+
if (hr < 24) return hr + 'h';
|
|
24
|
+
return Math.floor(hr / 24) + 'd';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Skeleton rows for a cold thread/post-list load. Two lines per row (title +
|
|
28
|
+
// meta) mirrors cm-tp-item/cm-forum-item's actual shape so the shimmer
|
|
29
|
+
// doesn't jump on load. Reuses the kit-wide .ds-skel shimmer primitive.
|
|
30
|
+
function ListSkeleton({ cls, rows = 5 } = {}) {
|
|
31
|
+
return h('div', { class: cls + ' cm-list-skeleton', 'aria-hidden': 'true' },
|
|
32
|
+
...Array.from({ length: rows }, (_, i) => h('div', { key: 'lsk' + i, class: 'cm-list-item-skeleton' },
|
|
33
|
+
h('span', { class: 'ds-skel ds-skel-title' }), h('span', { class: 'ds-skel ds-skel-meta' }))));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ThreadPanel({ threads = [], activeId = null, title = 'Threads', onSelect, onCreate, onClose, loading = false } = {}) {
|
|
37
|
+
const list = Array.isArray(threads) ? threads : [];
|
|
38
|
+
return h('div', { class: 'cm-thread-panel', role: 'complementary', 'aria-label': title },
|
|
39
|
+
h('div', { class: 'cm-tp-head' },
|
|
40
|
+
h('span', { class: 'cm-tp-title' }, title),
|
|
41
|
+
h('div', { class: 'cm-tp-head-actions' },
|
|
42
|
+
onCreate ? h('button', { type: 'button', class: 'cm-tp-new', 'aria-label': 'new thread', title: 'New thread', onclick: onCreate }, '+') : null,
|
|
43
|
+
onClose ? h('button', { type: 'button', class: 'cm-tp-close', 'aria-label': 'close', title: 'Close', onclick: onClose }, Icon('x')) : null
|
|
44
|
+
)
|
|
45
|
+
),
|
|
46
|
+
loading ? ListSkeleton({ cls: 'cm-tp-list' }) : h('div', { class: 'cm-tp-list' },
|
|
47
|
+
list.length
|
|
48
|
+
? list.map(t => h('button', {
|
|
49
|
+
type: 'button', key: 'tp-' + t.id,
|
|
50
|
+
class: 'cm-tp-item' + (t.id === activeId ? ' is-active' : '') + (t.unread ? ' is-unread' : ''),
|
|
51
|
+
onclick: () => onSelect && onSelect(t.id)
|
|
52
|
+
},
|
|
53
|
+
t.unread ? h('span', { class: 'cm-tp-dot', 'aria-hidden': 'true' }) : null,
|
|
54
|
+
h('span', { class: 'cm-tp-item-title' }, t.title || '(untitled)'),
|
|
55
|
+
t.lastMessage ? h('span', { class: 'cm-tp-item-snippet' }, t.lastMessage) : null,
|
|
56
|
+
h('span', { class: 'cm-tp-item-meta' },
|
|
57
|
+
t.author ? h('span', { class: 'cm-tp-item-author' }, t.author) : null,
|
|
58
|
+
t.time ? h('span', { class: 'cm-tp-item-time' }, fmtRelTime(t.time)) : null
|
|
59
|
+
)
|
|
60
|
+
))
|
|
61
|
+
: h('div', { class: 'cm-tp-empty', role: 'status' },
|
|
62
|
+
Icon('thread', { size: 20 }),
|
|
63
|
+
h('span', { class: 'cm-tp-empty-text' }, onCreate ? 'no threads yet — start one' : 'no threads yet'))
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ForumView({ posts = [], onSearch, onSort, onSelect, onNewPost, loading = false } = {}) {
|
|
69
|
+
const list = Array.isArray(posts) ? posts : [];
|
|
70
|
+
return h('div', { class: 'cm-forum', role: 'region', 'aria-label': 'forum' },
|
|
71
|
+
h('div', { class: 'cm-forum-toolbar' },
|
|
72
|
+
h('input', {
|
|
73
|
+
type: 'search', class: 'cm-forum-search', placeholder: 'Search posts…',
|
|
74
|
+
'aria-label': 'search posts',
|
|
75
|
+
oninput: onSearch ? (e) => onSearch(e.target.value) : null
|
|
76
|
+
}),
|
|
77
|
+
h('select', {
|
|
78
|
+
class: 'cm-forum-sort', 'aria-label': 'sort posts',
|
|
79
|
+
onchange: onSort ? (e) => onSort(e.target.value) : null
|
|
80
|
+
},
|
|
81
|
+
h('option', { value: 'recent' }, 'Recent'),
|
|
82
|
+
h('option', { value: 'replies' }, 'Most replies'),
|
|
83
|
+
h('option', { value: 'oldest' }, 'Oldest')
|
|
84
|
+
),
|
|
85
|
+
onNewPost ? h('button', { type: 'button', class: 'cm-forum-new', onclick: onNewPost }, 'New post') : null
|
|
86
|
+
),
|
|
87
|
+
loading ? ListSkeleton({ cls: 'cm-forum-list' }) : h('div', { class: 'cm-forum-list' },
|
|
88
|
+
list.length
|
|
89
|
+
? list.map(p => h('button', {
|
|
90
|
+
type: 'button', key: 'fp-' + p.id, class: 'cm-forum-item',
|
|
91
|
+
onclick: () => onSelect && onSelect(p.id)
|
|
92
|
+
},
|
|
93
|
+
h('div', { class: 'cm-forum-item-head' },
|
|
94
|
+
h('span', { class: 'cm-forum-item-title' }, p.title || '(untitled)'),
|
|
95
|
+
h('span', { class: 'cm-forum-item-replies' }, clampCount(p.replyCount), Icon('chevron-right', { size: 13 }))
|
|
96
|
+
),
|
|
97
|
+
p.snippet ? h('div', { class: 'cm-forum-item-snippet' }, p.snippet) : null,
|
|
98
|
+
h('div', { class: 'cm-forum-item-meta' },
|
|
99
|
+
p.author ? h('span', { class: 'cm-forum-item-author' }, p.author) : null,
|
|
100
|
+
p.time ? h('span', { class: 'cm-forum-item-time' }, fmtRelTime(p.time)) : null,
|
|
101
|
+
Array.isArray(p.tags) && p.tags.length
|
|
102
|
+
? h('span', { class: 'cm-forum-item-tags' }, ...p.tags.map((tag, i) =>
|
|
103
|
+
h('span', { class: 'cm-forum-tag', key: 'tg-' + i }, tag)))
|
|
104
|
+
: null
|
|
105
|
+
)
|
|
106
|
+
))
|
|
107
|
+
: h('div', { class: 'cm-forum-empty', role: 'status' },
|
|
108
|
+
Icon('forum', { size: 20 }),
|
|
109
|
+
h('span', { class: 'cm-forum-empty-text' }, onNewPost ? 'no posts yet — start the discussion' : 'no posts yet'))
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function PageView({ title = '', html = '', isAdmin = false, onEdit } = {}) {
|
|
115
|
+
return h('div', { class: 'cm-page', role: 'document' },
|
|
116
|
+
h('div', { class: 'cm-page-head' },
|
|
117
|
+
h('h1', { class: 'cm-page-title' }, title || ''),
|
|
118
|
+
isAdmin && onEdit ? h('button', { type: 'button', class: 'cm-page-edit', onclick: onEdit }, 'Edit') : null
|
|
119
|
+
),
|
|
120
|
+
h('div', {
|
|
121
|
+
class: 'cm-page-body',
|
|
122
|
+
// Page bodies are host/user-authored HTML, so they pass through the
|
|
123
|
+
// DOMPurify gate before innerHTML — never injected raw (stored-XSS gate).
|
|
124
|
+
ref: (el) => {
|
|
125
|
+
if (!el) return;
|
|
126
|
+
if (!html) { el.innerHTML = '<p class="cm-page-empty">This page is empty.</p>'; return; }
|
|
127
|
+
sanitizeHtml(html).then((clean) => { el.innerHTML = clean; });
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
}
|