sol-components 2.1.0
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 +7 -0
- package/core/activate.js +27 -0
- package/core/adopt.js +71 -0
- package/core/auth-core.js +73 -0
- package/core/auth-fetch.js +154 -0
- package/core/component-mount.js +110 -0
- package/core/defaults.js +48 -0
- package/core/define.js +15 -0
- package/core/display-target.js +166 -0
- package/core/edit-placements.js +28 -0
- package/core/editor-self.js +127 -0
- package/core/editor.js +162 -0
- package/core/events.js +27 -0
- package/core/extension-points.js +189 -0
- package/core/form-utils.js +210 -0
- package/core/from-query.js +138 -0
- package/core/from-rdf.js +52 -0
- package/core/here.js +33 -0
- package/core/include-core.js +73 -0
- package/core/inrupt-global.js +18 -0
- package/core/menu-consumer.js +41 -0
- package/core/menu-rdf.js +154 -0
- package/core/pod-ops.js +392 -0
- package/core/pod-registry.js +82 -0
- package/core/popup-proxy.js +255 -0
- package/core/rdf-core.js +280 -0
- package/core/rdf-render.js +136 -0
- package/core/rdf-utils.js +411 -0
- package/core/rdf.js +154 -0
- package/core/services.js +106 -0
- package/core/shape-to-form.js +741 -0
- package/core/sparql-safety.js +20 -0
- package/core/utils.js +196 -0
- package/dist/importmap-cdn.json +49 -0
- package/dist/importmap-local.json +49 -0
- package/dist/sol-loader.manifest.json +140 -0
- package/dist/vendor/@comunica-query-sparql.js +137851 -0
- package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
- package/dist/vendor/dompurify.js +1476 -0
- package/dist/vendor/ical.js.js +9739 -0
- package/dist/vendor/marked.js +85 -0
- package/dist/vendor/n3.js +14670 -0
- package/dist/vendor/rdf-validate-shacl.js +6970 -0
- package/dist/vendor/rdflib.js +35172 -0
- package/dist/vendor/solid-logic.js +6819 -0
- package/dist/vendor/solid-ui.js +21945 -0
- package/node/sol-form.js +133 -0
- package/node/sol-include.js +55 -0
- package/node/sol-login.js +632 -0
- package/node/sol-menu.js +639 -0
- package/node/sol-query.js +116 -0
- package/package.json +133 -0
- package/web/menu-from-rdf.js +23 -0
- package/web/scripts/prefs.js +25 -0
- package/web/sol-accordion.js +114 -0
- package/web/sol-basic.js +50 -0
- package/web/sol-breadcrumb.js +131 -0
- package/web/sol-button.js +244 -0
- package/web/sol-calendar.js +465 -0
- package/web/sol-default.js +118 -0
- package/web/sol-dropdown-button.js +222 -0
- package/web/sol-feed.js +1336 -0
- package/web/sol-form.js +949 -0
- package/web/sol-full.js +43 -0
- package/web/sol-gallery.js +303 -0
- package/web/sol-include.js +246 -0
- package/web/sol-live-edit.js +415 -0
- package/web/sol-login.js +856 -0
- package/web/sol-menu.js +593 -0
- package/web/sol-modal.js +377 -0
- package/web/sol-pod-extras.js +17 -0
- package/web/sol-pod-ops.js +680 -0
- package/web/sol-pod.js +1039 -0
- package/web/sol-query.js +546 -0
- package/web/sol-rolodex.js +95 -0
- package/web/sol-search.js +402 -0
- package/web/sol-settings.js +199 -0
- package/web/sol-solidos.js +93 -0
- package/web/sol-tabs.js +445 -0
- package/web/sol-time.js +194 -0
- package/web/sol-tree-edit.js +492 -0
- package/web/sol-wac.js +456 -0
- package/web/sol-weather.js +337 -0
- package/web/sol-window.js +142 -0
- package/web/styles/buttons-css.js +108 -0
- package/web/styles/help.css +242 -0
- package/web/styles/root.css +112 -0
- package/web/styles/sol-accordion-css.js +97 -0
- package/web/styles/sol-calendar-css.js +154 -0
- package/web/styles/sol-feed-css.js +475 -0
- package/web/styles/sol-form-css.js +471 -0
- package/web/styles/sol-gallery-css.js +181 -0
- package/web/styles/sol-include-css.js +95 -0
- package/web/styles/sol-live-edit-css.js +84 -0
- package/web/styles/sol-live-edit.css +101 -0
- package/web/styles/sol-login-css.js +116 -0
- package/web/styles/sol-menu-css.js +145 -0
- package/web/styles/sol-modal-css.js +134 -0
- package/web/styles/sol-pod-css.js +187 -0
- package/web/styles/sol-pod-modal-css.js +203 -0
- package/web/styles/sol-query-css.js +140 -0
- package/web/styles/sol-query-help.css +267 -0
- package/web/styles/sol-query-one-pager.css +67 -0
- package/web/styles/sol-search-css.js +157 -0
- package/web/styles/sol-solidos-css.js +7 -0
- package/web/styles/sol-tabs-css.js +114 -0
- package/web/styles/sol-time-css.js +30 -0
- package/web/styles/sol-wac-css.js +73 -0
- package/web/styles/sol-weather-css.js +59 -0
- package/web/styles/solid-logo.svg +9 -0
- package/web/styles/view-accordion-css.js +66 -0
- package/web/styles/view-anchorlist-css.js +22 -0
- package/web/styles/view-autocomplete-css.js +59 -0
- package/web/styles/view-rolodex-css.js +102 -0
- package/web/styles/view-select-css.js +21 -0
- package/web/utils/calendar-fetch.js +388 -0
- package/web/utils/code-mirror-editor.js +82 -0
- package/web/utils/commons-fetch.js +108 -0
- package/web/utils/feed-edit.js +159 -0
- package/web/utils/feed-edit.smoke.mjs +74 -0
- package/web/utils/feed-fetch.js +573 -0
- package/web/utils/live-edit-help/csv.js +64 -0
- package/web/utils/live-edit-help/graphviz.js +41 -0
- package/web/utils/live-edit-help/jsonld.js +55 -0
- package/web/utils/live-edit-help/markdown.js +52 -0
- package/web/utils/live-edit-help/mermaid.js +48 -0
- package/web/utils/live-edit-help/turtle.js +85 -0
- package/web/utils/rdf-config.js +125 -0
- package/web/utils/renderers/csv.js +124 -0
- package/web/utils/renderers/d3-force.js +82 -0
- package/web/utils/renderers/graphviz.js +13 -0
- package/web/utils/renderers/html.js +10 -0
- package/web/utils/renderers/jsonld.js +63 -0
- package/web/utils/renderers/markdown.js +19 -0
- package/web/utils/renderers/mermaid.js +54 -0
- package/web/utils/renderers/turtle.js +51 -0
- package/web/utils/sol-query-triple-patterns.js +151 -0
- package/web/utils/sol-query-ui.js +250 -0
- package/web/utils/sol-query-views.js +32 -0
- package/web/views/_helpers.js +34 -0
- package/web/views/accordion.js +133 -0
- package/web/views/anchorlist.js +59 -0
- package/web/views/auto-complete.js +183 -0
- package/web/views/dl.js +38 -0
- package/web/views/list.js +19 -0
- package/web/views/menu.js +56 -0
- package/web/views/rolodex.js +126 -0
- package/web/views/select.js +79 -0
- package/web/views/table.js +73 -0
- package/web/views/tabs.js +57 -0
package/web/sol-pod.js
ADDED
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <sol-pod> — Solid pod file browser web component.
|
|
3
|
+
* Attributes: source (one pod storage URL, or a comma/space-separated list;
|
|
4
|
+
* if omitted, discovers from current origin)
|
|
5
|
+
* pod-click-action (Function|string — callback when an item is activated
|
|
6
|
+
* (gear icon, Enter, or double-click); if omitted, opens
|
|
7
|
+
* the default pod-ops modal)
|
|
8
|
+
* Properties: login (SolLogin element ref), currentPath, items
|
|
9
|
+
* Events: sol-navigate({url}), sol-drag-start({item}), sol-drag-end(), sol-status({message,type})
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* <sol-login id="auth"></sol-login>
|
|
13
|
+
* <sol-pod source="https://pod.example/" login="#auth"></sol-pod>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { CSS, sheet as POD_SHEET } from './styles/sol-pod-css.js';
|
|
17
|
+
import { sheet as POD_MODAL_SHEET, CSS as POD_MODAL_CSS } from './styles/sol-pod-modal-css.js';
|
|
18
|
+
import { adopt } from '../core/adopt.js';
|
|
19
|
+
import { define } from '../core/define.js';
|
|
20
|
+
import {
|
|
21
|
+
fileIcon,
|
|
22
|
+
fetchContainer,
|
|
23
|
+
discoverOwnerWebIds, getStoragesFromWebIds,
|
|
24
|
+
} from '../core/pod-ops.js';
|
|
25
|
+
import { getRegistry } from '../core/pod-registry.js';
|
|
26
|
+
import './sol-modal.js'; // modal shell is part of sol-pod's own UX
|
|
27
|
+
import './sol-login.js'; // built-in login button in the pod header
|
|
28
|
+
|
|
29
|
+
// ── SolPod component ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Solid pod file browser web component.
|
|
33
|
+
*
|
|
34
|
+
* Browse containers, view/edit files, manage permissions. Pairs with
|
|
35
|
+
* sol-login for authenticated access. Delegates file operations to sol-pod-ops.
|
|
36
|
+
*
|
|
37
|
+
* @class SolPod
|
|
38
|
+
* @extends HTMLElement
|
|
39
|
+
* @attr {string} source - pod storage URL, or comma/space-separated list of URLs (discovers from origin if omitted)
|
|
40
|
+
* @attr {string} login - CSS selector for a sol-login element
|
|
41
|
+
* @attr {string} login-mode - forwarded to the built-in sol-login as its `mode` ("redirect" | "popup")
|
|
42
|
+
* @attr {string} login-callback - forwarded to the built-in sol-login as its `popup-callback`
|
|
43
|
+
* @attr {string} issuers - comma-separated OIDC issuer origins, forwarded to the built-in sol-login
|
|
44
|
+
* @attr {string} side - auth session tag; also forwarded to the built-in sol-login as its `side`
|
|
45
|
+
* @attr {string} pod-click-action - callback when an item is activated (gear / Enter / double-click)
|
|
46
|
+
* @attr {string} handler - default sol-* component for file viewing
|
|
47
|
+
* @attr {string} gear-icon - icon for BOTH the per-item action button and
|
|
48
|
+
* the breadcrumb (current-container) gear. Treated as a URL
|
|
49
|
+
* when it contains '/' or ends in svg/png/jpg/gif/webp;
|
|
50
|
+
* otherwise used as text (emoji). Defaults to '⚙'.
|
|
51
|
+
* @attr {string} pods-group - shared pod-list scope; absent = the default shared
|
|
52
|
+
* group, 'none' = a standalone unshared registry
|
|
53
|
+
*
|
|
54
|
+
* Lifecycle: auto-initializes from `connectedCallback` (microtask-deferred so
|
|
55
|
+
* JS callers that set `podClickAction` between `appendChild` and the next
|
|
56
|
+
* microtask still land setup before init runs). `initialize()` is
|
|
57
|
+
* single-flight — calling it explicitly returns the in-flight or resolved
|
|
58
|
+
* promise instead of triggering a duplicate discovery.
|
|
59
|
+
*
|
|
60
|
+
* Last-visited recall: the current container URL is persisted to
|
|
61
|
+
* localStorage keyed by (pods-group, side). On the next mount, if the
|
|
62
|
+
* remembered path sits under one of the known storages, sol-pod restores it
|
|
63
|
+
* (and switches the pod selector to that storage if it differs from
|
|
64
|
+
* `storages[0]`). Wrapped against storage-unavailable contexts.
|
|
65
|
+
*
|
|
66
|
+
* Item shape (returned by `fetchContainer`, passed to `podClickAction`):
|
|
67
|
+
* { url, name, displayName, isContainer, contentType,
|
|
68
|
+
* size, // bytes, from posix:size (null if not emitted)
|
|
69
|
+
* mtime, // POSIX epoch seconds, from posix:mtime
|
|
70
|
+
* modified, // ISO datetime string, from dct:modified
|
|
71
|
+
* types } // array of rdf:type IRIs
|
|
72
|
+
*
|
|
73
|
+
* @property {Object} login - SolLogin element reference (external if given, else the built-in one)
|
|
74
|
+
* @property {string} currentPath - current container URL (also the remembered start path)
|
|
75
|
+
* @property {Array} items - current directory listing (see item shape above)
|
|
76
|
+
* @property {Array} storages - known pod URLs for this pod's group
|
|
77
|
+
* @fires sol-navigate - detail: { url }
|
|
78
|
+
* @fires sol-drag-start - detail: { item, element }
|
|
79
|
+
* @fires sol-drag-end
|
|
80
|
+
* @fires sol-auth-needed - detail: { url }
|
|
81
|
+
* @fires sol-status - detail: { message, type }
|
|
82
|
+
* @fires sol-prefs-change - detail: { prefs } — a hide-pattern filter was toggled
|
|
83
|
+
* @fires sol-pod-pods-changed - detail: { group, pods } — the group's pod list
|
|
84
|
+
* grew (discovery / add). May fire once per pod in the group;
|
|
85
|
+
* a host listener should treat it idempotently.
|
|
86
|
+
*/
|
|
87
|
+
class SolPod extends HTMLElement {
|
|
88
|
+
static get observedAttributes() { return ['source', 'login', 'pod-click-action', 'handler', 'side']; }
|
|
89
|
+
|
|
90
|
+
constructor() {
|
|
91
|
+
super();
|
|
92
|
+
this.attachShadow({ mode: 'open' });
|
|
93
|
+
this._login = null;
|
|
94
|
+
this._loginEl = null;
|
|
95
|
+
this._side = null;
|
|
96
|
+
this._currentPath = '';
|
|
97
|
+
this._rootUrl = '';
|
|
98
|
+
this._items = [];
|
|
99
|
+
this._storages = []; // cache mirror of the group registry
|
|
100
|
+
this._group = null;
|
|
101
|
+
this._registry = null;
|
|
102
|
+
this._onRegistryChange = null;
|
|
103
|
+
this._pendingSeed = null;
|
|
104
|
+
this._initialized = false;
|
|
105
|
+
this._modal = null;
|
|
106
|
+
this._toastTimer = null;
|
|
107
|
+
this._draggedItem = null;
|
|
108
|
+
this._podClickAction = null;
|
|
109
|
+
this._selected = new Set();
|
|
110
|
+
this._lastSelectedIndex = -1;
|
|
111
|
+
this._currentItems = [];
|
|
112
|
+
this._allItems = [];
|
|
113
|
+
this._filterText = '';
|
|
114
|
+
this._focusIndex = -1;
|
|
115
|
+
this._prefs = { hideDot: true, hideHash: true, hideTilde: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get login() { return this._login || this._loginEl; }
|
|
119
|
+
set login(el) {
|
|
120
|
+
if (typeof el === 'string') el = document.querySelector(el);
|
|
121
|
+
this._login = el;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Auth session tag for this pod ('left' / 'right' in podz, etc).
|
|
126
|
+
* Passed to the linked sol-login's fetchFor() so multi-session setups
|
|
127
|
+
* pick the right session. When unset, fetchFor falls back to
|
|
128
|
+
* origin-coverage matching — back-compatible for single-session pages.
|
|
129
|
+
*/
|
|
130
|
+
get side() { return this._side; }
|
|
131
|
+
set side(v) { this._side = v || null; }
|
|
132
|
+
|
|
133
|
+
get currentPath() { return this._currentPath; }
|
|
134
|
+
get items() { return this._items; }
|
|
135
|
+
get rootUrl() { return this._rootUrl; }
|
|
136
|
+
|
|
137
|
+
/** Known pod URLs for this pod's group (shared across the group). */
|
|
138
|
+
get storages() { return this._registry ? this._registry.list() : [...this._storages]; }
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Add pod URLs to this pod's group registry and optionally select one.
|
|
142
|
+
* Additive — it contributes to the shared list rather than replacing it.
|
|
143
|
+
*/
|
|
144
|
+
setStorages(arr, currentUrl) {
|
|
145
|
+
this._registry?.addAll(Array.isArray(arr) ? arr : []);
|
|
146
|
+
const target = currentUrl || this._rootUrl;
|
|
147
|
+
if (target && this.storages.includes(target)) {
|
|
148
|
+
const sel = this.shadowRoot.querySelector('.pod-select');
|
|
149
|
+
if (sel) sel.value = target;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Preload known pod URLs (e.g. restored from host persistence) into
|
|
155
|
+
* this pod's group registry. Silent — does not emit sol-pod-pods-changed.
|
|
156
|
+
*/
|
|
157
|
+
seedPods(urls) {
|
|
158
|
+
if (this._registry) this._registry.addAll(urls, { silent: true });
|
|
159
|
+
else this._pendingSeed = (this._pendingSeed || []).concat(urls || []);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get prefs() { return this._prefs; }
|
|
163
|
+
set prefs(p) { this._prefs = { ...this._prefs, ...p }; }
|
|
164
|
+
|
|
165
|
+
get source() { return this.getAttribute('source') || ''; }
|
|
166
|
+
set source(v) { this.setAttribute('source', v); }
|
|
167
|
+
|
|
168
|
+
// The `source` attribute may be one URL or a comma/space-separated list;
|
|
169
|
+
// each becomes a pod-selector entry. Every URL is normalised to end '/'
|
|
170
|
+
// and the list is de-duplicated (after normalisation, so 'x' and 'x/'
|
|
171
|
+
// collapse to one).
|
|
172
|
+
_sources() {
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const raw of (this.getAttribute('source') || '').split(/[,\s]+/)) {
|
|
176
|
+
const s = raw.trim();
|
|
177
|
+
if (!s) continue;
|
|
178
|
+
const u = s.endsWith('/') ? s : s + '/';
|
|
179
|
+
if (!seen.has(u)) { seen.add(u); out.push(u); }
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
get podClickAction() { return this._podClickAction; }
|
|
185
|
+
set podClickAction(v) {
|
|
186
|
+
if (typeof v === 'function') { this._podClickAction = v; return; }
|
|
187
|
+
if (typeof v === 'string' && v) { this._podClickAction = v; return; }
|
|
188
|
+
this._podClickAction = null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
connectedCallback() {
|
|
192
|
+
if (!this._initialized) {
|
|
193
|
+
this._initialized = true;
|
|
194
|
+
this._render();
|
|
195
|
+
|
|
196
|
+
// Join this pod's group registry — the shared set of known pods
|
|
197
|
+
// that drives the selector. `pods-group` scopes it; absent = the
|
|
198
|
+
// default shared group; 'none' = a standalone, unshared registry.
|
|
199
|
+
this._group = this.getAttribute('pods-group') || '__default__';
|
|
200
|
+
this._registry = getRegistry(this._group);
|
|
201
|
+
this._onRegistryChange = (pods, silent) => this._applyRegistry(pods, silent);
|
|
202
|
+
this._registry.subscribe(this._onRegistryChange);
|
|
203
|
+
if (this._pendingSeed) {
|
|
204
|
+
this._registry.addAll(this._pendingSeed, { silent: true });
|
|
205
|
+
this._pendingSeed = null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const loginAttr = this.getAttribute('login');
|
|
209
|
+
if (loginAttr) this.login = loginAttr;
|
|
210
|
+
const sideAttr = this.getAttribute('side');
|
|
211
|
+
if (sideAttr) this._side = sideAttr;
|
|
212
|
+
const clickAttr = this.getAttribute('pod-click-action') || this.getAttribute('handler');
|
|
213
|
+
if (clickAttr) this.podClickAction = clickAttr;
|
|
214
|
+
|
|
215
|
+
// The header carries a built-in <sol-login>. An external login=
|
|
216
|
+
// selector, when given, takes its place and the built-in is dropped.
|
|
217
|
+
const embeddedLogin = this.shadowRoot.querySelector('sol-login');
|
|
218
|
+
if (this._login) {
|
|
219
|
+
embeddedLogin.remove();
|
|
220
|
+
} else {
|
|
221
|
+
this._loginEl = embeddedLogin;
|
|
222
|
+
// Forward this pod's login config to the built-in <sol-login> so a
|
|
223
|
+
// host can opt into popup mode / a side tag / a callback page /
|
|
224
|
+
// a starting issuer list.
|
|
225
|
+
const lm = this.getAttribute('login-mode');
|
|
226
|
+
if (lm) embeddedLogin.setAttribute('mode', lm);
|
|
227
|
+
if (sideAttr) embeddedLogin.setAttribute('side', sideAttr);
|
|
228
|
+
const lc = this.getAttribute('login-callback');
|
|
229
|
+
if (lc) embeddedLogin.setAttribute('popup-callback', lc);
|
|
230
|
+
const iss = this.getAttribute('issuers');
|
|
231
|
+
if (iss) embeddedLogin.setAttribute('issuers', iss);
|
|
232
|
+
const reload = () => { if (this._currentPath) this.loadContainer(this._currentPath); };
|
|
233
|
+
// A login can reveal pods the logged-out session could not see —
|
|
234
|
+
// re-discover, then reload the current container.
|
|
235
|
+
this._loginEl.addEventListener('sol-login', () => {
|
|
236
|
+
this.discover().catch(() => {});
|
|
237
|
+
reload();
|
|
238
|
+
});
|
|
239
|
+
this._loginEl.addEventListener('sol-logout', reload);
|
|
240
|
+
this._loginEl.initialize().catch(() => {});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Kick off discovery + first-container load on mount so the
|
|
244
|
+
// pod always resolves out of its "Loading pods..." placeholder.
|
|
245
|
+
// Deferred to a microtask so JS callers that set
|
|
246
|
+
// `podClickAction` / other properties between `appendChild` and
|
|
247
|
+
// the next turn of the microtask queue still land their setup
|
|
248
|
+
// before init runs. `initialize()` is single-flight so an
|
|
249
|
+
// explicit `await pod.initialize()` from such a caller just
|
|
250
|
+
// awaits the same in-flight promise rather than triggering a
|
|
251
|
+
// duplicate discovery.
|
|
252
|
+
queueMicrotask(() => this.initialize().catch((err) =>
|
|
253
|
+
console.warn('[sol-pod] init failed:', err)));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
disconnectedCallback() {
|
|
258
|
+
if (this._onDocClick) {
|
|
259
|
+
document.removeEventListener('click', this._onDocClick);
|
|
260
|
+
this._onDocClick = null;
|
|
261
|
+
}
|
|
262
|
+
if (this._registry && this._onRegistryChange) {
|
|
263
|
+
this._registry.unsubscribe(this._onRegistryChange);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Group-registry subscriber: a pod was added by this or a sibling
|
|
269
|
+
* <sol-pod>. Refresh the selector; on a non-silent change, let the
|
|
270
|
+
* host persist the new list via the sol-pod-pods-changed event.
|
|
271
|
+
*/
|
|
272
|
+
_applyRegistry(pods, silent) {
|
|
273
|
+
this._storages = pods;
|
|
274
|
+
this._populateSelect(pods);
|
|
275
|
+
const sel = this.shadowRoot.querySelector('.pod-select');
|
|
276
|
+
if (sel && this._rootUrl && pods.includes(this._rootUrl)) sel.value = this._rootUrl;
|
|
277
|
+
if (!silent) {
|
|
278
|
+
this.dispatchEvent(new CustomEvent('sol-pod-pods-changed', {
|
|
279
|
+
bubbles: true, composed: true, detail: { group: this._group, pods },
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
attributeChangedCallback(name, oldV, newV) {
|
|
285
|
+
if (oldV === newV) return;
|
|
286
|
+
if (name === 'source' && this._initialized) {
|
|
287
|
+
this._setSource();
|
|
288
|
+
}
|
|
289
|
+
if (name === 'login' && this._initialized) {
|
|
290
|
+
this.login = newV;
|
|
291
|
+
}
|
|
292
|
+
if (name === 'pod-click-action' || name === 'handler') {
|
|
293
|
+
this.podClickAction = newV;
|
|
294
|
+
}
|
|
295
|
+
if (name === 'side') {
|
|
296
|
+
this._side = newV || null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Initialize the component — discovers pods and loads initial view.
|
|
301
|
+
* Single-flight: subsequent calls return the same in-flight (or
|
|
302
|
+
* resolved) promise, so the connectedCallback auto-init and an
|
|
303
|
+
* explicit `await pod.initialize()` from a caller share one pass. */
|
|
304
|
+
initialize() {
|
|
305
|
+
if (this._initPromise) return this._initPromise;
|
|
306
|
+
this._initPromise = this._doInitialize();
|
|
307
|
+
return this._initPromise;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async _doInitialize() {
|
|
311
|
+
if (this._sources().length) {
|
|
312
|
+
// An explicit `source` lists exactly the pods to use — skip
|
|
313
|
+
// discovery. An absent or empty `source` falls through to it.
|
|
314
|
+
await this._setSource();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// No explicit source: use whatever the group registry already holds
|
|
318
|
+
// (seeded by the host, or discovered by a sibling pod); otherwise
|
|
319
|
+
// discover for this session/origin. Seeded pods are authoritative —
|
|
320
|
+
// a non-empty registry stands as the dropdown's contents and no
|
|
321
|
+
// discovery is attempted. Discovery only fills an empty registry.
|
|
322
|
+
if (this.storages.length === 0) {
|
|
323
|
+
await this.discover();
|
|
324
|
+
}
|
|
325
|
+
const pods = this.storages;
|
|
326
|
+
// Always render the selector — an empty list must show "No pods
|
|
327
|
+
// found" rather than leave the initial "Loading pods..." placeholder.
|
|
328
|
+
this._populateSelect(pods);
|
|
329
|
+
if (pods.length > 0) {
|
|
330
|
+
const start = this._pickStartPath(pods[0]);
|
|
331
|
+
this._rootUrl = this._rootForPath(start, pods) || pods[0];
|
|
332
|
+
const sel = this.shadowRoot.querySelector('.pod-select');
|
|
333
|
+
if (sel) sel.value = this._rootUrl;
|
|
334
|
+
await this.loadContainer(start);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Discover pod storages for the current session/origin (WebID-based,
|
|
340
|
+
* falling back to the current origin) and add them to this pod's
|
|
341
|
+
* group registry. Returns the group's full pod list.
|
|
342
|
+
*/
|
|
343
|
+
async discover() {
|
|
344
|
+
let found;
|
|
345
|
+
try {
|
|
346
|
+
const webIds = await discoverOwnerWebIds();
|
|
347
|
+
found = await getStoragesFromWebIds(webIds);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
console.warn('[sol-pod] Discovery failed:', e);
|
|
350
|
+
found = [window.location.origin + '/'];
|
|
351
|
+
}
|
|
352
|
+
this._registry?.addAll(found);
|
|
353
|
+
return this.storages;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_fetchFor(url) {
|
|
357
|
+
const login = this._login || this._loginEl;
|
|
358
|
+
if (login?.fetchFor) return login.fetchFor(url, this._side);
|
|
359
|
+
return fetch;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async _setSource() {
|
|
363
|
+
const sources = this._sources();
|
|
364
|
+
if (!sources.length) return;
|
|
365
|
+
this._registry?.addAll(sources);
|
|
366
|
+
const start = this._pickStartPath(sources[0]);
|
|
367
|
+
// Align the selected pod root with whichever storage the
|
|
368
|
+
// remembered path is under, so the dropdown matches the
|
|
369
|
+
// breadcrumb on a "return to last visited" boot.
|
|
370
|
+
this._rootUrl = this._rootForPath(start, sources) || sources[0];
|
|
371
|
+
this._populateSelect(this.storages);
|
|
372
|
+
const sel = this.shadowRoot.querySelector('.pod-select');
|
|
373
|
+
if (sel) sel.value = this._rootUrl;
|
|
374
|
+
await this.loadContainer(start);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async loadContainer(url) {
|
|
378
|
+
this._showLoading();
|
|
379
|
+
try {
|
|
380
|
+
const fetchFn = this._fetchFor(url);
|
|
381
|
+
const items = await fetchContainer(url, fetchFn);
|
|
382
|
+
this._rawItems = items; // unfiltered — kept so prefs can re-apply
|
|
383
|
+
this._currentPath = url;
|
|
384
|
+
this._rememberPath(url);
|
|
385
|
+
this._items = this._filterItems(items);
|
|
386
|
+
this._allItems = this._items;
|
|
387
|
+
// New container = fresh context; clear any in-flight filter.
|
|
388
|
+
this._filterText = '';
|
|
389
|
+
const filterInput = this.shadowRoot.querySelector('.pod-filter');
|
|
390
|
+
if (filterInput) filterInput.value = '';
|
|
391
|
+
this._renderTree(this._allItems, { preserveFocus: false });
|
|
392
|
+
this._updateBreadcrumb(url);
|
|
393
|
+
this._emitStatus('', '');
|
|
394
|
+
|
|
395
|
+
this.dispatchEvent(new CustomEvent('sol-navigate', {
|
|
396
|
+
bubbles: true, composed: true, detail: { url }
|
|
397
|
+
}));
|
|
398
|
+
} catch (e) {
|
|
399
|
+
if (e.message?.includes('401') || e.message?.includes('403') || e.needsAuth) {
|
|
400
|
+
this._showMessage('Authentication required \u2014 please log in.', true);
|
|
401
|
+
this._currentPath = url;
|
|
402
|
+
this._updateBreadcrumb(url);
|
|
403
|
+
this.dispatchEvent(new CustomEvent('sol-auth-needed', {
|
|
404
|
+
bubbles: true, composed: true, detail: { url }
|
|
405
|
+
}));
|
|
406
|
+
} else {
|
|
407
|
+
this._showMessage(`Failed to load: ${e.message}`, true);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/* ── last-visited path memory ──────────────────────────────────────
|
|
413
|
+
* Persist the current container URL in localStorage keyed by
|
|
414
|
+
* (pods-group, side) so the next page load can restore the user
|
|
415
|
+
* where they were. Multiple sol-pods sharing the same group/side
|
|
416
|
+
* share the memory — same context, same recall. Wrapped against
|
|
417
|
+
* environments where localStorage is unavailable (private mode,
|
|
418
|
+
* partitioned iframes). */
|
|
419
|
+
_pathStorageKey() {
|
|
420
|
+
return 'sol-pod:lastPath:' + (this._group || '__default__') + ':' + (this._side || 'default');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
_rememberPath(url) {
|
|
424
|
+
try { localStorage.setItem(this._pathStorageKey(), url); } catch (_) {}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
_recallPath() {
|
|
428
|
+
try { return localStorage.getItem(this._pathStorageKey()) || null; } catch (_) { return null; }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Pick the remembered path if it sits under one of the available
|
|
432
|
+
* pod storages — otherwise the caller's fallback (root) wins. */
|
|
433
|
+
_pickStartPath(fallback) {
|
|
434
|
+
const remembered = this._recallPath();
|
|
435
|
+
if (!remembered) return fallback;
|
|
436
|
+
if (this._rootForPath(remembered, this.storages)) return remembered;
|
|
437
|
+
return fallback;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Return whichever root URL in `pods` contains `path`, or null. */
|
|
441
|
+
_rootForPath(path, pods) {
|
|
442
|
+
if (!path || !pods?.length) return null;
|
|
443
|
+
return pods.find(root => path === root || path.startsWith(root)) || null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
_filterItems(items) {
|
|
447
|
+
return items.filter(item => {
|
|
448
|
+
// Match the user's mental model: filter on the decoded name so
|
|
449
|
+
// e.g. %23foo (decodes to #foo) hides when 'hide hash' is on.
|
|
450
|
+
const n = item.displayName || item.name;
|
|
451
|
+
if (this._prefs.hideDot && n.startsWith('.')) return false;
|
|
452
|
+
if (this._prefs.hideHash && n.startsWith('#')) return false;
|
|
453
|
+
if (this._prefs.hideTilde && n.endsWith('~')) return false;
|
|
454
|
+
return true;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Re-apply the pattern prefs to the cached listing — no refetch.
|
|
459
|
+
_reapplyPrefs() {
|
|
460
|
+
this._items = this._allItems = this._filterItems(this._rawItems || []);
|
|
461
|
+
this._renderTree(this._allItems, { preserveFocus: false });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── DOM rendering ───────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
_render() {
|
|
467
|
+
const s = this.shadowRoot;
|
|
468
|
+
s.innerHTML = `
|
|
469
|
+
<div class="pod-header">
|
|
470
|
+
<div class="pod-header-row">
|
|
471
|
+
<select class="pod-select" aria-label="Pod storage">
|
|
472
|
+
<option value="">Loading pods...</option>
|
|
473
|
+
</select>
|
|
474
|
+
<sol-login class="pod-login" visible></sol-login>
|
|
475
|
+
<button class="pod-settings-btn" type="button" title="Settings"
|
|
476
|
+
aria-label="Display settings" aria-expanded="false">⚙</button>
|
|
477
|
+
</div>
|
|
478
|
+
<div class="pod-settings" role="group" aria-label="Display settings">
|
|
479
|
+
<label><input type="checkbox" data-pref="hideDot"> Hide dot-files</label>
|
|
480
|
+
<label><input type="checkbox" data-pref="hideHash"> Hide #-files</label>
|
|
481
|
+
<label><input type="checkbox" data-pref="hideTilde"> Hide ~ backups</label>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
<div class="breadcrumb"></div>
|
|
485
|
+
<div class="pod-filter-row">
|
|
486
|
+
<input class="pod-filter" type="search"
|
|
487
|
+
placeholder="Filter (type to search, esc to clear)"
|
|
488
|
+
aria-label="Filter items in this container" />
|
|
489
|
+
</div>
|
|
490
|
+
<div class="tree-wrapper" tabindex="0">
|
|
491
|
+
<div class="empty">Loading...</div>
|
|
492
|
+
</div>`;
|
|
493
|
+
s.adoptedStyleSheets = [];
|
|
494
|
+
adopt(s, { sheet: POD_SHEET, css: CSS });
|
|
495
|
+
|
|
496
|
+
const sel = s.querySelector('.pod-select');
|
|
497
|
+
sel.addEventListener('change', () => {
|
|
498
|
+
if (sel.value === '__add__') {
|
|
499
|
+
this._promptAddPod();
|
|
500
|
+
} else if (sel.value) {
|
|
501
|
+
this._rootUrl = sel.value;
|
|
502
|
+
this.loadContainer(sel.value);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const filter = s.querySelector('.pod-filter');
|
|
507
|
+
filter.addEventListener('input', () => {
|
|
508
|
+
this._filterText = filter.value;
|
|
509
|
+
this._renderTree(this._allItems, { preserveFocus: false });
|
|
510
|
+
});
|
|
511
|
+
filter.addEventListener('keydown', (e) => {
|
|
512
|
+
if (e.key === 'Escape') {
|
|
513
|
+
filter.value = '';
|
|
514
|
+
this._filterText = '';
|
|
515
|
+
this._renderTree(this._allItems, { preserveFocus: false });
|
|
516
|
+
this.shadowRoot.querySelector('.tree-wrapper')?.focus();
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
} else if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
519
|
+
// Move focus into the list.
|
|
520
|
+
const ul = this.shadowRoot.querySelector('.file-tree');
|
|
521
|
+
const first = ul?.querySelector('li');
|
|
522
|
+
if (first) {
|
|
523
|
+
this._focusIndex = 0;
|
|
524
|
+
first.focus();
|
|
525
|
+
e.preventDefault();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Display-settings panel — toggle the pattern prefs (hide dot / # / ~).
|
|
531
|
+
const settingsBtn = s.querySelector('.pod-settings-btn');
|
|
532
|
+
const settings = s.querySelector('.pod-settings');
|
|
533
|
+
settingsBtn.addEventListener('click', () => {
|
|
534
|
+
const open = settings.classList.toggle('open');
|
|
535
|
+
settingsBtn.setAttribute('aria-expanded', String(open));
|
|
536
|
+
if (open) {
|
|
537
|
+
settings.querySelectorAll('input[data-pref]').forEach(cb => {
|
|
538
|
+
cb.checked = !!this._prefs[cb.dataset.pref];
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
settings.addEventListener('change', (e) => {
|
|
543
|
+
const cb = e.target;
|
|
544
|
+
if (!cb?.dataset?.pref) return;
|
|
545
|
+
this._prefs[cb.dataset.pref] = cb.checked;
|
|
546
|
+
this._reapplyPrefs();
|
|
547
|
+
// Let a host persist the change — the panel itself keeps no storage.
|
|
548
|
+
this.dispatchEvent(new CustomEvent('sol-prefs-change', {
|
|
549
|
+
bubbles: true, composed: true, detail: { prefs: { ...this._prefs } }
|
|
550
|
+
}));
|
|
551
|
+
});
|
|
552
|
+
// Close the panel on any click outside it. composedPath() crosses the
|
|
553
|
+
// shadow boundary, so this also catches clicks elsewhere in the pod.
|
|
554
|
+
this._onDocClick = (e) => {
|
|
555
|
+
if (!settings.classList.contains('open')) return;
|
|
556
|
+
const path = e.composedPath();
|
|
557
|
+
if (path.includes(settings) || path.includes(settingsBtn)) return;
|
|
558
|
+
settings.classList.remove('open');
|
|
559
|
+
settingsBtn.setAttribute('aria-expanded', 'false');
|
|
560
|
+
};
|
|
561
|
+
document.addEventListener('click', this._onDocClick);
|
|
562
|
+
|
|
563
|
+
// Keyboard nav at the wrapper level so it works whether the wrapper
|
|
564
|
+
// or an individual li has focus.
|
|
565
|
+
const wrapper = s.querySelector('.tree-wrapper');
|
|
566
|
+
wrapper.addEventListener('keydown', (e) => this._onWrapperKey(e));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
_populateSelect(storages) {
|
|
570
|
+
const sel = this.shadowRoot.querySelector('.pod-select');
|
|
571
|
+
if (!sel) return;
|
|
572
|
+
sel.innerHTML = '';
|
|
573
|
+
if (storages.length === 0) {
|
|
574
|
+
sel.innerHTML = '<option value="">No pods found</option>';
|
|
575
|
+
} else {
|
|
576
|
+
storages.forEach(url => {
|
|
577
|
+
const opt = document.createElement('option');
|
|
578
|
+
opt.value = url;
|
|
579
|
+
// Strip the scheme for display; the value still carries the
|
|
580
|
+
// full URL so selection / fetch keep working.
|
|
581
|
+
opt.textContent = url.replace(/^https?:\/\//, '');
|
|
582
|
+
sel.appendChild(opt);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const addOpt = document.createElement('option');
|
|
586
|
+
addOpt.value = '__add__'; addOpt.textContent = '\uFF0B Add a Pod...';
|
|
587
|
+
sel.appendChild(addOpt);
|
|
588
|
+
|
|
589
|
+
// Offer the pod storages (which double as OIDC issuers) as login
|
|
590
|
+
// choices, so clicking the login button drops down the pod list.
|
|
591
|
+
// Merge rather than replace \u2014 any host-configured issuers survive.
|
|
592
|
+
if (this._loginEl) storages.forEach(u => this._loginEl.addIssuer(u));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async _promptAddPod() {
|
|
596
|
+
const sel = this.shadowRoot.querySelector('.pod-select');
|
|
597
|
+
const prev = this._currentPath || sel.options[0]?.value;
|
|
598
|
+
if (prev && prev !== '__add__') sel.value = prev;
|
|
599
|
+
|
|
600
|
+
// Use sol-modal prompt if available, else native prompt
|
|
601
|
+
let url;
|
|
602
|
+
if (customElements.get('sol-modal')) {
|
|
603
|
+
const { SolModal } = await import('./sol-modal.js');
|
|
604
|
+
url = await SolModal.prompt('Enter pod URL:', 'https://example.solidcommunity.net/');
|
|
605
|
+
} else {
|
|
606
|
+
url = prompt('Enter pod URL:', 'https://example.solidcommunity.net/');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!url || !url.startsWith('http')) {
|
|
610
|
+
if (prev && prev !== '__add__') sel.value = prev;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const normalized = url.endsWith('/') ? url : url + '/';
|
|
614
|
+
// Adding to the registry repopulates the selector(s) for the group.
|
|
615
|
+
this._registry?.add(normalized);
|
|
616
|
+
sel.value = normalized;
|
|
617
|
+
this._rootUrl = normalized;
|
|
618
|
+
this.dispatchEvent(new CustomEvent('sol-pod-add', {
|
|
619
|
+
bubbles: true, composed: true, detail: { url: normalized }
|
|
620
|
+
}));
|
|
621
|
+
await this.loadContainer(normalized);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
_showLoading() {
|
|
625
|
+
const tw = this.shadowRoot.querySelector('.tree-wrapper');
|
|
626
|
+
if (tw) tw.innerHTML = '<div class="loading">Loading...</div>';
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
_showMessage(msg, isError) {
|
|
630
|
+
const tw = this.shadowRoot.querySelector('.tree-wrapper');
|
|
631
|
+
if (tw) tw.innerHTML = `<div class="empty${isError ? ' error' : ''}">${msg}</div>`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
_updateBreadcrumb(url) {
|
|
635
|
+
const el = this.shadowRoot.querySelector('.breadcrumb');
|
|
636
|
+
if (!el || !this._rootUrl) return;
|
|
637
|
+
el.innerHTML = '';
|
|
638
|
+
const home = document.createElement('button');
|
|
639
|
+
home.textContent = '\u{1F3E0}'; home.className = 'sol-btn sol-btn-sm sol-btn-ghost';
|
|
640
|
+
home.onclick = () => this.loadContainer(this._rootUrl);
|
|
641
|
+
el.appendChild(home);
|
|
642
|
+
if (url !== this._rootUrl) {
|
|
643
|
+
const parts = url.replace(this._rootUrl, '').split('/').filter(Boolean);
|
|
644
|
+
let cur = this._rootUrl;
|
|
645
|
+
parts.forEach(part => {
|
|
646
|
+
cur += part + '/'; const pathUrl = cur;
|
|
647
|
+
el.appendChild(document.createTextNode(' / '));
|
|
648
|
+
const btn = document.createElement('button');
|
|
649
|
+
btn.textContent = part; btn.className = 'sol-btn sol-btn-sm sol-btn-ghost';
|
|
650
|
+
btn.onclick = () => this.loadContainer(pathUrl);
|
|
651
|
+
el.appendChild(btn);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Gear at the right edge — activates the current container (host
|
|
656
|
+
// podClickAction first, else the sol-pod-ops modal). Same icon
|
|
657
|
+
// treatment as per-item gears so a `gear-icon` attribute applies
|
|
658
|
+
// consistently across the whole view.
|
|
659
|
+
const gear = document.createElement('button');
|
|
660
|
+
gear.className = 'sol-btn sol-btn-sm sol-btn-ghost crumb-gear';
|
|
661
|
+
gear.title = 'Edit this folder';
|
|
662
|
+
gear.setAttribute('aria-label', 'Edit this folder');
|
|
663
|
+
this._paintGearIcon(gear);
|
|
664
|
+
gear.onclick = () => this._openCurrentContainerModal();
|
|
665
|
+
el.appendChild(gear);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Paint a gear button using the `gear-icon` attribute (URL → img,
|
|
669
|
+
* short string → text, absent → default ⚙). Shared by per-item
|
|
670
|
+
* and breadcrumb gears. */
|
|
671
|
+
_paintGearIcon(btn) {
|
|
672
|
+
btn.textContent = '';
|
|
673
|
+
const iconAttr = this.getAttribute('gear-icon');
|
|
674
|
+
if (iconAttr && /\/|\.(svg|png|jpe?g|gif|webp)$/i.test(iconAttr)) {
|
|
675
|
+
const img = document.createElement('img');
|
|
676
|
+
img.src = iconAttr;
|
|
677
|
+
img.alt = '';
|
|
678
|
+
btn.appendChild(img);
|
|
679
|
+
} else {
|
|
680
|
+
btn.textContent = iconAttr || '⚙';
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
_openCurrentContainerModal() {
|
|
685
|
+
const u = this._currentPath || this._rootUrl;
|
|
686
|
+
if (!u) return;
|
|
687
|
+
const trimmed = u.replace(/\/$/, '');
|
|
688
|
+
const name = trimmed.split('/').pop() || u;
|
|
689
|
+
let displayName;
|
|
690
|
+
try { displayName = decodeURIComponent(name); } catch { displayName = name; }
|
|
691
|
+
// Route through _activateItem so a host-supplied podClickAction
|
|
692
|
+
// (e.g. dk-solidos navigating the iframe to this container) gets
|
|
693
|
+
// first refusal — same precedence as per-item activation. Falls
|
|
694
|
+
// through to the pod-ops modal when no handler is wired.
|
|
695
|
+
this._activateItem({
|
|
696
|
+
url: u,
|
|
697
|
+
name,
|
|
698
|
+
displayName,
|
|
699
|
+
isContainer: true,
|
|
700
|
+
contentType: 'text/turtle',
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
_renderTree(allItems, { preserveFocus = true } = {}) {
|
|
705
|
+
this._allItems = allItems;
|
|
706
|
+
const visible = this._applyFilter(allItems);
|
|
707
|
+
this._currentItems = visible;
|
|
708
|
+
|
|
709
|
+
// Drop selections that are no longer visible.
|
|
710
|
+
const visibleUrls = new Set(visible.map(it => it.url));
|
|
711
|
+
for (const u of [...this._selected]) if (!visibleUrls.has(u)) this._selected.delete(u);
|
|
712
|
+
|
|
713
|
+
const tw = this.shadowRoot.querySelector('.tree-wrapper');
|
|
714
|
+
const prevFocusUrl = preserveFocus
|
|
715
|
+
? tw.querySelector('li:focus')?.dataset?.url || null
|
|
716
|
+
: null;
|
|
717
|
+
tw.innerHTML = '';
|
|
718
|
+
|
|
719
|
+
if (visible.length === 0) {
|
|
720
|
+
const msg = this._filterText
|
|
721
|
+
? `No matches for "${this._filterText}"`
|
|
722
|
+
: (allItems.length === 0 ? 'Empty container' : 'No matches');
|
|
723
|
+
tw.innerHTML = `<div class="empty">${msg}</div>`;
|
|
724
|
+
this._focusIndex = -1;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const ul = document.createElement('ul');
|
|
729
|
+
ul.className = 'file-tree';
|
|
730
|
+
visible.forEach((item, idx) => ul.appendChild(this._createTreeItem(item, idx)));
|
|
731
|
+
tw.appendChild(ul);
|
|
732
|
+
|
|
733
|
+
// Restore focus if requested (linear scan — URLs contain characters that
|
|
734
|
+
// are awkward to escape in a CSS attribute selector).
|
|
735
|
+
if (prevFocusUrl) {
|
|
736
|
+
const li = Array.from(ul.children).find(el => el.dataset.url === prevFocusUrl);
|
|
737
|
+
if (li) {
|
|
738
|
+
li.focus();
|
|
739
|
+
this._focusIndex = Number(li.dataset.index);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Drop zone
|
|
744
|
+
tw.ondragover = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; tw.parentElement?.classList.add('drag-over'); };
|
|
745
|
+
tw.ondragleave = (e) => { if (e.target === tw) tw.parentElement?.classList.remove('drag-over'); };
|
|
746
|
+
tw.ondrop = (e) => { e.preventDefault(); tw.parentElement?.classList.remove('drag-over'); };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
_applyFilter(items) {
|
|
750
|
+
const q = (this._filterText || '').trim().toLowerCase();
|
|
751
|
+
if (!q) return items;
|
|
752
|
+
return items.filter(it => {
|
|
753
|
+
const n = (it.displayName || it.name || '').toLowerCase();
|
|
754
|
+
return n.includes(q);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
_onWrapperKey(e) {
|
|
759
|
+
// Don't intercept while the filter input is the target.
|
|
760
|
+
if (e.target?.classList?.contains('pod-filter')) return;
|
|
761
|
+
|
|
762
|
+
const ul = this.shadowRoot.querySelector('.file-tree');
|
|
763
|
+
const items = ul ? Array.from(ul.children) : [];
|
|
764
|
+
const focusEl = this.shadowRoot.activeElement;
|
|
765
|
+
let idx = focusEl?.tagName === 'LI'
|
|
766
|
+
? items.indexOf(focusEl)
|
|
767
|
+
: (this._focusIndex >= 0 ? this._focusIndex : -1);
|
|
768
|
+
|
|
769
|
+
const focusAt = (i) => {
|
|
770
|
+
if (i < 0 || i >= items.length) return;
|
|
771
|
+
this._focusIndex = i;
|
|
772
|
+
items[i].focus();
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
switch (e.key) {
|
|
776
|
+
case 'ArrowDown':
|
|
777
|
+
focusAt(idx < 0 ? 0 : Math.min(items.length - 1, idx + 1));
|
|
778
|
+
e.preventDefault();
|
|
779
|
+
break;
|
|
780
|
+
case 'ArrowUp':
|
|
781
|
+
focusAt(idx <= 0 ? 0 : idx - 1);
|
|
782
|
+
e.preventDefault();
|
|
783
|
+
break;
|
|
784
|
+
case 'Home':
|
|
785
|
+
focusAt(0);
|
|
786
|
+
e.preventDefault();
|
|
787
|
+
break;
|
|
788
|
+
case 'End':
|
|
789
|
+
focusAt(items.length - 1);
|
|
790
|
+
e.preventDefault();
|
|
791
|
+
break;
|
|
792
|
+
case 'Enter': {
|
|
793
|
+
if (idx < 0) return;
|
|
794
|
+
const item = this._currentItems[idx];
|
|
795
|
+
if (!item) return;
|
|
796
|
+
if (item.isContainer) this.loadContainer(item.url);
|
|
797
|
+
else this._activateItem(item);
|
|
798
|
+
e.preventDefault();
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
case 'Backspace': {
|
|
802
|
+
const parent = this._parentOf(this._currentPath);
|
|
803
|
+
if (parent) { this.loadContainer(parent); e.preventDefault(); }
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case '/': {
|
|
807
|
+
const f = this.shadowRoot.querySelector('.pod-filter');
|
|
808
|
+
if (f) { f.focus(); f.select(); e.preventDefault(); }
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
case 'Escape':
|
|
812
|
+
if (this._filterText) {
|
|
813
|
+
this._filterText = '';
|
|
814
|
+
const f = this.shadowRoot.querySelector('.pod-filter');
|
|
815
|
+
if (f) f.value = '';
|
|
816
|
+
this._renderTree(this._allItems, { preserveFocus: false });
|
|
817
|
+
e.preventDefault();
|
|
818
|
+
}
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
_parentOf(url) {
|
|
824
|
+
if (!url || !this._rootUrl) return null;
|
|
825
|
+
if (url === this._rootUrl) return null;
|
|
826
|
+
const u = url.endsWith('/') ? url.slice(0, -1) : url;
|
|
827
|
+
const i = u.lastIndexOf('/');
|
|
828
|
+
if (i < 0) return null;
|
|
829
|
+
const p = u.slice(0, i + 1);
|
|
830
|
+
return p.startsWith(this._rootUrl) ? p : this._rootUrl;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async _activateItem(item) {
|
|
834
|
+
if (typeof this._podClickAction === 'function') {
|
|
835
|
+
this._podClickAction(await this._withContentType(item), this);
|
|
836
|
+
} else if (typeof this._podClickAction === 'string') {
|
|
837
|
+
this._openNamedHandler(this._podClickAction, item);
|
|
838
|
+
} else {
|
|
839
|
+
this._openItemModal(item);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// HEAD the clicked resource so a function podClickAction receives the
|
|
844
|
+
// server's real Content-Type rather than the extension-inferred guess.
|
|
845
|
+
// One request, only for the clicked item; on failure the guess stands.
|
|
846
|
+
async _withContentType(item) {
|
|
847
|
+
try {
|
|
848
|
+
const resp = await this._fetchFor(item.url)(item.url, { method: 'HEAD' });
|
|
849
|
+
const ct = (resp.headers.get('Content-Type') || '').split(';')[0].trim();
|
|
850
|
+
if (ct) return { ...item, contentType: ct };
|
|
851
|
+
} catch (e) { /* keep the inferred contentType */ }
|
|
852
|
+
return item;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
_createTreeItem(item, idx) {
|
|
856
|
+
const li = document.createElement('li');
|
|
857
|
+
li.className = item.isContainer ? 'folder' : 'file';
|
|
858
|
+
li.tabIndex = 0;
|
|
859
|
+
li.dataset.url = item.url;
|
|
860
|
+
li.dataset.index = String(idx);
|
|
861
|
+
|
|
862
|
+
const label = document.createElement('span');
|
|
863
|
+
label.className = 'item-label';
|
|
864
|
+
label.textContent = `${item.isContainer ? '\u{1F4C1}' : fileIcon(item.name)} ${item.displayName || item.name}`;
|
|
865
|
+
li.appendChild(label);
|
|
866
|
+
|
|
867
|
+
const openItemAction = (e) => {
|
|
868
|
+
e.stopPropagation();
|
|
869
|
+
e.preventDefault();
|
|
870
|
+
this._activateItem(item);
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const gear = document.createElement('button');
|
|
874
|
+
gear.className = 'item-gear';
|
|
875
|
+
gear.title = 'Actions';
|
|
876
|
+
this._paintGearIcon(gear);
|
|
877
|
+
gear.onclick = openItemAction;
|
|
878
|
+
li.appendChild(gear);
|
|
879
|
+
|
|
880
|
+
const handleSelectClick = (e) => {
|
|
881
|
+
if (e.shiftKey && this._lastSelectedIndex >= 0) {
|
|
882
|
+
const a = Math.min(this._lastSelectedIndex, idx);
|
|
883
|
+
const b = Math.max(this._lastSelectedIndex, idx);
|
|
884
|
+
this._selected.clear();
|
|
885
|
+
for (let i = a; i <= b; i++) this._selected.add(this._currentItems[i].url);
|
|
886
|
+
} else if (e.ctrlKey || e.metaKey) {
|
|
887
|
+
if (this._selected.has(item.url)) this._selected.delete(item.url);
|
|
888
|
+
else this._selected.add(item.url);
|
|
889
|
+
this._lastSelectedIndex = idx;
|
|
890
|
+
} else {
|
|
891
|
+
this._selected.clear();
|
|
892
|
+
this._selected.add(item.url);
|
|
893
|
+
this._lastSelectedIndex = idx;
|
|
894
|
+
}
|
|
895
|
+
this._refreshSelectionUI();
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// Drag
|
|
899
|
+
li.draggable = true;
|
|
900
|
+
li.ondragstart = (e) => {
|
|
901
|
+
let items;
|
|
902
|
+
if (this._selected.has(item.url) && this._selected.size > 1) {
|
|
903
|
+
items = this._currentItems.filter(it => this._selected.has(it.url));
|
|
904
|
+
} else {
|
|
905
|
+
this._selected.clear();
|
|
906
|
+
this._selected.add(item.url);
|
|
907
|
+
this._lastSelectedIndex = idx;
|
|
908
|
+
this._refreshSelectionUI();
|
|
909
|
+
items = [item];
|
|
910
|
+
}
|
|
911
|
+
this._draggedItem = items[0];
|
|
912
|
+
e.dataTransfer.effectAllowed = 'copyMove';
|
|
913
|
+
e.dataTransfer.setData('text/plain', items.map(it => it.url).join('\n'));
|
|
914
|
+
li.classList.add('dragging');
|
|
915
|
+
this.dispatchEvent(new CustomEvent('sol-drag-start', {
|
|
916
|
+
bubbles: true, composed: true, detail: { items, item: items[0], element: this }
|
|
917
|
+
}));
|
|
918
|
+
};
|
|
919
|
+
li.ondragend = () => {
|
|
920
|
+
li.classList.remove('dragging');
|
|
921
|
+
this.dispatchEvent(new CustomEvent('sol-drag-end', { bubbles: true, composed: true }));
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
if (item.isContainer) {
|
|
925
|
+
li.onclick = (e) => {
|
|
926
|
+
if (e.shiftKey || e.ctrlKey || e.metaKey) { handleSelectClick(e); return; }
|
|
927
|
+
if (!li.classList.contains('dragging')) this.loadContainer(item.url);
|
|
928
|
+
};
|
|
929
|
+
} else {
|
|
930
|
+
li.onclick = handleSelectClick;
|
|
931
|
+
li.ondblclick = openItemAction;
|
|
932
|
+
}
|
|
933
|
+
// Keyboard activation (Enter / Backspace / arrows / `/`) is handled at
|
|
934
|
+
// the .tree-wrapper level — see _onWrapperKey.
|
|
935
|
+
|
|
936
|
+
return li;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
_refreshSelectionUI() {
|
|
940
|
+
const ul = this.shadowRoot.querySelector('.file-tree');
|
|
941
|
+
if (!ul) return;
|
|
942
|
+
for (const li of ul.children) {
|
|
943
|
+
li.classList.toggle('selected', this._selected.has(li.dataset.url));
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ── Item modal — delegates to <sol-pod-ops> when it is loaded ───────
|
|
948
|
+
|
|
949
|
+
_openItemModal(item) {
|
|
950
|
+
// sol-pod-ops is an optional add-on. Without it — and with no
|
|
951
|
+
// podClickAction wired up — show a short how-to instead.
|
|
952
|
+
if (!customElements.get('sol-pod-ops')) { this._openHelpModal(item); return; }
|
|
953
|
+
|
|
954
|
+
const modal = document.createElement('sol-modal');
|
|
955
|
+
modal.modalTitle = item.isContainer ? `Folder: ${item.displayName || item.name}` : (item.displayName || item.name);
|
|
956
|
+
modal.styles = [POD_MODAL_SHEET || POD_MODAL_CSS];
|
|
957
|
+
|
|
958
|
+
modal.handler = (body) => {
|
|
959
|
+
body.style.padding = '0';
|
|
960
|
+
body.style.overflow = 'hidden';
|
|
961
|
+
const ops = document.createElement('sol-pod-ops');
|
|
962
|
+
ops.item = item;
|
|
963
|
+
ops.fetchFn = this._fetchFor(item.url);
|
|
964
|
+
ops.setAttribute('source', item.url);
|
|
965
|
+
ops.style.height = '100%';
|
|
966
|
+
ops.addEventListener('sol-status', (e) => this._emitStatus(e.detail.message, e.detail.type));
|
|
967
|
+
ops.addEventListener('sol-navigate', async () => {
|
|
968
|
+
modal.close();
|
|
969
|
+
await this.loadContainer(this._currentPath);
|
|
970
|
+
});
|
|
971
|
+
body.appendChild(ops);
|
|
972
|
+
};
|
|
973
|
+
modal.open();
|
|
974
|
+
this._modal = modal;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Shown when an item is activated but nothing is set up to handle it —
|
|
978
|
+
// no podClickAction, and <sol-pod-ops> has not been loaded.
|
|
979
|
+
_openHelpModal(item) {
|
|
980
|
+
const modal = document.createElement('sol-modal');
|
|
981
|
+
modal.modalTitle = item.displayName || item.name;
|
|
982
|
+
modal.setAttribute('size', 'large');
|
|
983
|
+
modal.handler = (body) => {
|
|
984
|
+
body.style.padding = '16px 20px';
|
|
985
|
+
body.innerHTML =
|
|
986
|
+
'<p>Nothing is wired up to handle this item.</p>' +
|
|
987
|
+
'<p>Set a <code>podClickAction</code> on the <sol-pod> element ' +
|
|
988
|
+
'to receive item clicks and render the info into your page:</p>' +
|
|
989
|
+
'<pre>document.querySelector(\'sol-pod\').podClickAction =\n' +
|
|
990
|
+
' (item) => { /* item: url, name, displayName, isContainer, contentType */ };</pre>' +
|
|
991
|
+
'<p>Or load the <code>sol-pod-ops</code> script for the built-in ' +
|
|
992
|
+
'file-operations panel.</p>';
|
|
993
|
+
};
|
|
994
|
+
modal.open();
|
|
995
|
+
this._modal = modal;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ── Named gear handlers ─────────────────────────────────────────────
|
|
999
|
+
|
|
1000
|
+
async _openNamedHandler(name, item) {
|
|
1001
|
+
switch (name) {
|
|
1002
|
+
case 'solidos': return this._openSolidosModal(item);
|
|
1003
|
+
default: return this._openItemModal(item);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
_openSolidosModal(item) {
|
|
1008
|
+
// sol-solidos is optional too — fall back to the item modal without it.
|
|
1009
|
+
if (!customElements.get('sol-solidos')) { this._openItemModal(item); return; }
|
|
1010
|
+
|
|
1011
|
+
const modal = document.createElement('sol-modal');
|
|
1012
|
+
modal.modalTitle = item.displayName || item.name;
|
|
1013
|
+
modal.size = 'large';
|
|
1014
|
+
|
|
1015
|
+
modal.handler = (body) => {
|
|
1016
|
+
body.style.padding = '0';
|
|
1017
|
+
body.style.overflow = 'hidden';
|
|
1018
|
+
const solidos = document.createElement('sol-solidos');
|
|
1019
|
+
solidos.setAttribute('source', item.url);
|
|
1020
|
+
solidos.style.height = '100%';
|
|
1021
|
+
body.appendChild(solidos);
|
|
1022
|
+
};
|
|
1023
|
+
modal.open();
|
|
1024
|
+
this._modal = modal;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ── Status emission ─────────────────────────────────────────────────
|
|
1028
|
+
|
|
1029
|
+
_emitStatus(message, type) {
|
|
1030
|
+
this.dispatchEvent(new CustomEvent('sol-status', {
|
|
1031
|
+
bubbles: true, composed: true,
|
|
1032
|
+
detail: { message, type }
|
|
1033
|
+
}));
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
define('sol-pod', SolPod);
|
|
1038
|
+
export { SolPod };
|
|
1039
|
+
export default SolPod;
|