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.
Files changed (71) hide show
  1. package/dist/247420.css +33 -0
  2. package/dist/247420.js +53 -51
  3. package/package.json +1 -1
  4. package/src/components/agent-chat/controls.js +143 -0
  5. package/src/components/agent-chat/empty-state.js +63 -0
  6. package/src/components/agent-chat/message-rows.js +141 -0
  7. package/src/components/agent-chat/surface.js +206 -0
  8. package/src/components/agent-chat/thread-behaviour.js +50 -0
  9. package/src/components/agent-chat.js +6 -546
  10. package/src/components/chat/composer-affordances.js +109 -0
  11. package/src/components/chat/composer.js +256 -0
  12. package/src/components/chat/threads.js +105 -0
  13. package/src/components/chat-message-parts/agent-nodes.js +103 -0
  14. package/src/components/chat-message-parts/inline.js +106 -0
  15. package/src/components/chat-message-parts/prose-nodes.js +133 -0
  16. package/src/components/chat-message-parts/renderers.js +89 -0
  17. package/src/components/chat-message-parts.js +12 -408
  18. package/src/components/chat-minimap/minimap.js +167 -0
  19. package/src/components/chat-minimap/paint.js +73 -0
  20. package/src/components/chat-minimap/preview.js +41 -0
  21. package/src/components/chat-minimap.js +7 -263
  22. package/src/components/chat.js +20 -628
  23. package/src/components/community/chrome.js +63 -0
  24. package/src/components/community/navigation.js +167 -0
  25. package/src/components/community/presence.js +106 -0
  26. package/src/components/community/shell.js +17 -0
  27. package/src/components/community/views.js +131 -0
  28. package/src/components/community.js +18 -447
  29. package/src/components/files/chrome.js +140 -0
  30. package/src/components/files/entries.js +156 -0
  31. package/src/components/files/grid-controls.js +63 -0
  32. package/src/components/files/grid.js +177 -0
  33. package/src/components/files/types.js +69 -0
  34. package/src/components/files-modals/dialogs.js +118 -0
  35. package/src/components/files-modals/modal-shell.js +153 -0
  36. package/src/components/files-modals/preview-bodies.js +129 -0
  37. package/src/components/files-modals/preview-containers.js +96 -0
  38. package/src/components/files-modals.js +14 -475
  39. package/src/components/files.js +15 -564
  40. package/src/components/freddie/pages-config.js +3 -94
  41. package/src/components/freddie/pages-models.js +97 -0
  42. package/src/components/freddie.js +2 -1
  43. package/src/components/interaction-primitives/mobile.js +25 -0
  44. package/src/components/interaction-primitives/pointer.js +214 -0
  45. package/src/components/interaction-primitives/reorderable.js +47 -0
  46. package/src/components/interaction-primitives/shortcuts.js +119 -0
  47. package/src/components/interaction-primitives.js +16 -388
  48. package/src/components/sessions/conversation-list.js +230 -0
  49. package/src/components/sessions/dashboard.js +202 -0
  50. package/src/components/sessions/detail-bits.js +49 -0
  51. package/src/components/sessions/format.js +42 -0
  52. package/src/components/sessions/session-card.js +92 -0
  53. package/src/components/sessions.js +16 -579
  54. package/src/components/voice/audio-cue.js +39 -0
  55. package/src/components/voice/capture.js +90 -0
  56. package/src/components/voice/playback.js +75 -0
  57. package/src/components/voice/settings-modal.js +104 -0
  58. package/src/components/voice.js +16 -293
  59. package/src/css/app-shell/base.css +23 -0
  60. package/src/css/app-shell/states-interactions.css +10 -0
  61. package/src/kits/os/freddie/chat-protocol.js +32 -0
  62. package/src/kits/os/freddie/chat-transport.js +178 -0
  63. package/src/kits/os/freddie/pages-chat.js +10 -180
  64. package/src/kits/os/shell-chrome.js +163 -0
  65. package/src/kits/os/shell-geometry.js +59 -0
  66. package/src/kits/os/shell.js +178 -335
  67. package/src/page-html/client-script.js +151 -0
  68. package/src/page-html/head-tags.js +59 -0
  69. package/src/page-html/markdown.js +68 -0
  70. package/src/page-html/page-styles.js +44 -0
  71. 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
+ }