wiki-plugin-lucille 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +201 -0
- package/client/lucille.js +198 -0
- package/factory.json +7 -0
- package/index.js +4 -0
- package/package.json +16 -0
- package/server/server.js +1294 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# wiki-plugin-lucille — Developer Documentation
|
|
2
|
+
|
|
3
|
+
P2P video hosting for Federated Wiki, backed by DigitalOcean Spaces (cold storage) and WebTorrent (live delivery).
|
|
4
|
+
|
|
5
|
+
**Current version**: 0.0.1
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
Follows the Service-Bundling Plugin Pattern. The wiki plugin (`wiki-plugin-lucille`) spawns and proxies a standalone Node.js service (`lucille`). Each wiki instance gets its own DO Spaces bucket and its own BitTorrent tracker.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Federated Wiki
|
|
13
|
+
└── wiki-plugin-lucille (CommonJS plugin)
|
|
14
|
+
├── server/server.js ← spawns lucille, proxies /plugin/lucille/*
|
|
15
|
+
├── client/lucille.js ← renders items in the wiki journal
|
|
16
|
+
└── node_modules/lucille ← the lucille service (ESM)
|
|
17
|
+
├── lucille.js ← Express API + tracker + seeder bootstrap
|
|
18
|
+
└── src/
|
|
19
|
+
├── config.js ← tiers, federation peers
|
|
20
|
+
├── seeder.js ← WebTorrent seeder
|
|
21
|
+
├── tracker.js ← bittorrent-tracker server
|
|
22
|
+
├── spaces.js ← DO Spaces upload/presign
|
|
23
|
+
└── persistence/ ← file-based key-value DB
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Service modes
|
|
27
|
+
|
|
28
|
+
The lucille service supports three modes via `LUCILLE_MODE` env var:
|
|
29
|
+
|
|
30
|
+
| Mode | Description |
|
|
31
|
+
|------|-------------|
|
|
32
|
+
| `all` | API + tracker + seeder (default for wiki plugin) |
|
|
33
|
+
| `tracker` | Tracker only (no API, no seeding) |
|
|
34
|
+
| `api` | API only (no tracker, no seeder) |
|
|
35
|
+
|
|
36
|
+
### Storage and Seeding Flow
|
|
37
|
+
|
|
38
|
+
1. Client uploads video → `PUT /user/:uuid/video/:title/file`
|
|
39
|
+
2. Server writes to temp file, hashes (SHA-256), uploads to DO Spaces
|
|
40
|
+
3. `seedVideo(spacesKey)` downloads the file from Spaces and seeds via WebTorrent
|
|
41
|
+
4. Magnet URI + infoHash stored in video record
|
|
42
|
+
5. Player at `GET /watch/:videoId` loads player.html, which fetches magnet from `GET /video/:videoId/watch` and streams via WebTorrent.js
|
|
43
|
+
|
|
44
|
+
### Federation
|
|
45
|
+
|
|
46
|
+
Each lucille instance publishes its tracker URL at `GET /federation/trackers`. On startup, the seeder fetches all peer tracker URLs from configured federation peers and builds a unified announce list. This means every video is announced on every peer's tracker — no central registry needed.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Instance A startup:
|
|
50
|
+
1. Start own tracker (ws://wiki-a.example.com:8000)
|
|
51
|
+
2. Fetch wiki-b.example.com/plugin/lucille/federation/trackers
|
|
52
|
+
3. Build announce list: [ws://wiki-a:8000, ws://wiki-b:9000]
|
|
53
|
+
4. seedAll() — each torrent announces to both trackers
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Tiers
|
|
57
|
+
|
|
58
|
+
Wiki owners configure their own tiers. Defaults:
|
|
59
|
+
|
|
60
|
+
| Tier | Price | Storage | Videos | Duration |
|
|
61
|
+
|------|-------|---------|--------|----------|
|
|
62
|
+
| Free | 0 MP | 500 MB | 5 | 10 min |
|
|
63
|
+
| Creator | 500 MP | 10 GB | 50 | 1 hr |
|
|
64
|
+
| Pro | 2000 MP | unlimited | unlimited | unlimited |
|
|
65
|
+
|
|
66
|
+
`0` in storageLimit/videoLimit/durationLimit means unlimited.
|
|
67
|
+
|
|
68
|
+
Quota is enforced at:
|
|
69
|
+
- `PUT /user/:uuid/video/:title` — video count check
|
|
70
|
+
- `PUT /user/:uuid/video/:title/file` — storage check + video count check
|
|
71
|
+
|
|
72
|
+
Quota errors return HTTP 402 with `{ error: "...", upgradeRequired: true }`.
|
|
73
|
+
|
|
74
|
+
## Setup / Onboarding
|
|
75
|
+
|
|
76
|
+
Wiki owner visits `/plugin/lucille/setup` to configure:
|
|
77
|
+
- **DO Spaces credentials** — key, secret, region, bucket, CDN endpoint
|
|
78
|
+
- **Tracker** — port (must be open in firewall) + public hostname for federation
|
|
79
|
+
- **Tiers** — JSON array of tier definitions
|
|
80
|
+
- **Federation peers** — newline-separated base URLs of other lucille instances
|
|
81
|
+
- **Sanora URL** — for tier upgrade payments via Addie
|
|
82
|
+
|
|
83
|
+
Config stored at `~/.lucille/config.json`.
|
|
84
|
+
|
|
85
|
+
## Routes
|
|
86
|
+
|
|
87
|
+
### Plugin (served by wiki plugin server.js)
|
|
88
|
+
|
|
89
|
+
| Method | Path | Description |
|
|
90
|
+
|--------|------|-------------|
|
|
91
|
+
| `GET` | `/plugin/lucille/setup` | Onboarding UI |
|
|
92
|
+
| `POST` | `/plugin/lucille/setup` | Save config + (re)launch lucille |
|
|
93
|
+
| `GET` | `/plugin/lucille/setup/status` | Config + running status JSON |
|
|
94
|
+
| `GET` | `/plugin/lucille/version-status` | Installed vs npm version |
|
|
95
|
+
| `POST` | `/plugin/lucille/update` | `npm install lucille@latest` |
|
|
96
|
+
| `*` | `/plugin/lucille/*` | Proxied to lucille service |
|
|
97
|
+
|
|
98
|
+
### Lucille service (proxied at `/plugin/lucille/*`)
|
|
99
|
+
|
|
100
|
+
| Method | Path | Auth | Description |
|
|
101
|
+
|--------|------|------|-------------|
|
|
102
|
+
| `GET` | `/health` | — | Health check |
|
|
103
|
+
| `GET` | `/status` | — | Seeder/tracker status |
|
|
104
|
+
| `GET` | `/config` | — | Current config (no credentials) |
|
|
105
|
+
| `POST` | `/config` | — | Update tiers/peers/sanoraUrl |
|
|
106
|
+
| `GET` | `/tiers` | — | Tier list |
|
|
107
|
+
| `GET` | `/federation/trackers` | — | Own tracker URL + lucille base URL |
|
|
108
|
+
| `PUT` | `/user/create` | Sessionless | Create user |
|
|
109
|
+
| `GET` | `/user/:uuid` | Sessionless | Get user + tier details |
|
|
110
|
+
| `PUT` | `/user/:uuid/tier` | Sessionless | Upgrade tier |
|
|
111
|
+
| `PUT` | `/user/:uuid/video/:title` | Sessionless | Register video metadata |
|
|
112
|
+
| `GET` | `/user/:uuid/videos` | Sessionless | List user's videos (private) |
|
|
113
|
+
| `GET` | `/videos/:uuid` | Public | List user's videos (public) |
|
|
114
|
+
| `PUT` | `/user/:uuid/video/:title/file` | Sessionless (headers) | Upload video file |
|
|
115
|
+
| `GET` | `/watch/:videoId` | Public | Player page (iframe-embeddable) |
|
|
116
|
+
| `GET` | `/video/:videoId/watch` | Public | Magnet + tracker JSON |
|
|
117
|
+
| `GET` | `/magnet?key=videos/…` | Internal | Magnet for a Spaces key |
|
|
118
|
+
| `POST` | `/seed` | Internal | Notify of new video key |
|
|
119
|
+
|
|
120
|
+
### Sessionless auth
|
|
121
|
+
|
|
122
|
+
All authed endpoints use the sessionless pattern:
|
|
123
|
+
- Body (PUT): `{ timestamp, pubKey, signature }` where `signature = sign(timestamp + pubKey)`
|
|
124
|
+
- Query (GET): `?timestamp=…&signature=…` where `signature = sign(timestamp + user.pubKey)`
|
|
125
|
+
- File upload (PUT `…/file`): signature in headers `x-pn-timestamp`, `x-pn-signature`
|
|
126
|
+
|
|
127
|
+
## Wiki item format
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"type": "lucille",
|
|
132
|
+
"videoId": "abc123",
|
|
133
|
+
"text": "My Video Title",
|
|
134
|
+
"description": "Short description",
|
|
135
|
+
"price": 0,
|
|
136
|
+
"playerBase": "/plugin/lucille"
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The client plugin renders a thumbnail with a play button. Clicking opens an iframe lightbox pointing to `/plugin/lucille/watch/:videoId`.
|
|
141
|
+
|
|
142
|
+
## Client plugin
|
|
143
|
+
|
|
144
|
+
`client/lucille.js` follows the standard `window.plugins.NAME = { emit, bind }` pattern.
|
|
145
|
+
|
|
146
|
+
- **emit** — renders the video card (thumbnail + title + description + price)
|
|
147
|
+
- **bind** — adds a traffic-light ◉ status indicator showing version state (gray=loading, red=not installed, yellow=update available, green=current)
|
|
148
|
+
|
|
149
|
+
The shared lightbox (`lucilleOpen`/`lucilleClose`) is injected once into `document.body` and reused across all items on the page.
|
|
150
|
+
|
|
151
|
+
## Environment variables (lucille service)
|
|
152
|
+
|
|
153
|
+
| Var | Default | Description |
|
|
154
|
+
|-----|---------|-------------|
|
|
155
|
+
| `PORT` | `5444` | API port |
|
|
156
|
+
| `LUCILLE_MODE` | `all` | `all` / `tracker` / `api` |
|
|
157
|
+
| `DO_SPACES_KEY` | — | DO Spaces access key |
|
|
158
|
+
| `DO_SPACES_SECRET` | — | DO Spaces secret |
|
|
159
|
+
| `DO_SPACES_REGION` | `nyc3` | DO Spaces region |
|
|
160
|
+
| `DO_SPACES_BUCKET` | — | Bucket name |
|
|
161
|
+
| `DO_SPACES_CDN_ENDPOINT` | — | CDN URL (optional) |
|
|
162
|
+
| `TRACKER_PORT` | `8000` | BitTorrent tracker WebSocket port |
|
|
163
|
+
| `TRACKER_URL` | `ws://localhost:8000` | Public tracker URL (used in announce lists) |
|
|
164
|
+
| `SANORA_URL` | — | Sanora base URL for payments |
|
|
165
|
+
| `ALLOWED_TIME_DIFFERENCE` | `600000` | Max timestamp skew (ms) |
|
|
166
|
+
|
|
167
|
+
## Dependencies
|
|
168
|
+
|
|
169
|
+
### wiki-plugin-lucille (CommonJS)
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"http-proxy": "^1.18.1",
|
|
173
|
+
"lucille": "file:../../lucille",
|
|
174
|
+
"node-fetch": "^2.6.1"
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### lucille service (ESM)
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"@aws-sdk/client-s3": "^3.705.0",
|
|
182
|
+
"@aws-sdk/s3-request-presigner": "^3.705.0",
|
|
183
|
+
"bittorrent-tracker": "latest",
|
|
184
|
+
"cors": "^2.8.5",
|
|
185
|
+
"express": "^4.21.2",
|
|
186
|
+
"express-fileupload": "^1.5.1",
|
|
187
|
+
"sessionless-node": "latest",
|
|
188
|
+
"webtorrent": "^2.5.1"
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Storage
|
|
193
|
+
|
|
194
|
+
- `~/.lucille/config.json` — plugin config (DO Spaces credentials, tiers, federation peers)
|
|
195
|
+
- Lucille service uses its own file-based DB in `src/persistence/` for users, videos, and keys
|
|
196
|
+
|
|
197
|
+
## nginx / firewall requirements
|
|
198
|
+
|
|
199
|
+
- Tracker port (default 8000) must be open for WebSocket connections for federation to work
|
|
200
|
+
- The wiki's nginx should not proxy-pass the tracker port (it runs on its own port, not through the wiki)
|
|
201
|
+
- For large video uploads, set `client_max_body_size` appropriately (e.g. `500M`)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// wiki-plugin-lucille — client-side plugin
|
|
2
|
+
// Renders lucille items in the fedwiki journal.
|
|
3
|
+
// Item data: { type: 'lucille', videoId, title, description, price }
|
|
4
|
+
|
|
5
|
+
window.plugins = window.plugins || {};
|
|
6
|
+
|
|
7
|
+
window.plugins.lucille = (function() {
|
|
8
|
+
|
|
9
|
+
// ── Traffic light colors (matching plugmatic) ─────────────────────────────
|
|
10
|
+
const color = {
|
|
11
|
+
gray: '#ccc',
|
|
12
|
+
red: '#f55',
|
|
13
|
+
yellow: '#fb0',
|
|
14
|
+
green: '#0e0'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ── Emit: render the item ─────────────────────────────────────────────────
|
|
18
|
+
function emit($item, item) {
|
|
19
|
+
const videoId = item.videoId || '';
|
|
20
|
+
const title = item.text || item.title || 'Untitled';
|
|
21
|
+
const description = item.description || '';
|
|
22
|
+
const price = item.price || 0;
|
|
23
|
+
const playerBase = item.playerBase || '/plugin/lucille';
|
|
24
|
+
|
|
25
|
+
// Check owner status and render config section if owner
|
|
26
|
+
fetch('/plugin/lucille/setup/status', { credentials: 'include' })
|
|
27
|
+
.then(r => r.json())
|
|
28
|
+
.then(data => {
|
|
29
|
+
if (!data.isOwner) return;
|
|
30
|
+
const configDiv = document.createElement('div');
|
|
31
|
+
configDiv.style.cssText = 'border:1px solid rgba(167,139,250,0.3);border-radius:8px;padding:12px;margin-top:8px;background:rgba(167,139,250,0.06);font-family:system-ui;';
|
|
32
|
+
const stripeStatus = data.stripeOnboarded
|
|
33
|
+
? '<span style="color:#0e0;font-size:0.75rem;">✅ Stripe payouts enabled</span>'
|
|
34
|
+
: data.serverAddieReady
|
|
35
|
+
? '<a href="/plugin/lucille/setup/stripe" target="_blank" style="color:#fb0;font-size:0.75rem;">⚠️ Complete Stripe setup →</a>'
|
|
36
|
+
: '';
|
|
37
|
+
configDiv.innerHTML = `
|
|
38
|
+
<div style="font-size:0.8rem;color:#a78bfa;margin-bottom:8px;font-weight:600;">Allyabase connection (owner only)</div>
|
|
39
|
+
<div style="display:flex;gap:6px;align-items:center;">
|
|
40
|
+
<input id="luc-url-input" value="${escapeHtml(data.allyabaseUrl || '')}" placeholder="https://dev.allyabase.com"
|
|
41
|
+
style="flex:1;background:#0d001a;border:1px solid rgba(167,139,250,0.3);border-radius:4px;padding:6px 8px;color:#e0d0ff;font-size:0.8rem;">
|
|
42
|
+
<button id="luc-url-btn"
|
|
43
|
+
style="background:#7c3aed;border:none;border-radius:4px;padding:6px 12px;color:white;cursor:pointer;font-size:0.8rem;white-space:nowrap;">
|
|
44
|
+
Save
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
<div id="luc-url-status" style="margin-top:6px;font-size:0.75rem;"></div>
|
|
48
|
+
${stripeStatus ? '<div style="margin-top:6px;">' + stripeStatus + '</div>' : ''}`;
|
|
49
|
+
$item.append(configDiv);
|
|
50
|
+
configDiv.querySelector('#luc-url-btn').addEventListener('click', function() {
|
|
51
|
+
const input = configDiv.querySelector('#luc-url-input');
|
|
52
|
+
const btn = configDiv.querySelector('#luc-url-btn');
|
|
53
|
+
const status = configDiv.querySelector('#luc-url-status');
|
|
54
|
+
const base = (input.value || '').trim().replace(/\/$/, '');
|
|
55
|
+
if (!base) { status.textContent = 'Enter an allyabase URL first'; return; }
|
|
56
|
+
btn.disabled = true; btn.textContent = 'Saving…';
|
|
57
|
+
fetch('/plugin/lucille/setup', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
credentials: 'include',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ allyabaseUrl: base })
|
|
62
|
+
})
|
|
63
|
+
.then(r => r.json())
|
|
64
|
+
.then(d => {
|
|
65
|
+
if (d.error) { status.innerHTML = '<span style="color:#f55;">' + escapeHtml(d.error) + '</span>'; return; }
|
|
66
|
+
status.innerHTML = '<span style="color:#0e0;">Saved</span>';
|
|
67
|
+
})
|
|
68
|
+
.catch(e => { status.innerHTML = '<span style="color:#f55;">' + escapeHtml(e.message) + '</span>'; })
|
|
69
|
+
.finally(() => { btn.disabled = false; btn.textContent = 'Save'; });
|
|
70
|
+
});
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {});
|
|
73
|
+
|
|
74
|
+
if (!videoId) {
|
|
75
|
+
// No video yet — show setup prompt
|
|
76
|
+
$item.append(`
|
|
77
|
+
<div style="
|
|
78
|
+
background: rgba(167,139,250,0.08);
|
|
79
|
+
border: 2px dashed rgba(167,139,250,0.3);
|
|
80
|
+
border-radius: 10px;
|
|
81
|
+
padding: 20px;
|
|
82
|
+
text-align: center;
|
|
83
|
+
color: #a78bfa;
|
|
84
|
+
font-family: system-ui;
|
|
85
|
+
">
|
|
86
|
+
<div style="font-size: 2rem; margin-bottom: 8px;">🎬</div>
|
|
87
|
+
<div style="font-weight: 600;">${escapeHtml(title)}</div>
|
|
88
|
+
<div style="font-size: 0.85rem; margin-top: 8px; opacity: 0.7;">No video uploaded yet</div>
|
|
89
|
+
</div>
|
|
90
|
+
`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const playerUrl = `${playerBase}/watch/${encodeURIComponent(videoId)}`;
|
|
95
|
+
|
|
96
|
+
$item.append(`
|
|
97
|
+
<div class="lucille-item" style="font-family: system-ui;">
|
|
98
|
+
<div class="lucille-thumb" style="
|
|
99
|
+
position: relative;
|
|
100
|
+
background: #111;
|
|
101
|
+
border-radius: 8px;
|
|
102
|
+
overflow: hidden;
|
|
103
|
+
aspect-ratio: 16/9;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
" onclick="window.lucilleOpen('${escapeAttr(playerUrl)}', '${escapeAttr(title)}')">
|
|
106
|
+
<div style="
|
|
107
|
+
position: absolute; inset: 0;
|
|
108
|
+
display: flex; align-items: center; justify-content: center;
|
|
109
|
+
background: linear-gradient(135deg, #1a0033 0%, #0a001a 100%);
|
|
110
|
+
">
|
|
111
|
+
<span style="font-size: 48px; opacity: 0.7; filter: drop-shadow(0 0 12px #a78bfa);">▶</span>
|
|
112
|
+
</div>
|
|
113
|
+
<div style="
|
|
114
|
+
position: absolute; bottom: 0; left: 0; right: 0;
|
|
115
|
+
padding: 10px 12px;
|
|
116
|
+
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
|
|
117
|
+
color: white;
|
|
118
|
+
font-size: 0.9rem;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
">${escapeHtml(title)}</div>
|
|
121
|
+
</div>
|
|
122
|
+
${description ? `<div style="font-size: 0.85rem; color: #666; margin-top: 6px; padding: 0 2px;">${escapeHtml(description)}</div>` : ''}
|
|
123
|
+
${price > 0 ? `<div style="font-size: 0.8rem; color: #a78bfa; margin-top: 4px; padding: 0 2px;">${price} MP</div>` : ''}
|
|
124
|
+
</div>
|
|
125
|
+
`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Bind: editor & version status ─────────────────────────────────────────
|
|
129
|
+
function bind($item, item) {
|
|
130
|
+
// Version status indicator (◉ traffic light — matches plugmatic style)
|
|
131
|
+
const $status = $('<span>').text('◉ ').css({ color: color.gray, cursor: 'pointer' })
|
|
132
|
+
.attr('title', 'Lucille service status');
|
|
133
|
+
|
|
134
|
+
$item.find('.item-buttons').prepend($status);
|
|
135
|
+
|
|
136
|
+
// Check service status
|
|
137
|
+
fetch('/plugin/lucille/version-status')
|
|
138
|
+
.then(r => r.json())
|
|
139
|
+
.then(data => {
|
|
140
|
+
if (!data.installed) {
|
|
141
|
+
$status.css('color', color.red).attr('title', 'Lucille not installed');
|
|
142
|
+
} else if (data.updateAvailable) {
|
|
143
|
+
$status.css('color', color.yellow).attr('title', `Update available: ${data.installed} → ${data.published}`);
|
|
144
|
+
$status.on('click', () => {
|
|
145
|
+
if (confirm(`Update lucille from ${data.installed} to ${data.published}?\nWiki will need a restart.`)) {
|
|
146
|
+
fetch('/plugin/lucille/update', { method: 'POST' })
|
|
147
|
+
.then(r => r.json())
|
|
148
|
+
.then(result => {
|
|
149
|
+
if (result.success) alert(`Updated to ${result.version}. Please restart the wiki server.`);
|
|
150
|
+
else alert('Update failed: ' + result.error);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
$status.css('color', color.green).attr('title', `Lucille ${data.installed} — up to date`);
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
.catch(() => {
|
|
159
|
+
$status.css('color', color.red).attr('title', 'Could not reach lucille service');
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
164
|
+
function escapeHtml(str) {
|
|
165
|
+
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
166
|
+
}
|
|
167
|
+
function escapeAttr(str) {
|
|
168
|
+
return String(str).replace(/"/g,'"').replace(/'/g,''');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { emit, bind };
|
|
172
|
+
}());
|
|
173
|
+
|
|
174
|
+
// ── Video lightbox (shared, injected once) ────────────────────────────────────
|
|
175
|
+
if (!window.lucilleOpen) {
|
|
176
|
+
// Inject modal into body
|
|
177
|
+
const modal = document.createElement('div');
|
|
178
|
+
modal.id = 'lucille-modal';
|
|
179
|
+
modal.style.cssText = 'display:none;position:fixed;inset:0;z-index:9999;align-items:center;justify-content:center;background:rgba(0,0,0,0.88);';
|
|
180
|
+
modal.innerHTML = `
|
|
181
|
+
<div style="position:relative;width:90vw;max-width:960px;aspect-ratio:16/9;background:#000;border-radius:10px;overflow:hidden;box-shadow:0 24px 80px rgba(0,0,0,0.7);">
|
|
182
|
+
<button onclick="lucilleClose()" style="position:absolute;top:10px;right:12px;z-index:2;background:rgba(0,0,0,0.5);border:none;color:#fff;font-size:20px;padding:4px 10px;border-radius:6px;cursor:pointer;">✕</button>
|
|
183
|
+
<iframe id="lucille-iframe" src="" allowfullscreen allow="autoplay" style="width:100%;height:100%;border:none;display:block;"></iframe>
|
|
184
|
+
</div>`;
|
|
185
|
+
document.body.appendChild(modal);
|
|
186
|
+
|
|
187
|
+
window.lucilleOpen = function(url, title) {
|
|
188
|
+
document.getElementById('lucille-iframe').src = url;
|
|
189
|
+
modal.style.display = 'flex';
|
|
190
|
+
document.title = title || document.title;
|
|
191
|
+
};
|
|
192
|
+
window.lucilleClose = function() {
|
|
193
|
+
modal.style.display = 'none';
|
|
194
|
+
document.getElementById('lucille-iframe').src = '';
|
|
195
|
+
};
|
|
196
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') window.lucilleClose(); });
|
|
197
|
+
modal.addEventListener('click', e => { if (e.target === modal) window.lucilleClose(); });
|
|
198
|
+
}
|
package/factory.json
ADDED
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wiki-plugin-lucille",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Federated Wiki plugin — P2P video hosting via Lucille (WebTorrent + DO Spaces)",
|
|
5
|
+
"keywords": ["wiki", "plugin", "video", "webtorrent", "planet-nine"],
|
|
6
|
+
"author": "Planet Nine",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"http-proxy": "^1.18.1",
|
|
12
|
+
"lucille": "latest",
|
|
13
|
+
"node-fetch": "^2.6.1",
|
|
14
|
+
"sessionless-node": "latest"
|
|
15
|
+
}
|
|
16
|
+
}
|