wireweave 0.1.1 → 0.1.3

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.
@@ -1,185 +0,0 @@
1
- const profiles = new Map();
2
- const fetching = new Set();
3
-
4
- const chat = {
5
- activeChannelId: null,
6
-
7
- get messages() { return state.chatMessages || []; },
8
- set messages(v) { state.chatMessages = v; },
9
-
10
- async send(content) {
11
- if (!auth.isLoggedIn() || !state.currentChannelId) return;
12
- const trimmed = content.trim();
13
- if (!trimmed) return;
14
- const chanHex = await _hexChannelId(state.currentChannelId, state.currentServerId);
15
- const relayHint = (state.nostrRelays || [])[0] || '';
16
- const template = {
17
- kind: 42,
18
- created_at: Math.floor(Date.now() / 1000),
19
- tags: [['e', chanHex, relayHint, 'root']],
20
- content: trimmed,
21
- };
22
- const signedEvent = await auth.sign(template);
23
- nostrNet.publish(signedEvent);
24
- chat._addMessage(chat._eventToMsg(signedEvent));
25
- },
26
-
27
- sendImage(file) {
28
- if (!file) {
29
- var input = document.createElement('input');
30
- input.type = 'file'; input.accept = 'image/*,video/*';
31
- input.onchange = function() { if (input.files[0]) nostrMedia.sendMedia(input.files[0]).catch(function(e){ message.add('Upload failed: ' + e.message); }); };
32
- input.click(); return;
33
- }
34
- nostrMedia.sendMedia(file).catch(function(e){ message.add('Upload failed: ' + e.message); });
35
- },
36
-
37
- async loadHistory(channelId) {
38
- if (chat.activeChannelId) {
39
- nostrNet.unsubscribe('chat-' + chat.activeChannelId);
40
- nostrNet.unsubscribe('chat-live-' + chat.activeChannelId);
41
- }
42
- chat.activeChannelId = channelId;
43
- state.chatMessages = [];
44
- const chanHex = await _hexChannelId(channelId, state.currentServerId);
45
- const collected = [];
46
- nostrNet.subscribe(
47
- 'chat-' + channelId,
48
- [{ kinds: [42], '#e': [chanHex], limit: 50 }],
49
- function(event) { collected.push(chat._eventToMsg(event)); },
50
- function() {
51
- collected.sort(function(a, b) { return a.timestamp - b.timestamp; });
52
- state.chatMessages = collected;
53
- chat._updateMembers(collected);
54
- ui.render.all();
55
- }
56
- );
57
- nostrNet.subscribe(
58
- 'chat-live-' + channelId,
59
- [{ kinds: [42], '#e': [chanHex], since: Math.floor(Date.now() / 1000) }],
60
- function(event) { chat._addMessage(chat._eventToMsg(event)); },
61
- function() {}
62
- );
63
- },
64
-
65
- async sendAnnouncement(text) {
66
- if (!auth.isLoggedIn() || !state.currentChannelId) return;
67
- if (!window.serverRoles || !serverRoles.isAdmin(state.currentServerId)) return;
68
- const trimmed = text.trim();
69
- if (!trimmed) return;
70
- const chanHex = await _hexChannelId(state.currentChannelId, state.currentServerId);
71
- const relayHint = (state.nostrRelays || [])[0] || '';
72
- const template = {
73
- kind: 42,
74
- created_at: Math.floor(Date.now() / 1000),
75
- tags: [['e', chanHex, relayHint, 'root'], ['t', 'announcement']],
76
- content: trimmed,
77
- };
78
- const signedEvent = await auth.sign(template);
79
- nostrNet.publish(signedEvent);
80
- chat._addMessage(chat._eventToMsg(signedEvent));
81
- },
82
-
83
- _eventToMsg(event) {
84
- const tTags = (event.tags || []).filter(t => t[0] === 't').map(t => t[1]);
85
- chat._fetchProfile(event.pubkey);
86
- return {
87
- id: event.id,
88
- type: 'text',
89
- userId: event.pubkey,
90
- content: event.content,
91
- timestamp: event.created_at * 1000,
92
- tags: tTags,
93
- };
94
- },
95
-
96
- _addMessage(msg) {
97
- if (state.chatMessages && state.chatMessages.find(function(m) { return m.id === msg.id; })) return;
98
- const current = state.chatMessages ? state.chatMessages.slice() : [];
99
- current.push(msg);
100
- state.chatMessages = current;
101
- chat._updateMembers(current);
102
- ui.render.all();
103
- },
104
-
105
- _updateMembers(msgs) {
106
- const seen = new Map();
107
- (msgs || []).forEach(function(m) {
108
- if (m.userId && !seen.has(m.userId)) seen.set(m.userId, chat.resolveProfile(m.userId));
109
- });
110
- state.roomMembers = Array.from(seen.entries()).map(function([id, username]) {
111
- return { id, username, online: true };
112
- });
113
- },
114
-
115
- async deleteMessage(id) {
116
- const template = {
117
- kind: 5,
118
- created_at: Math.floor(Date.now() / 1000),
119
- tags: [['e', id]],
120
- content: 'deleted',
121
- };
122
- const signedEvent = await auth.sign(template);
123
- nostrNet.publish(signedEvent);
124
- state.chatMessages = (state.chatMessages || []).filter(function(m) { return m.id !== id; });
125
- ui.render.all();
126
- },
127
-
128
- editMessage() {
129
- if (typeof ui !== 'undefined' && ui.showToast) ui.showToast('Nostr messages cannot be edited');
130
- else console.warn('Nostr messages cannot be edited');
131
- },
132
-
133
- resolveProfile(pubkey) {
134
- if (profiles.has(pubkey)) {
135
- const p = profiles.get(pubkey);
136
- return p.name || auth.npubShort(pubkey);
137
- }
138
- chat._fetchProfile(pubkey);
139
- return auth.npubShort(pubkey);
140
- },
141
-
142
- _fetchProfile(pubkey) {
143
- if (fetching.has(pubkey)) return;
144
- fetching.add(pubkey);
145
- nostrNet.subscribe(
146
- 'profile-' + pubkey,
147
- [{ kinds: [0], authors: [pubkey], limit: 1 }],
148
- function(event) {
149
- try {
150
- const p = JSON.parse(event.content);
151
- profiles.set(pubkey, p);
152
- if (pubkey === state.nostrPubkey) state.nostrProfile = p;
153
- } catch (e) {}
154
- },
155
- function() {
156
- nostrNet.unsubscribe('profile-' + pubkey);
157
- fetching.delete(pubkey);
158
- chat._updateMembers(state.chatMessages);
159
- ui.render.all();
160
- }
161
- );
162
- },
163
-
164
- linkify(html) {
165
- return html.replace(/https?:\/\/[^\s<>"]+/g, function(url) {
166
- if (!window.nostrMedia) return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>';
167
- var kind = nostrMedia.isMedia(url);
168
- if (kind === 'image') return '<a href="' + url + '" target="_blank" rel="noopener"><img src="' + url + '" style="max-width:100%;max-height:300px;border-radius:8px;margin-top:4px;display:block" loading="lazy"></a>';
169
- if (kind === 'video') return '<video src="' + url + '" controls style="max-width:100%;max-height:300px;border-radius:8px;margin-top:4px;display:block"></video>';
170
- return '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>';
171
- });
172
- },
173
-
174
- handleTextMessage(msg) { chat._addMessage(msg); },
175
- handleImageMessage() {},
176
- handleFileShared() {},
177
-
178
- updateProfile(pubkey, profile) {
179
- profiles.set(pubkey, profile);
180
- ui.render.all();
181
- },
182
- };
183
-
184
- window.__zellous.chat = chat;
185
- window.chat = chat;
@@ -1,5 +0,0 @@
1
- var voiceMachine = XState.createMachine({initial:'idle',states:{idle:{on:{connect:'connecting'}},connecting:{on:{connected:'connected',fail:'idle'}},connected:{on:{disconnect:'disconnecting'}},disconnecting:{on:{done:'idle'}}}});
2
- var peerMachine = XState.createMachine({initial:'new',states:{new:{on:{offer:'offering',recv_offer:'answering'}},offering:{on:{recv_answer:'connected',fail:'new',restart:'offering'}},answering:{on:{sent_answer:'connected',recv_answer:'connected',fail:'new'}},connected:{on:{disconnect:'reconnecting',close:'closed'}},reconnecting:{on:{offer:'offering',recv_answer:'connected',close:'closed'}},closed:{}}});
3
- var cameraMachine = XState.createMachine({initial:'idle',states:{idle:{on:{enable:'requesting'}},requesting:{on:{enabled:'active',denied:'idle'}},active:{on:{disable:'idle',error:'idle'}},error:{on:{enable:'requesting'}}}});
4
- window.__zellous.fsm = { voiceMachine: voiceMachine, peerMachine: peerMachine, cameraMachine: cameraMachine };
5
- window.nostrFsm = window.__zellous.fsm;
@@ -1,97 +0,0 @@
1
- var nostrMedia = {
2
- _BLOSSOM_SERVERS: [
3
- 'https://blossom.nostr.build',
4
- 'https://files.sovbit.host',
5
- 'https://nostrcheck.me',
6
- ],
7
-
8
- async _hashFile(file) {
9
- const buf = await file.arrayBuffer();
10
- const hash = await crypto.subtle.digest('SHA-256', buf);
11
- return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
12
- },
13
-
14
- async _generateBlossomAuth(file, uploadUrl) {
15
- if (!state.nostrPubkey) throw new Error('Not authenticated');
16
- const hash = await nostrMedia._hashFile(file);
17
- const auth_event = {
18
- kind: 24242,
19
- created_at: Math.floor(Date.now() / 1000),
20
- tags: [
21
- ['t', 'upload'],
22
- ['x', hash],
23
- ['expiration', String(Math.floor(Date.now() / 1000) + 600)],
24
- ],
25
- content: 'Upload ' + file.name,
26
- };
27
- const signed = await window.auth.sign(auth_event);
28
- return { header: 'Nostr ' + btoa(JSON.stringify(signed)), hash };
29
- },
30
-
31
- async _uploadBlossom(file, serverUrl) {
32
- const uploadUrl = serverUrl + '/upload';
33
- const { header, hash } = await nostrMedia._generateBlossomAuth(file, uploadUrl);
34
- const res = await fetch(uploadUrl, {
35
- method: 'PUT',
36
- body: file,
37
- headers: {
38
- 'Authorization': header,
39
- 'Content-Type': file.type || 'application/octet-stream',
40
- }
41
- });
42
- if (!res.ok) throw new Error('Status ' + res.status);
43
- const data = await res.json();
44
- const url = data.url || (data.content && JSON.parse(data.content).url);
45
- if (!url) throw new Error('No URL in response');
46
- return url;
47
- },
48
-
49
- async upload(file) {
50
- var errors = [];
51
- for (var i = 0; i < nostrMedia._BLOSSOM_SERVERS.length; i++) {
52
- try {
53
- var url = await nostrMedia._uploadBlossom(file, nostrMedia._BLOSSOM_SERVERS[i]);
54
- return { url: url, type: file.type, name: file.name, size: file.size };
55
- } catch(e) { errors.push(nostrMedia._BLOSSOM_SERVERS[i] + ': ' + e.message); }
56
- }
57
- throw new Error('Upload failed: ' + errors.join('; '));
58
- },
59
-
60
- isMedia(url) {
61
- if (!url || typeof url !== 'string') return null;
62
- var imgRx = /\.(png|jpe?g|gif|webp|svg|avif)(\?|$)/i;
63
- var vidRx = /\.(mp4|webm|mov|ogg)(\?|$)/i;
64
- if (imgRx.test(url)) return 'image';
65
- if (vidRx.test(url)) return 'video';
66
- return null;
67
- },
68
-
69
- extractUrls(text) {
70
- if (!text) return [];
71
- var urlRx = /https?:\/\/[^\s<>"]+/g;
72
- return (text.match(urlRx) || []);
73
- },
74
-
75
- async sendMedia(file) {
76
- if (file.size > 20 * 1024 * 1024) throw new Error('File too large (max 20 MB)');
77
- var result = await nostrMedia.upload(file);
78
- var chanHex = await _hexChannelId(state.currentChannelId, state.currentServerId);
79
- var relayHint = (state.nostrRelays || [])[0] || '';
80
- var imetaTag = ['imeta', 'url ' + result.url, 'm ' + result.type];
81
- if (result.size) imetaTag.push('size ' + result.size);
82
- var template = {
83
- kind: 42,
84
- created_at: Math.floor(Date.now() / 1000),
85
- tags: [['e', chanHex, relayHint, 'root'], imetaTag],
86
- content: result.url
87
- };
88
- var signed = await auth.sign(template);
89
- nostrNet.publish(signed);
90
- chat._addMessage(chat._eventToMsg(signed));
91
- }
92
- };
93
-
94
- window.__zellous.media = nostrMedia;
95
- window.nostrMedia = nostrMedia;
96
- if (!window.__debug) window.__debug = {};
97
- Object.defineProperty(window.__debug, 'media', { get: function() { return { servers: nostrMedia._NIP96_SERVERS }; }, configurable: true });
@@ -1,12 +0,0 @@
1
- var message = window.message || {
2
- handlers: {},
3
- handle: function(m) { var h = message.handlers[m.type]; if (h) h(m); },
4
- add: function(text, audioData, userId, username) {
5
- var id = Date.now() + Math.random();
6
- var msgs = (state.messages || []).concat([{ id: id, text: text, time: Date.now(), userId: userId, username: username }]);
7
- state.messages = msgs.length > 50 ? msgs.slice(-50) : msgs;
8
- if (window.ui) ui.render.messages();
9
- }
10
- };
11
- window.__zellous.message = message;
12
- window.message = message;
@@ -1,182 +0,0 @@
1
- var nostrNet = {
2
- relays: new Map(),
3
- subs: new Map(),
4
- pendingEvents: [],
5
- seenIds: new Set(),
6
-
7
- _makeRelayActor: function() {
8
- var machine = XState.createMachine({initial:'connecting',states:{connecting:{on:{connected:'connected',fail:'error'}},connected:{on:{fail:'error',disconnect:'disconnected'}},disconnected:{on:{reconnect:'connecting'}},error:{on:{reconnect:'connecting'}}}});
9
- var actor = XState.createActor(machine);
10
- actor.start();
11
- return actor;
12
- },
13
-
14
- connect: function() {
15
- var relays = (state.nostrRelays || []);
16
- for (var i = 0; i < relays.length; i++) nostrNet._openRelay(relays[i]);
17
- },
18
-
19
- disconnect: function() {
20
- nostrNet.relays.forEach(function(relay) {
21
- if (relay.ws) { relay.ws.onclose = null; relay.ws.onerror = null; relay.ws.close(); }
22
- });
23
- nostrNet.relays.clear();
24
- },
25
-
26
- _openRelay: function(url) {
27
- var existing = nostrNet.relays.get(url);
28
- if (existing && existing.ws && (existing.ws.readyState === 0 || existing.ws.readyState === 1)) return;
29
- var relay = existing || { ws: null, status: 'connecting', actor: null, reconnectDelay: 1000, failCount: 0, subIds: new Set(), latencyMs: null, _reqSentAt: null };
30
- relay.status = 'connecting';
31
- relay.latencyMs = null;
32
- if(!relay.actor) relay.actor = nostrNet._makeRelayActor();
33
- else relay.actor.send({type:'reconnect'});
34
- nostrNet.relays.set(url, relay);
35
- var ws;
36
- try { ws = new WebSocket(url); } catch (e) {
37
- relay.status = 'error'; relay.actor.send({type:'fail'}); nostrNet._updateRelayStatus(url, 'error');
38
- if (window.ui) ui.render.all(); return;
39
- }
40
- relay.ws = ws;
41
- ws.onopen = function() {
42
- relay.status = 'connected'; relay.actor.send({type:'connected'}); relay._openedAt = Date.now();
43
- nostrNet._updateRelayStatus(url, 'connected');
44
- var wasConnected = state.isConnected; state.isConnected = true;
45
- if (!wasConnected && window.ui) ui.render.all();
46
- nostrNet.subs.forEach(function(sub, subId) {
47
- var req = ['REQ', subId].concat(sub.filters);
48
- ws.send(JSON.stringify(req)); relay.subIds.add(subId);
49
- if (!relay._reqSentAt) relay._reqSentAt = Date.now();
50
- });
51
- nostrNet._drainPending();
52
- };
53
- ws.onmessage = function(e) {
54
- if (relay._reqSentAt && relay.latencyMs === null) {
55
- relay.latencyMs = Date.now() - relay._reqSentAt; relay._reqSentAt = null;
56
- nostrNet._updateRelayStatus(url, 'connected');
57
- }
58
- try { nostrNet._handleMessage(url, e.data); } catch (_) {}
59
- };
60
- ws.onerror = function() {
61
- relay.status = 'error'; nostrNet._updateRelayStatus(url, 'error');
62
- if (window.ui) ui.render.all();
63
- };
64
- ws.onclose = function() {
65
- relay.status = 'closed'; relay.actor.send({type:'fail'}); nostrNet._updateRelayStatus(url, 'error');
66
- var anyOpen = false;
67
- nostrNet.relays.forEach(function(r) { if (r.ws && r.ws.readyState === 1) anyOpen = true; });
68
- if (!anyOpen) state.isConnected = false;
69
- if (window.ui) ui.render.all();
70
- var sustained = relay._openedAt && (Date.now() - relay._openedAt) > 5000;
71
- if (sustained) { relay.failCount = 0; relay.reconnectDelay = 1000; }
72
- else { relay.failCount = (relay.failCount || 0) + 1; relay.reconnectDelay = Math.min(relay.reconnectDelay * 2, 30000); }
73
- relay._openedAt = null;
74
- nostrNet._reconnect(url, relay.reconnectDelay);
75
- };
76
- },
77
-
78
- _reconnect: function(url, delay) {
79
- setTimeout(function() {
80
- var relay = nostrNet.relays.get(url); if (!relay) return;
81
- relay.status = 'connecting'; nostrNet._openRelay(url);
82
- }, delay || 1000);
83
- },
84
-
85
- _handleMessage: function(url, rawData) {
86
- var msg = JSON.parse(rawData);
87
- if (!Array.isArray(msg) || msg.length < 2) return;
88
- var type = msg[0], subId = msg[1], sub;
89
- if (type === 'EVENT') {
90
- var event = msg[2]; if (!event || !event.id) return;
91
- if (event.created_at > Math.floor(Date.now() / 1000) + 300) return;
92
- if (nostrNet.seenIds.has(event.id)) return;
93
- var valid = true;
94
- if (window.NostrTools && NostrTools.verifyEvent) {
95
- try { valid = NostrTools.verifyEvent(event); } catch (_) { valid = false; }
96
- }
97
- if (!valid) return;
98
- nostrNet.seenIds.add(event.id); nostrNet._trimSeenIds();
99
- sub = nostrNet.subs.get(subId);
100
- if (sub && sub.onEvent) sub.onEvent(event);
101
- } else if (type === 'EOSE') {
102
- sub = nostrNet.subs.get(subId); if (sub && sub.onEose) sub.onEose();
103
- } else if (type === 'NOTICE') {
104
- console.warn('[nostr relay notice]', msg[1]);
105
- } else if (type === 'OK' && !msg[2]) {
106
- console.error('[nostr relay rejected event]', msg[1], msg[3] || '');
107
- }
108
- },
109
-
110
- _normSubId: function(subId) { return subId.length > 64 ? subId.slice(0, 64) : subId; },
111
-
112
- subscribe: function(subId, filters, onEvent, onEose) {
113
- subId = nostrNet._normSubId(subId);
114
- nostrNet.subs.set(subId, { filters: filters, onEvent: onEvent, onEose: onEose, relays: new Set() });
115
- nostrNet.relays.forEach(function(relay, url) {
116
- if (relay.ws && relay.ws.readyState === 1) {
117
- var req = ['REQ', subId].concat(filters); relay.ws.send(JSON.stringify(req));
118
- relay.subIds.add(subId); nostrNet.subs.get(subId).relays.add(url);
119
- if (!relay._reqSentAt) relay._reqSentAt = Date.now();
120
- }
121
- });
122
- return subId;
123
- },
124
-
125
- unsubscribe: function(subId) {
126
- subId = nostrNet._normSubId(subId);
127
- nostrNet.relays.forEach(function(relay) {
128
- if (relay.ws && relay.ws.readyState === 1 && relay.subIds.has(subId)) {
129
- relay.ws.send(JSON.stringify(['CLOSE', subId])); relay.subIds.delete(subId);
130
- }
131
- });
132
- nostrNet.subs.delete(subId);
133
- },
134
-
135
- publish: function(event) {
136
- var sent = false;
137
- nostrNet.relays.forEach(function(relay) {
138
- if (relay.ws && relay.ws.readyState === 1) { relay.ws.send(JSON.stringify(['EVENT', event])); sent = true; }
139
- });
140
- if (!sent) nostrNet.pendingEvents.push(event);
141
- },
142
-
143
- _drainPending: function() {
144
- var pending = nostrNet.pendingEvents.splice(0);
145
- for (var i = 0; i < pending.length; i++) nostrNet.publish(pending[i]);
146
- },
147
-
148
- isConnected: function() {
149
- var open = false;
150
- nostrNet.relays.forEach(function(r) { if (r.ws && r.ws.readyState === 1) open = true; });
151
- return open;
152
- },
153
-
154
- _updateRelayStatus: function(url, status) {
155
- var m = new Map(state.nostrRelayStatus || []); m.set(url, status); state.nostrRelayStatus = m;
156
- },
157
-
158
- _trimSeenIds: function() {
159
- if (nostrNet.seenIds.size > 10000) {
160
- var arr = Array.from(nostrNet.seenIds); nostrNet.seenIds = new Set(arr.slice(arr.length - 5000));
161
- }
162
- }
163
- };
164
- window.__zellous.net = nostrNet;
165
- window.network = nostrNet;
166
- window.nostrNet = nostrNet;
167
-
168
- function _healRelays() {
169
- nostrNet.relays.forEach(function(relay, url) {
170
- if (!relay.ws || relay.ws.readyState === 2 || relay.ws.readyState === 3) nostrNet._openRelay(url);
171
- });
172
- }
173
- window.addEventListener('online', function() {
174
- nostrNet.relays.forEach(function(relay) { relay.reconnectDelay = 1000; });
175
- _healRelays();
176
- });
177
- document.addEventListener('visibilitychange', function() {
178
- if (document.visibilityState === 'visible') _healRelays();
179
- });
180
- window.__debugNet = { get relays() {
181
- var out = []; nostrNet.relays.forEach(function(r, url) { out.push({url: url, status: r.status, actorState: r.actor ? r.actor.getSnapshot().value : null, latencyMs: r.latencyMs}); }); return out;
182
- }};
@@ -1,152 +0,0 @@
1
- var serverPages = {
2
- _store: new Map(),
3
- _subs: new Map(),
4
-
5
- _sanitize(html) {
6
- const el = document.createElement('div');
7
- el.innerHTML = html;
8
- el.querySelectorAll('script,iframe,object,embed,form,input,button').forEach(n => n.remove());
9
- el.querySelectorAll('*').forEach(n => {
10
- [...n.attributes].forEach(a => {
11
- if (/^on/i.test(a.name) || (a.name === 'href' && /^javascript:/i.test(a.value)) || (a.name === 'src' && /^javascript:/i.test(a.value))) n.removeAttribute(a.name);
12
- });
13
- });
14
- return el.innerHTML;
15
- },
16
-
17
- _pageKey(serverId, slug) { return 'zellous-page:' + serverId + ':' + slug; },
18
-
19
- getPages(serverId) { return Array.from((serverPages._store.get(serverId) || new Map()).values()); },
20
-
21
- subscribe(serverId) {
22
- if (serverPages._subs.has(serverId)) return;
23
- var creator = serverId ? serverId.split(':')[0] : null;
24
- if (!creator) return;
25
- var subId = 'pages-' + serverId;
26
- serverPages._subs.set(serverId, subId);
27
- nostrNet.subscribe(subId,
28
- [{ kinds: [30078], authors: [creator] }],
29
- function(event) {
30
- if (event.pubkey !== creator) return;
31
- var dTag = (event.tags.find(function(t) { return t[0] === 'd'; }) || [])[1] || '';
32
- var prefix = 'zellous-page:' + serverId + ':';
33
- if (!dTag.startsWith(prefix)) return;
34
- var slug = dTag.slice(prefix.length);
35
- if (!slug) return;
36
- try {
37
- var data = JSON.parse(event.content);
38
- var pages = serverPages._store.get(serverId) || new Map();
39
- if (data.deleted) { pages.delete(slug); } else {
40
- pages.set(slug, { slug: slug, title: data.title || slug, html: serverPages._sanitize(data.html || ''), updatedAt: event.created_at });
41
- }
42
- serverPages._store.set(serverId, pages);
43
- if (window.uiChannels) uiChannels.render();
44
- } catch(e) {}
45
- },
46
- function() {}
47
- );
48
- },
49
-
50
- unsubscribe(serverId) {
51
- var subId = serverPages._subs.get(serverId);
52
- if (subId) { nostrNet.unsubscribe(subId); serverPages._subs.delete(serverId); }
53
- },
54
-
55
- async publish(serverId, slug, title, html) {
56
- if (!serverRoles.isAdmin(serverId)) throw new Error('Admin only');
57
- var safe = serverPages._sanitize(html);
58
- var dTag = serverPages._pageKey(serverId, slug);
59
- var signed = await auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', dTag]], content: JSON.stringify({ title: title, html: safe }) });
60
- await nostrNet.publish(signed);
61
- var pages = serverPages._store.get(serverId) || new Map();
62
- pages.set(slug, { slug: slug, title: title, html: safe, updatedAt: Math.floor(Date.now() / 1000) });
63
- serverPages._store.set(serverId, pages);
64
- if (window.uiChannels) uiChannels.render();
65
- },
66
-
67
- async deletePage(serverId, slug) {
68
- if (!serverRoles.isAdmin(serverId)) throw new Error('Admin only');
69
- var dTag = serverPages._pageKey(serverId, slug);
70
- var signed = await auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', dTag]], content: JSON.stringify({ deleted: true }) });
71
- await nostrNet.publish(signed);
72
- var pages = serverPages._store.get(serverId) || new Map();
73
- pages.delete(slug);
74
- serverPages._store.set(serverId, pages);
75
- if (window.uiChannels) uiChannels.render();
76
- },
77
-
78
- showEditModal(serverId, existing) {
79
- document.getElementById('pageEditModal')?.remove();
80
- var modal = document.createElement('div');
81
- modal.id = 'pageEditModal';
82
- modal.className = 'modal-overlay';
83
- modal.style.display = 'flex';
84
- var slug = existing ? existing.slug : 'page-' + Date.now().toString(36);
85
- modal.innerHTML = '<div class="modal" style="max-width:680px;width:95%">' +
86
- '<div class="modal-header"><span class="modal-title">' + (existing ? 'Edit Page' : 'New Page') + '</span>' +
87
- '<button class="modal-close" id="pageModalClose">&times;</button></div>' +
88
- '<div class="modal-body" style="display:flex;flex-direction:column;gap:12px">' +
89
- '<input id="pageModalTitle" class="modal-input" placeholder="Page title" value="' + escHtml(existing ? existing.title : '') + '">' +
90
- '<div id="pageModalEditor" class="page-editor" contenteditable="true" style="min-height:220px;background:var(--bg-raised);border:1px solid var(--border);border-radius:6px;padding:12px;overflow-y:auto;font-size:14px;line-height:1.6"></div>' +
91
- '<div class="page-editor-toolbar" style="display:flex;gap:6px;flex-wrap:wrap">' +
92
- '<button class="page-tb-btn" data-cmd="bold"><b>B</b></button>' +
93
- '<button class="page-tb-btn" data-cmd="italic"><i>I</i></button>' +
94
- '<button class="page-tb-btn" data-cmd="underline"><u>U</u></button>' +
95
- '<button class="page-tb-btn" data-cmd="insertUnorderedList">&#8226;</button>' +
96
- '<button class="page-tb-btn" data-cmd="insertOrderedList">1.</button>' +
97
- '</div>' +
98
- '</div>' +
99
- '<div class="modal-footer">' +
100
- (existing ? '<button class="modal-btn danger" id="pageModalDelete">Delete</button>' : '') +
101
- '<button class="modal-btn secondary" id="pageModalCancel">Cancel</button>' +
102
- '<button class="modal-btn primary" id="pageModalSave">Save</button>' +
103
- '</div></div>';
104
- document.body.appendChild(modal);
105
- var editor = document.getElementById('pageModalEditor');
106
- if (existing) editor.innerHTML = existing.html;
107
- modal.querySelectorAll('.page-tb-btn').forEach(function(btn) {
108
- btn.addEventListener('mousedown', function(e) { e.preventDefault(); document.execCommand(btn.dataset.cmd, false, null); editor.focus(); });
109
- });
110
- document.getElementById('pageModalClose').addEventListener('click', function() { modal.remove(); });
111
- document.getElementById('pageModalCancel').addEventListener('click', function() { modal.remove(); });
112
- if (existing) {
113
- document.getElementById('pageModalDelete').addEventListener('click', async function() {
114
- if (!confirm('Delete this page?')) return;
115
- try { await serverPages.deletePage(serverId, slug); modal.remove(); } catch(e) { alert(e.message); }
116
- });
117
- }
118
- document.getElementById('pageModalSave').addEventListener('click', async function() {
119
- var title = document.getElementById('pageModalTitle').value.trim();
120
- if (!title) { alert('Title required'); return; }
121
- try { await serverPages.publish(serverId, slug, title, editor.innerHTML); modal.remove(); } catch(e) { alert(e.message); }
122
- });
123
- modal.addEventListener('click', function(e) { if (e.target === modal) modal.remove(); });
124
- },
125
-
126
- renderPageView(serverId, slug) {
127
- var pages = serverPages._store.get(serverId) || new Map();
128
- var page = pages.get(slug);
129
- var area = document.getElementById('pageView');
130
- if (!area) return;
131
- var isAdmin = window.serverRoles && serverRoles.isAdmin(serverId);
132
- area.innerHTML = '<div class="page-view-header">' +
133
- '<span class="page-view-title">' + escHtml(page ? page.title : slug) + '</span>' +
134
- (isAdmin ? '<button class="modal-btn secondary page-edit-btn" id="pageEditBtn">Edit</button>' : '') +
135
- '</div>' +
136
- '<div class="page-view-body">' + (page ? page.html : '<p style="color:var(--text-muted)">No content yet.</p>') + '</div>';
137
- if (isAdmin) {
138
- document.getElementById('pageEditBtn').addEventListener('click', function() {
139
- serverPages.showEditModal(serverId, page || { slug: slug, title: slug, html: '' });
140
- });
141
- }
142
- }
143
- };
144
-
145
- window.__zellous.pages = serverPages;
146
- window.serverPages = serverPages;
147
- if (!window.__debug) window.__debug = {};
148
- Object.defineProperty(window.__debug, 'pages', { get: function() {
149
- var out = {};
150
- serverPages._store.forEach(function(pages, srv) { out[srv] = Array.from(pages.keys()); });
151
- return out;
152
- }, configurable: true });