@3sln/trove 0.0.2
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/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Social UI: the notification bell (title bar) and the info panel (a right rail
|
|
2
|
+
// with the active file's tags and threaded conversation). Both render from the
|
|
3
|
+
// reactive SocialService; actions call back into it.
|
|
4
|
+
|
|
5
|
+
import { dd } from '../../runtime.js';
|
|
6
|
+
import { icon } from '../icon.js';
|
|
7
|
+
import { relativeDate } from '../format.js';
|
|
8
|
+
import { OpenFileAction } from '../../bl/actions.js';
|
|
9
|
+
import { troveUri } from '@3sln/trove/core/links.js';
|
|
10
|
+
|
|
11
|
+
const { div, span, button, textarea, input, p } = dd;
|
|
12
|
+
|
|
13
|
+
const REACTIONS = ['👍', '❤️', '🎉', '👀', '🚀'];
|
|
14
|
+
|
|
15
|
+
// ---- title-bar principal chip + bell ---------------------------------------
|
|
16
|
+
|
|
17
|
+
export function principalChip(state) {
|
|
18
|
+
const me = state.so.me;
|
|
19
|
+
// No identity, no profile. On a deployment with no login the server hands back a
|
|
20
|
+
// shared anonymous stand-in; rendering an avatar and a name for it would describe a
|
|
21
|
+
// user who does not exist, and imply an account there is no way to sign in or out of.
|
|
22
|
+
if (!me || me.anonymous) return null;
|
|
23
|
+
const initial = (me.name || me.id || '?')[0].toUpperCase();
|
|
24
|
+
return div({ className: 'principal', title: me.email || me.name || me.id },
|
|
25
|
+
me.picture ? dd.img({ src: me.picture, alt: '', className: 'avatar-img' }) : span({ className: 'avatar-txt' }, initial),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function notificationBell(state, ui) {
|
|
30
|
+
const n = state.so.notifications;
|
|
31
|
+
const social = ui.app.social;
|
|
32
|
+
return div({ className: 'bell-wrap' },
|
|
33
|
+
button({ className: 'iconbtn bell', title: 'Notifications' },
|
|
34
|
+
icon('bell', { size: 19 }),
|
|
35
|
+
n.unread ? span({ className: 'bell-badge' }, String(n.unread > 9 ? '9+' : n.unread)) : null,
|
|
36
|
+
).on({ click: () => social.toggleInbox() }),
|
|
37
|
+
state.so.inboxOpen ? inboxDropdown(state, ui) : null,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function inboxDropdown(state, ui) {
|
|
42
|
+
const items = state.so.notifications.items;
|
|
43
|
+
const social = ui.app.social;
|
|
44
|
+
return div({},
|
|
45
|
+
div({ className: 'scrim', $styling: { background: 'transparent', 'z-index': '54' } }).on({ click: () => social.toggleInbox(false) }),
|
|
46
|
+
div({ className: 'inbox' },
|
|
47
|
+
div({ className: 'inbox-head' },
|
|
48
|
+
span('Notifications'),
|
|
49
|
+
state.so.pushSupported && !state.so.pushEnabled
|
|
50
|
+
? button({ className: 'link' }, 'Enable push').on({ click: () => social.enablePush() })
|
|
51
|
+
: state.so.pushEnabled ? span({ className: 'muted' }, icon('check', { size: 13 })) : null,
|
|
52
|
+
),
|
|
53
|
+
items.length
|
|
54
|
+
? div({ className: 'inbox-items' }, ...items.slice(0, 30).map((note) => inboxItem(note, ui)))
|
|
55
|
+
: div({ className: 'inbox-empty' }, icon('info', { size: 24 }), span('You’re all caught up.')),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function inboxItem(note, ui) {
|
|
61
|
+
const first = note.items?.[0];
|
|
62
|
+
return div({ className: `inbox-item ${note.read ? '' : 'unread'}` },
|
|
63
|
+
div({ className: 'ii-title' }, note.title),
|
|
64
|
+
first?.excerpt ? div({ className: 'ii-excerpt' }, '“' + first.excerpt + '”') : null,
|
|
65
|
+
div({ className: 'ii-time' }, relativeDate(note.createdAt)),
|
|
66
|
+
).on({
|
|
67
|
+
click: () => {
|
|
68
|
+
if (first?.nodeId) {
|
|
69
|
+
ui.platform.api.stat(first.nodeId).then((r) => {
|
|
70
|
+
ui.go(new OpenFileAction(r.node));
|
|
71
|
+
ui.platform.workbench.toggleInfoPanel(true);
|
|
72
|
+
}).catch((err) => {
|
|
73
|
+
// The item a notification points at can be deleted, or live somewhere the
|
|
74
|
+
// reader lost access to. Either way the click must not just do nothing.
|
|
75
|
+
ui.platform.notifications.warn(
|
|
76
|
+
err?.status === 403 || err?.code === 'forbidden'
|
|
77
|
+
? 'You no longer have access to that item.'
|
|
78
|
+
: 'That item no longer exists.',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
ui.app.social.toggleInbox(false);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- info panel (tags + conversation) --------------------------------------
|
|
88
|
+
|
|
89
|
+
export function infoPanel(state, ui) {
|
|
90
|
+
const nav = state.nav;
|
|
91
|
+
const active = nav.activeFile ? { id: nav.activeTabId, node: nav.activeFile } : null;
|
|
92
|
+
const sc = state.so.sidecar;
|
|
93
|
+
return div({ className: 'infopanel' },
|
|
94
|
+
div({ className: 'ip-head' },
|
|
95
|
+
icon('info', { size: 15 }),
|
|
96
|
+
span('Details'),
|
|
97
|
+
button({ className: 'iconbtn', title: 'Close' }, icon('close', { size: 14 })).on({ click: () => ui.platform.workbench.toggleInfoPanel(false) }),
|
|
98
|
+
),
|
|
99
|
+
!active
|
|
100
|
+
? div({ className: 'ip-empty' }, span('Open a file to see its tags and conversation.'))
|
|
101
|
+
: div({ className: 'ip-body' },
|
|
102
|
+
fileHeader(active.node, state, ui),
|
|
103
|
+
linkSection(active.node, state, ui),
|
|
104
|
+
tagSection(sc, ui),
|
|
105
|
+
conversationSection(state, sc, ui),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fileHeader(node, state, ui) {
|
|
111
|
+
const pinned = ui.app.offline.isPinned(node.id);
|
|
112
|
+
return div({ className: 'ip-file' },
|
|
113
|
+
div({ className: 'ip-name' }, node.name),
|
|
114
|
+
div({ className: 'ip-path' }, node.contentType || ''),
|
|
115
|
+
// Secondary, not primary. Pinning a file for offline is a useful thing to be able
|
|
116
|
+
// to do and not the thing you came to this panel for — as the brightest element on
|
|
117
|
+
// screen it out-shouted the file's own links, tags and conversation.
|
|
118
|
+
button({ className: `btn small ${pinned ? 'on' : ''}`, $styling: { 'margin-top': '10px' } },
|
|
119
|
+
icon(pinned ? 'check' : 'download', { size: 14 }),
|
|
120
|
+
pinned ? 'Available offline' : 'Make available offline',
|
|
121
|
+
).on({ click: () => (pinned ? ui.exec('offline.unpin', node) : ui.exec('offline.pin', node)) }),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The item's own `trove:` link, and what links to it.
|
|
127
|
+
*
|
|
128
|
+
* With no folders, backlinks are the answer to "where does this live?" — the documents
|
|
129
|
+
* that gather it up. A load failure says so rather than rendering as an empty list,
|
|
130
|
+
* which would read as a fact about the drive instead of about the request.
|
|
131
|
+
*/
|
|
132
|
+
function linkSection(node, state, ui) {
|
|
133
|
+
const bl = state.so.backlinks;
|
|
134
|
+
const uri = node.collectionId ? troveUri(node) : null;
|
|
135
|
+
const mine = bl && bl.nodeId === node.id ? bl : null;
|
|
136
|
+
return div({ className: 'ip-section' },
|
|
137
|
+
div({ className: 'ip-label' }, 'Links'),
|
|
138
|
+
uri
|
|
139
|
+
? button({ className: 'ip-uri', title: 'Copy this item’s link' }, icon('link', { size: 12 }), span(uri))
|
|
140
|
+
.on({ click: () => copyLink(ui, uri) })
|
|
141
|
+
: null,
|
|
142
|
+
div({ className: 'ip-label', $styling: { 'margin-top': '10px' } }, 'Linked from'),
|
|
143
|
+
mine?.loading
|
|
144
|
+
? div({ className: 'ip-muted' }, 'Loading…')
|
|
145
|
+
: mine?.error
|
|
146
|
+
? div({ className: 'ip-muted error' }, `Couldn’t load links: ${mine.error}`)
|
|
147
|
+
: mine?.items?.length
|
|
148
|
+
? div({ className: 'ip-backlinks' }, ...mine.items.map((n) =>
|
|
149
|
+
button({ className: 'ip-backlink', title: n.name }, icon('file-text', { size: 12 }), span(n.name))
|
|
150
|
+
.on({ click: () => ui.go(new OpenFileAction(n)) })))
|
|
151
|
+
: div({ className: 'ip-muted' }, 'Nothing links here yet.'),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function copyLink(ui, uri) {
|
|
156
|
+
try {
|
|
157
|
+
await navigator.clipboard.writeText(uri);
|
|
158
|
+
ui.platform.notifications.success(`Copied ${uri}`);
|
|
159
|
+
} catch {
|
|
160
|
+
ui.platform.notifications.info(uri, { sticky: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function tagSection(sc, ui) {
|
|
165
|
+
const tags = sc?.tags || [];
|
|
166
|
+
return div({ className: 'ip-section' },
|
|
167
|
+
div({ className: 'ip-label' }, 'Tags'),
|
|
168
|
+
div({ className: 'tags' },
|
|
169
|
+
...tags.map((t) =>
|
|
170
|
+
span({ className: 'tag' },
|
|
171
|
+
t.value ? `${t.name}: ${t.value}` : t.name,
|
|
172
|
+
button({ className: 'tag-x' }, icon('close', { size: 11 })).on({ click: () => ui.app.social.removeTag(t.name) }),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
input({ className: 'tag-input', placeholder: '+ tag', value: '' }).on({
|
|
176
|
+
keydown: (e) => {
|
|
177
|
+
if (e.key === 'Enter' && e.target.value.trim()) {
|
|
178
|
+
const raw = e.target.value.trim();
|
|
179
|
+
const [name, ...rest] = raw.split(':');
|
|
180
|
+
ui.app.social.addTag(name.trim(), rest.join(':').trim() || undefined);
|
|
181
|
+
e.target.value = '';
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function conversationSection(state, sc, ui) {
|
|
190
|
+
if (sc?.loading) return div({ className: 'ip-section' }, div({ className: 'spinner' }));
|
|
191
|
+
const comments = sc?.comments || [];
|
|
192
|
+
// On a load failure, don't render a false "No comments yet" — say it couldn't load
|
|
193
|
+
// and offer a retry, so the user doesn't post into what looks like an empty thread.
|
|
194
|
+
const body = sc?.error
|
|
195
|
+
? div({ className: 'conv-empty' },
|
|
196
|
+
span(`Couldn't load the conversation: ${sc.error}`),
|
|
197
|
+
button({ className: 'btn', $styling: { 'margin-top': '8px' } }, 'Retry').on({ click: () => ui.app.social.loadSidecar(sc.nodeId) }))
|
|
198
|
+
: comments.length
|
|
199
|
+
? div({ className: 'thread' }, ...comments.map((c) => commentNode(c, state, ui, 0)))
|
|
200
|
+
: div({ className: 'conv-empty' }, 'No comments yet. Start the discussion — use @ to mention someone.');
|
|
201
|
+
return div({ className: 'ip-section conv' },
|
|
202
|
+
div({ className: 'ip-label' }, `Conversation${sc?.commentCount ? ` · ${sc.commentCount}` : ''}`),
|
|
203
|
+
body,
|
|
204
|
+
sc?.error ? null : composer(state, ui),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function commentNode(c, state, ui, depth) {
|
|
209
|
+
const me = state.so.me;
|
|
210
|
+
const social = ui.app.social;
|
|
211
|
+
const mine = me && c.author?.id === me.id;
|
|
212
|
+
return div({ className: 'comment', $styling: depth ? { marginLeft: '18px' } : {} },
|
|
213
|
+
div({ className: 'c-head' },
|
|
214
|
+
span({ className: 'c-avatar' }, (c.author?.name || '?')[0].toUpperCase()),
|
|
215
|
+
span({ className: 'c-author' }, c.author?.name || c.author?.id || 'Someone'),
|
|
216
|
+
span({ className: 'c-time' }, relativeDate(c.createdAt), c.edited ? ' · edited' : ''),
|
|
217
|
+
),
|
|
218
|
+
c.deleted
|
|
219
|
+
? div({ className: 'c-body deleted' }, 'comment deleted')
|
|
220
|
+
: div({ className: 'c-body' }, renderBody(c.body)),
|
|
221
|
+
!c.deleted ? div({ className: 'c-actions' },
|
|
222
|
+
...REACTIONS.map((emoji) => {
|
|
223
|
+
const users = c.reactions?.[emoji] || [];
|
|
224
|
+
return button({ className: `react ${users.includes(me?.id) ? 'on' : ''}` }, emoji, users.length ? span({ className: 'rc' }, String(users.length)) : null)
|
|
225
|
+
.on({ click: () => social.react(c.id, emoji) });
|
|
226
|
+
}),
|
|
227
|
+
button({ className: 'c-link' }, 'Reply').on({ click: () => social.setReplyTo({ id: c.id, author: c.author }) }),
|
|
228
|
+
mine ? button({ className: 'c-link danger' }, 'Delete').on({ click: () => social.deleteComment(c.id) }) : null,
|
|
229
|
+
) : null,
|
|
230
|
+
(c.replies || []).length ? div({ className: 'replies' }, ...c.replies.map((r) => commentNode(r, state, ui, depth + 1))) : null,
|
|
231
|
+
).key(c.id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function composer(state, ui) {
|
|
235
|
+
const social = ui.app.social;
|
|
236
|
+
const me = state.so.me;
|
|
237
|
+
const replyTo = state.so.replyTo;
|
|
238
|
+
// `me` is null only when /api/me didn't answer — offline, or refused. It is NOT the
|
|
239
|
+
// anonymous case (an anonymous deployment returns a real anonymous principal), so
|
|
240
|
+
// "Sign in to join the conversation" was shown to people with nowhere to sign in,
|
|
241
|
+
// and to people who were merely offline. Say which it is, and offer the one action
|
|
242
|
+
// that helps: Trove ships no login of its own, so signing in means letting the
|
|
243
|
+
// identity proxy in front of it redirect a fresh page load.
|
|
244
|
+
if (!me) {
|
|
245
|
+
if (!state.off?.online) {
|
|
246
|
+
return div({ className: 'conv-signin' }, 'You’re offline. Conversations come back when you reconnect.');
|
|
247
|
+
}
|
|
248
|
+
return div({ className: 'conv-signin' },
|
|
249
|
+
span('You’re signed out, so you can read this conversation but not add to it.'),
|
|
250
|
+
button({ className: 'c-link' }, 'Reload to sign in').on({ click: () => window.location.reload() }),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return div({ className: 'composer' },
|
|
254
|
+
replyTo ? div({ className: 'replying' }, `Replying to ${replyTo.author?.name || 'comment'}`,
|
|
255
|
+
button({ className: 'c-link' }, 'cancel').on({ click: () => social.setReplyTo(null) })) : null,
|
|
256
|
+
textarea({ className: 'composer-input', placeholder: 'Write a comment… @mention someone', rows: 2, value: '' }).on({
|
|
257
|
+
keydown: (e) => {
|
|
258
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
259
|
+
social.comment(e.target.value);
|
|
260
|
+
e.target.value = '';
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
}),
|
|
264
|
+
div({ className: 'composer-actions' },
|
|
265
|
+
span({ className: 'muted' }, '⌘/Ctrl + Enter'),
|
|
266
|
+
button({ className: 'btn primary', disabled: state.so.posting }, 'Comment').on({
|
|
267
|
+
click: (e) => {
|
|
268
|
+
const box = e.target.closest('.composer').querySelector('.composer-input');
|
|
269
|
+
social.comment(box.value);
|
|
270
|
+
box.value = '';
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Render @[Name](id) tokens and bare @handles as highlighted mentions.
|
|
278
|
+
function renderBody(text) {
|
|
279
|
+
const out = [];
|
|
280
|
+
const re = /@\[([^\]]+)\]\(([^)]+)\)|(?:^|\s)@([a-zA-Z0-9._@-]{2,})/g;
|
|
281
|
+
let last = 0;
|
|
282
|
+
let m;
|
|
283
|
+
while ((m = re.exec(text))) {
|
|
284
|
+
if (m.index > last) out.push(text.slice(last, m.index + (m[3] ? (m[0].startsWith(' ') ? 1 : 0) : 0)));
|
|
285
|
+
out.push(span({ className: 'mention' }, '@' + (m[1] || m[3])));
|
|
286
|
+
last = re.lastIndex;
|
|
287
|
+
}
|
|
288
|
+
if (last < text.length) out.push(text.slice(last));
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { dd } from '../../runtime.js';
|
|
2
|
+
import { icon } from '../icon.js';
|
|
3
|
+
import { bytes } from '../format.js';
|
|
4
|
+
import { sanitizedVNodes, htmlToText } from '../sanitize.js';
|
|
5
|
+
|
|
6
|
+
const { div, button, span } = dd;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A plugin's status slot. The manifest declares the slot (name + side); the plugin
|
|
10
|
+
* pushes content into it at runtime over RPC. The content is untrusted HTML from a
|
|
11
|
+
* sandboxed frame, so it goes through the allowlist sanitizer before it becomes nodes.
|
|
12
|
+
* A slot with nothing in it, or one whose `when` doesn't hold, renders nothing at all
|
|
13
|
+
* rather than an empty chip.
|
|
14
|
+
*/
|
|
15
|
+
function statusSlot(item, ui) {
|
|
16
|
+
if (item.visible === false || !item.html) return null;
|
|
17
|
+
if (item.when && !ui.platform.context.evaluate(item.when)) return null;
|
|
18
|
+
if (!ui.platform.plugins.isAvailable(item)) return null;
|
|
19
|
+
const content = sanitizedVNodes(item.html, dd);
|
|
20
|
+
if (!content.length) return null;
|
|
21
|
+
const title = item.tooltip ? htmlToText(item.tooltip) : '';
|
|
22
|
+
return item.command
|
|
23
|
+
? button({ className: 'seg', title }, ...content).on({ click: () => ui.exec(item.command) })
|
|
24
|
+
: span({ className: 'seg', title }, ...content);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The two things that make background work and standing problems discoverable at all.
|
|
29
|
+
*
|
|
30
|
+
* Running work shows only while it is running — a status bar that always carries an
|
|
31
|
+
* "Activity" button teaches people to ignore it. The attention badge is the opposite:
|
|
32
|
+
* it stays until the problem is actually fixed, because that is the whole point of an
|
|
33
|
+
* issue as against a toast that scrolls away.
|
|
34
|
+
*/
|
|
35
|
+
export function activityChips(state, ui) {
|
|
36
|
+
const act = state.act || { tasks: [], issues: [] };
|
|
37
|
+
const running = act.tasks.filter((t) => t.status === 'running');
|
|
38
|
+
const open = () => ui.app.activity.togglePanel(true);
|
|
39
|
+
const chips = [];
|
|
40
|
+
|
|
41
|
+
if (running.length) {
|
|
42
|
+
const first = running[0];
|
|
43
|
+
const determinate = first.total != null && first.total > 0;
|
|
44
|
+
const pct = determinate ? Math.round(((first.done || 0) / first.total) * 100) : null;
|
|
45
|
+
chips.push(button({
|
|
46
|
+
className: 'seg sb-activity',
|
|
47
|
+
title: running.map((t) => t.title).join('\n'),
|
|
48
|
+
},
|
|
49
|
+
div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }),
|
|
50
|
+
span(running.length > 1
|
|
51
|
+
? `${running.length} running`
|
|
52
|
+
: `${first.title}${pct == null ? '' : ` ${pct}%`}`)).on({ click: open }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (act.issues.length) {
|
|
56
|
+
chips.push(button({
|
|
57
|
+
className: 'seg sb-attention',
|
|
58
|
+
title: act.issues.map((i) => i.title).join('\n'),
|
|
59
|
+
}, icon('info', { size: 12 }), span(`${act.issues.length} need${act.issues.length === 1 ? 's' : ''} attention`))
|
|
60
|
+
.on({ click: open }));
|
|
61
|
+
}
|
|
62
|
+
return chips;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How full the disk is — but only when the backend actually knows.
|
|
67
|
+
*
|
|
68
|
+
* A filesystem or NAS can answer exactly, and that is where it matters: a disk fills
|
|
69
|
+
* up and every upload starts failing with no warning that anything was coming. An
|
|
70
|
+
* object store has no equivalent number, so this renders nothing at all rather than a
|
|
71
|
+
* meter that means nothing. The bar turns amber under 10% free and red under 5%, which
|
|
72
|
+
* is early enough to act on.
|
|
73
|
+
*/
|
|
74
|
+
export function usageChip(ex) {
|
|
75
|
+
const u = ex.usage;
|
|
76
|
+
if (!u?.total) return null;
|
|
77
|
+
const freeRatio = u.available / u.total;
|
|
78
|
+
const pct = Math.min(100, Math.round((u.used / u.total) * 100));
|
|
79
|
+
const level = freeRatio < 0.05 ? 'critical' : freeRatio < 0.1 ? 'low' : '';
|
|
80
|
+
return span({
|
|
81
|
+
className: `seg sb-usage ${level}`,
|
|
82
|
+
title: `${bytes(u.used)} used of ${bytes(u.total)} — ${bytes(u.available)} free on this volume`,
|
|
83
|
+
},
|
|
84
|
+
div({ className: 'usage-bar' }, div({ className: 'usage-fill', $styling: { width: `${pct}%` } })),
|
|
85
|
+
span(`${bytes(u.available)} free`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Everything the status bar reports, derived once.
|
|
90
|
+
*
|
|
91
|
+
* Two shells render these: the desktop as a row of segments along the bottom, the phone
|
|
92
|
+
* as rows in a sheet behind a single icon. Deriving them in one place is what stops the
|
|
93
|
+
* two from quietly telling the user different things about the same drive.
|
|
94
|
+
*/
|
|
95
|
+
export function statusFacts(state, ui) {
|
|
96
|
+
const ex = state.ex;
|
|
97
|
+
const items = ex.items || [];
|
|
98
|
+
const act = state.act || { tasks: [], issues: [] };
|
|
99
|
+
return {
|
|
100
|
+
collectionId: ex.collectionId || 'default',
|
|
101
|
+
// The COLLECTION's totals when the server could give them, not the page's. Summing
|
|
102
|
+
// what happens to be loaded reports a 3,000-file drive as 500 files — a wrong number,
|
|
103
|
+
// not a rounded one. Falls back to the page only when the server didn't say.
|
|
104
|
+
totalItems: ex.stats?.items ?? items.length,
|
|
105
|
+
// Whether that total is the COLLECTION's or just the page we happen to hold. With
|
|
106
|
+
// more pages waiting, the page length is a floor, not a total, and must read as one.
|
|
107
|
+
totalKnown: ex.stats?.items != null,
|
|
108
|
+
totalBytes: ex.stats?.bytes ?? items.reduce((n, i) => n + (i.size || 0), 0),
|
|
109
|
+
shown: items.length,
|
|
110
|
+
partial: !!ex.nextCursor,
|
|
111
|
+
usage: ex.usage,
|
|
112
|
+
uploading: state.tr.items.filter((t) => t.status === 'active'),
|
|
113
|
+
running: act.tasks.filter((t) => t.status === 'running'),
|
|
114
|
+
issues: act.issues,
|
|
115
|
+
off: state.off || { online: true, pins: [], queued: 0, syncing: false },
|
|
116
|
+
caps: ui.platform.capabilities,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default function statusBar(state, ui) {
|
|
121
|
+
const ex = state.ex;
|
|
122
|
+
const items = ex.items || [];
|
|
123
|
+
const f = statusFacts(state, ui);
|
|
124
|
+
const { totalItems, totalBytes, caps } = f;
|
|
125
|
+
const active = f.uploading;
|
|
126
|
+
|
|
127
|
+
// Plugin-contributed status slots, ordered by their declared `order` then name.
|
|
128
|
+
const slots = [...(state.statusItems || [])]
|
|
129
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
|
|
130
|
+
const left = slots.filter((s) => s.slot === 'left').map((s) => statusSlot(s, ui));
|
|
131
|
+
const right = slots.filter((s) => s.slot !== 'left').map((s) => statusSlot(s, ui));
|
|
132
|
+
|
|
133
|
+
const off = f.off;
|
|
134
|
+
return div({ className: 'statusbar' },
|
|
135
|
+
!off.online
|
|
136
|
+
? span({ className: 'seg offline-badge', title: 'You are offline — pinned files and cached data are available' }, icon('info', { size: 12 }), span('Offline'))
|
|
137
|
+
: off.syncing
|
|
138
|
+
? span({ className: 'seg', title: 'Syncing offline changes' }, div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }), span('Syncing…'))
|
|
139
|
+
: null,
|
|
140
|
+
off.queued ? span({ className: 'seg', title: 'Changes waiting to sync' }, `${off.queued} queued`) : null,
|
|
141
|
+
// The pinned files themselves. There is no offline-files view anywhere, so a chip
|
|
142
|
+
// counting them that only dropped you on the home screen was a label pretending to
|
|
143
|
+
// be a control — and unpinning meant finding each file again through search.
|
|
144
|
+
off.pins.length
|
|
145
|
+
? button({ className: 'seg', title: `${off.pins.length} file(s) available offline` },
|
|
146
|
+
icon('download', { size: 12 }), span(`${off.pins.length} offline`), icon('chevron-down', { size: 11 }))
|
|
147
|
+
.on({ click: (e) => {
|
|
148
|
+
const items = off.pins.slice(0, 12).flatMap((p) => [
|
|
149
|
+
{ label: p.name, icon: 'file-text', run: () => ui.exec('explorer.open', p) },
|
|
150
|
+
{ label: `Remove “${p.name}” from offline`, icon: 'close', run: () => ui.exec('offline.unpin', p) },
|
|
151
|
+
{ sep: true },
|
|
152
|
+
]);
|
|
153
|
+
if (off.pins.length > 12) items.push({ label: `…and ${off.pins.length - 12} more`, run: () => {} });
|
|
154
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
155
|
+
ui.platform.workbench.showContextMenu(r.left, r.top - 8 - items.length * 34, items);
|
|
156
|
+
} })
|
|
157
|
+
: null,
|
|
158
|
+
button({ className: 'seg', title: 'Switch collection' },
|
|
159
|
+
icon('files', { size: 13 }), span(ex.collectionId || 'default'),
|
|
160
|
+
(ex.collections || []).length > 1 || ex.canCreateCollection ? icon('chevron-down', { size: 11 }) : null)
|
|
161
|
+
.on({ click: (e) => {
|
|
162
|
+
const items = ui.app.collectionMenu?.() || [];
|
|
163
|
+
// With nowhere else to go, the segment is a label, not a dead menu.
|
|
164
|
+
if (items.length > 1 || ex.canCreateCollection) {
|
|
165
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
166
|
+
ui.platform.workbench.showContextMenu(r.left, r.top - 8 - items.length * 34, items);
|
|
167
|
+
} else ui.exec('workbench.view.home');
|
|
168
|
+
} }),
|
|
169
|
+
active.length
|
|
170
|
+
? button({ className: 'seg', title: 'Active uploads — click to cancel' }, div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }), span(`${active.length} uploading`))
|
|
171
|
+
.on({ click: (e) => ui.platform.workbench.showContextMenu(e.clientX, e.clientY, active.map((t) => ({
|
|
172
|
+
label: `Cancel ${t.name} (${Math.round((t.ratio || 0) * 100)}%)`, icon: 'close', danger: true,
|
|
173
|
+
run: () => ui.app.transfers.cancel(t.id),
|
|
174
|
+
}))) })
|
|
175
|
+
: span({ className: 'seg', title: ex.nextCursor ? `Showing ${items.length} of ${f.totalKnown ? totalItems : 'more'}` : '' },
|
|
176
|
+
`${totalItems.toLocaleString()}${f.totalKnown || !f.partial ? '' : '+'} item${totalItems === 1 ? '' : 's'}`),
|
|
177
|
+
...left,
|
|
178
|
+
div({ className: 'spacer' }),
|
|
179
|
+
...activityChips(state, ui),
|
|
180
|
+
...right,
|
|
181
|
+
// Same qualifier as the item count beside it. Without it the two numbers in one bar
|
|
182
|
+
// disagreed about their own honesty: "500+ items" next to a byte figure that was
|
|
183
|
+
// only the loaded page's, presented as the collection's.
|
|
184
|
+
span({
|
|
185
|
+
className: 'seg',
|
|
186
|
+
title: f.totalKnown || !f.partial
|
|
187
|
+
? 'Total size of this collection'
|
|
188
|
+
: `Size of the ${f.shown.toLocaleString()} items loaded so far — this server doesn’t report a collection total`,
|
|
189
|
+
}, `${bytes(totalBytes)}${f.totalKnown || !f.partial ? '' : '+'}`),
|
|
190
|
+
usageChip(ex),
|
|
191
|
+
caps ? span({ className: 'seg', title: 'Storage backend capabilities' },
|
|
192
|
+
icon(caps.storage?.presignDownload ? 'download' : 'files', { size: 12 }),
|
|
193
|
+
span(caps.storage?.presignDownload ? 'S3 direct' : 'proxied'),
|
|
194
|
+
) : null,
|
|
195
|
+
caps?.features?.semanticSearch ? button({ className: 'seg', title: 'Semantic search enabled' }, icon('star', { size: 12 }), span('semantic'))
|
|
196
|
+
.on({ click: () => ui.exec('workbench.view.home') }) : null,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The grid view: tiles, with a picture on them where there is a picture to show.
|
|
2
|
+
//
|
|
3
|
+
// This is the second way to look at a drive, and it exists as much to prove the view
|
|
4
|
+
// contract as to be useful — a contribution type with one implementation is a guess. It
|
|
5
|
+
// draws the SAME groups the list does, headers and all, because the group header is
|
|
6
|
+
// where Upload, Empty trash and Retry live: a view that dropped it would take those off
|
|
7
|
+
// the screen the moment someone switched to tiles.
|
|
8
|
+
//
|
|
9
|
+
// On thumbnails, honestly: there is no thumbnail service yet, so a tile showing a
|
|
10
|
+
// photograph is showing the photograph, scaled down by the browser. That is affordable
|
|
11
|
+
// only because `loading="lazy"` means the ones below the fold are never fetched — the
|
|
12
|
+
// grid asks for what is on screen and nothing else. A hosted build that generates real
|
|
13
|
+
// thumbnails should register its own view rather than patch this one; that is the whole
|
|
14
|
+
// point of the contribution.
|
|
15
|
+
|
|
16
|
+
import { dd } from '../../../runtime.js';
|
|
17
|
+
import { icon, iconForNode } from '../../icon.js';
|
|
18
|
+
import { groupHeader, menuButton, openRowMenu } from './parts.js';
|
|
19
|
+
import { attachMedia } from '../../media.js';
|
|
20
|
+
|
|
21
|
+
const { div, span, img } = dd;
|
|
22
|
+
|
|
23
|
+
export function gridView({ groups, index, handlers, ui }) {
|
|
24
|
+
let gi = -1;
|
|
25
|
+
return div({ className: 'launch-view view-grid' },
|
|
26
|
+
...groups.map((group) => div({ className: 'launch-group' },
|
|
27
|
+
groupHeader(group),
|
|
28
|
+
group.items.length
|
|
29
|
+
? div({ className: 'grid-list' }, ...group.items.map((it) => {
|
|
30
|
+
const at = ++gi;
|
|
31
|
+
return tile(it, at === index, {
|
|
32
|
+
hover: () => handlers.hover(at),
|
|
33
|
+
select: () => handlers.select(at),
|
|
34
|
+
}, ui);
|
|
35
|
+
}))
|
|
36
|
+
: div({ className: 'launch-empty' }, group.empty || 'Nothing here.'),
|
|
37
|
+
)),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Which way to move for an arrow key.
|
|
43
|
+
*
|
|
44
|
+
* The launcher owns keyboard navigation and thinks in one flat list, which is right for
|
|
45
|
+
* rows and wrong for tiles: down should mean the tile below, not the tile beside. The
|
|
46
|
+
* view is asked, gets to answer in the launcher's own terms (a delta), and the launcher
|
|
47
|
+
* keeps the wrapping, the selection and the scrolling.
|
|
48
|
+
*
|
|
49
|
+
* The column count is measured rather than assumed, because it comes from
|
|
50
|
+
* `auto-fill` — it depends on the window width, the shell, and whether the sidebar
|
|
51
|
+
* is open, none of which this file should be trying to predict.
|
|
52
|
+
*
|
|
53
|
+
* Left and right are only ours when the search box is empty. With a query typed in it,
|
|
54
|
+
* those keys belong to the caret: someone fixing a typo must not find the highlight
|
|
55
|
+
* walking across the grid instead.
|
|
56
|
+
*/
|
|
57
|
+
export function gridMove({ key, textual }) {
|
|
58
|
+
if (key === 'ArrowLeft') return textual ? null : -1;
|
|
59
|
+
if (key === 'ArrowRight') return textual ? null : 1;
|
|
60
|
+
const cols = columns();
|
|
61
|
+
if (key === 'ArrowUp') return -cols;
|
|
62
|
+
if (key === 'ArrowDown') return cols;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function columns() {
|
|
67
|
+
// Tiles on the same row share an offsetTop. One row's worth is the answer; a grid with
|
|
68
|
+
// a single row (or none on screen) falls back to 1, which is the list's behaviour and
|
|
69
|
+
// never worse than a guess.
|
|
70
|
+
const tiles = document.querySelectorAll('.grid-list .grid-tile');
|
|
71
|
+
if (tiles.length < 2) return 1;
|
|
72
|
+
const top = tiles[0].offsetTop;
|
|
73
|
+
let n = 0;
|
|
74
|
+
for (const el of tiles) {
|
|
75
|
+
if (el.offsetTop !== top) break;
|
|
76
|
+
n++;
|
|
77
|
+
}
|
|
78
|
+
return n || 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function tile(it, active, { hover, select }, ui) {
|
|
82
|
+
const node = it.node;
|
|
83
|
+
const pictorial = (node?.contentType || '').startsWith('image/');
|
|
84
|
+
return div({ className: `grid-tile ${active ? 'active' : ''}`, title: it.detail ? `${it.title} — ${it.detail}` : it.title },
|
|
85
|
+
div({ className: 'gt-media' },
|
|
86
|
+
// The icon is drawn underneath, always. When the image loads it covers it; when
|
|
87
|
+
// the image fails — a deleted object, a format the browser won't decode, a 401 —
|
|
88
|
+
// hiding the <img> uncovers exactly what the list would have shown, rather than
|
|
89
|
+
// leaving the browser's broken-image glyph in a gallery.
|
|
90
|
+
icon(node ? iconForNode(node) : it.icon, { size: 26 }),
|
|
91
|
+
// The `src` is minted rather than built — a tile fetches without our Authorization
|
|
92
|
+
// header, so the URL has to carry its own grant. Minting is batched (see
|
|
93
|
+
// platform/mediaUrls.js), so a wall of tiles costs one request, not one each.
|
|
94
|
+
pictorial
|
|
95
|
+
? img({ className: 'gt-img', alt: '', loading: 'lazy', decoding: 'async' }).on({
|
|
96
|
+
$attach: (dom) => {
|
|
97
|
+
dom._detachMedia = attachMedia(dom, node, ui, {
|
|
98
|
+
// Uncovering the icon underneath is exactly what the list would have shown,
|
|
99
|
+
// and better than the browser's broken-image glyph in a gallery.
|
|
100
|
+
onError: () => { dom.style.display = 'none'; },
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
$detach: (dom) => { dom._detachMedia?.(); dom._detachMedia = null; },
|
|
104
|
+
}).opaque()
|
|
105
|
+
: null,
|
|
106
|
+
it.badge ? span({ className: 'gt-badge' }, it.badge) : null,
|
|
107
|
+
menuButton(it, ui, select),
|
|
108
|
+
),
|
|
109
|
+
div({ className: 'gt-name' }, it.title),
|
|
110
|
+
).on({
|
|
111
|
+
click: it.run,
|
|
112
|
+
mouseenter: hover,
|
|
113
|
+
...(it.menu ? { contextmenu: (e) => { e.preventDefault(); openRowMenu(e.currentTarget, it, ui, e, select); } } : {}),
|
|
114
|
+
});
|
|
115
|
+
}
|