@nibgate/sdk 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ import { normalizeResource, normalizeContentType } from '../core/resource.js';
2
+ import { normalizeRating, ratingMessage } from '../core/rating.js';
3
+ import { emit } from './events.js';
4
+ import { browserWindow } from './env.js';
5
+ import { rateContentOnchain } from './reputation.js';
6
+ import { createGate } from './gate.js';
7
+
8
+ export function rateResource(resource, rating = {}, extra = {}) {
9
+ const normalized = normalizeResource(resource);
10
+ const normalizedRating = normalizeRating(rating);
11
+ const payload = {
12
+ ...extra,
13
+ ...normalizedRating,
14
+ ratingMessage: extra.ratingMessage || rating.message || rating.ratingMessage || ratingMessage(normalized, normalizedRating, extra),
15
+ ratingSignature: extra.ratingSignature || rating.signature || rating.ratingSignature || undefined,
16
+ resource: normalized
17
+ };
18
+ return emit('content_rating', payload);
19
+ }
20
+
21
+ export function createOnchainRating(resource, options = {}) {
22
+ const item = createGate(resource, options.gateOptions || {});
23
+ const win = browserWindow();
24
+ const statusTarget = typeof options.status === 'string' ? win?.document.querySelector(options.status) : options.status;
25
+ const ratingTarget = typeof options.ratingTarget === 'string' ? win?.document.querySelector(options.ratingTarget) : options.ratingTarget;
26
+ const buttonSelector = options.ratingButtons || options.buttons || '[data-nibgate-rating-value], [data-rating]';
27
+ const explicitButtons = Array.isArray(options.buttons)
28
+ ? options.buttons
29
+ : typeof options.buttons === 'string'
30
+ ? Array.from(win?.document.querySelectorAll(options.buttons) || [])
31
+ : options.buttons
32
+ ? [options.buttons]
33
+ : null;
34
+ const buttons = explicitButtons || Array.from(win?.document.querySelectorAll(buttonSelector) || []);
35
+ const source = options.source || 'nibgate-onchain-rating';
36
+
37
+ let busy = false;
38
+ let payment = options.payment || null;
39
+
40
+ function setStatus(message) {
41
+ if (typeof options.onStatus === 'function') options.onStatus(message);
42
+ if (statusTarget) statusTarget.textContent = message || '';
43
+ }
44
+
45
+ function setBusy(value) {
46
+ busy = Boolean(value);
47
+ buttons.forEach((button) => {
48
+ if (button && 'disabled' in button) button.disabled = busy;
49
+ });
50
+ }
51
+
52
+ function setPayment(nextPayment = null) {
53
+ payment = nextPayment || null;
54
+ return payment;
55
+ }
56
+
57
+ function setVisible(isVisible) {
58
+ if (!ratingTarget) return Boolean(isVisible);
59
+ if ('hidden' in ratingTarget) ratingTarget.hidden = !isVisible;
60
+ ratingTarget.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
61
+ return Boolean(isVisible);
62
+ }
63
+
64
+ function valueFromButton(button) {
65
+ const raw = button?.dataset?.nibgateRatingValue || button?.dataset?.rating || button?.value || button?.textContent;
66
+ const numeric = Number.parseFloat(String(raw || '').replace(/[^\d.]/g, ''));
67
+ return Number.isFinite(numeric) ? numeric : Number(options.rating || options.stars || 0);
68
+ }
69
+
70
+ async function rate(input = {}) {
71
+ setBusy(true);
72
+ try {
73
+ const rating = Number.parseFloat(input.rating ?? input.stars ?? input.value ?? options.rating ?? options.stars);
74
+ if (!Number.isFinite(rating)) throw new Error('Choose a rating before sending.');
75
+ const paymentId = input.paymentId || options.paymentId || (typeof options.getPaymentId === 'function' ? options.getPaymentId() : payment?.paymentId);
76
+ const unlockRef = input.unlockRef || options.unlockRef
77
+ || (typeof options.getUnlockRef === 'function' ? options.getUnlockRef() : null)
78
+ || paymentId || payment?.txHash || payment?.transactionHash || '';
79
+
80
+ setStatus(options.pendingMessage || 'Send the onchain rating transaction...');
81
+ const result = await rateContentOnchain(item.resource, { ...options, ...input, rating, paymentId, unlockRef, source });
82
+ setStatus(options.successMessage || 'Rating sent to Nibgate reputation.');
83
+ if (typeof options.onRated === 'function') options.onRated(result);
84
+ return result;
85
+ } catch (error) {
86
+ const message = error?.message || options.errorMessage || 'Rating failed.';
87
+ setStatus(message);
88
+ if (typeof options.onError === 'function') options.onError(error);
89
+ throw error;
90
+ } finally {
91
+ setBusy(false);
92
+ }
93
+ }
94
+
95
+ function mount() {
96
+ buttons.forEach((button) => {
97
+ button?.addEventListener?.('click', () => rate({ rating: valueFromButton(button) }).catch(() => null));
98
+ });
99
+ if (options.visible !== undefined) setVisible(Boolean(options.visible));
100
+ return controller;
101
+ }
102
+
103
+ const controller = { resource: item.resource, rate, mount, setPayment, setVisible };
104
+ if (options.autoMount !== false) mount();
105
+ return controller;
106
+ }
107
+
108
+ export function mountRatingUI(resource, options = {}) {
109
+ const item = createGate(resource, options.gateOptions || {});
110
+ const win = browserWindow();
111
+ if (!win) return null;
112
+ const target = typeof options.target === 'string' ? win.document.querySelector(options.target) : options.target;
113
+ if (!target) return null;
114
+
115
+ const stars = [1, 2, 3, 4, 5];
116
+ let selectedRating = 0;
117
+
118
+ const container = win.document.createElement('div');
119
+ container.className = 'nibgate-rating-ui';
120
+ container.style.cssText = 'display:flex;align-items:center;gap:4px;padding:8px 0';
121
+
122
+ const starButtons = stars.map((value) => {
123
+ const btn = win.document.createElement('button');
124
+ btn.type = 'button';
125
+ btn.dataset.nibgateRatingValue = String(value);
126
+ btn.setAttribute('aria-label', `${value} star${value > 1 ? 's' : ''}`);
127
+ btn.innerHTML = '☆';
128
+ btn.style.cssText = 'background:none;border:none;font-size:24px;cursor:pointer;color:#ccc;transition:color 0.15s;padding:2px;line-height:1';
129
+ btn.addEventListener('mouseenter', () => {
130
+ starButtons.forEach((b, i) => b.style.color = i < value ? '#f5b342' : '#ccc');
131
+ });
132
+ btn.addEventListener('mouseleave', () => {
133
+ starButtons.forEach((b, i) => b.style.color = i < selectedRating ? '#f5b342' : '#ccc');
134
+ });
135
+ btn.addEventListener('click', () => {
136
+ selectedRating = value;
137
+ starButtons.forEach((b, i) => b.style.color = i < value ? '#f5b342' : '#ccc');
138
+ rateResource(item.resource, { rating: value }).catch(() => {});
139
+ });
140
+ container.appendChild(btn);
141
+ return btn;
142
+ });
143
+
144
+ const statusEl = win.document.createElement('span');
145
+ statusEl.style.cssText = 'font-size:13px;color:#888;margin-left:8px';
146
+ statusEl.textContent = options.label || 'Rate this content';
147
+ container.appendChild(statusEl);
148
+
149
+ target.appendChild(container);
150
+
151
+ function rate(r, input = {}) {
152
+ return item.rate({ ...input, rating: r });
153
+ }
154
+
155
+ function setRating(value) {
156
+ selectedRating = value;
157
+ starButtons.forEach((b, i) => b.style.color = i < value ? '#f5b342' : '#ccc');
158
+ }
159
+
160
+ return { resource: item.resource, container, setRating, rate };
161
+ }
@@ -0,0 +1,45 @@
1
+ import { validateResourceMetadata } from '../core/resource.js';
2
+ import { browserWindow } from './env.js';
3
+ import { createGate } from './gate.js';
4
+ import { checkResourceAccess } from './access.js';
5
+
6
+ export function trackResourcePage(resource, options = {}) {
7
+ const item = createGate(resource, options.gateOptions || {});
8
+ const validation = validateResourceMetadata(item.resource, options.validation || {});
9
+ if ((validation.warnings.length || validation.errors.length) && options.warn !== false && browserWindow()?.console?.warn) {
10
+ browserWindow().console.warn('Nibgate content metadata needs attention', validation);
11
+ }
12
+ item.content({ source: options.source, metadataQuality: { score: validation.score, warnings: validation.warnings, errors: validation.errors }, ...(options.content || {}) });
13
+ item.view({
14
+ source: options.source,
15
+ path: options.path || browserWindow()?.location?.pathname || item.resource.path,
16
+ referrer: options.referrer ?? browserWindow()?.document?.referrer ?? '',
17
+ ...(options.view || {})
18
+ });
19
+ return item;
20
+ }
21
+
22
+ export function setupResourcePage(resource, options = {}) {
23
+ const item = trackResourcePage(resource, options);
24
+ const win = browserWindow();
25
+ if (!win) return item;
26
+
27
+ const button = typeof options.button === 'string' ? win.document.querySelector(options.button) : options.button;
28
+ const statusElement = typeof options.status === 'string' ? win.document.querySelector(options.status) : options.status;
29
+ const setStatus = options.onStatus || ((message) => {
30
+ if (statusElement) statusElement.textContent = message || '';
31
+ });
32
+
33
+ if (button) {
34
+ button.addEventListener('click', async () => {
35
+ button.disabled = true;
36
+ try {
37
+ await checkResourceAccess(resource, { ...options, onStatus: setStatus });
38
+ } finally {
39
+ button.disabled = false;
40
+ }
41
+ });
42
+ }
43
+
44
+ return item;
45
+ }
@@ -75,13 +75,8 @@ export function createNibgateServer(options = {}) {
75
75
 
76
76
  if (access.blocked) {
77
77
  return jsonResponse({
78
- status: 403,
79
- error: `${access.actor} access is blocked for this resource`,
80
- nibgate: {
81
- contentId: resource.id,
82
- actor: access.actor,
83
- access: resource.access
84
- }
78
+ ok: false, error: `${access.actor} access is blocked for this resource`,
79
+ nibgate: { contentId: resource.id, actor: access.actor, access: resource.access }
85
80
  }, { status: 403 });
86
81
  }
87
82
 
@@ -96,11 +91,7 @@ export function createNibgateServer(options = {}) {
96
91
 
97
92
  if (!access.allowed) {
98
93
  if (access.blocked) {
99
- return jsonResponse({
100
- status: 403,
101
- error: `${access.actor} access is blocked for this resource`,
102
- resource
103
- }, { status: 403 });
94
+ return jsonResponse({ ok: false, error: `${access.actor} access is blocked for this resource`, resource }, { status: 403 });
104
95
  }
105
96
 
106
97
  const rail = normalizePaymentRail(resource.paymentRail || routeOptions.paymentRail || options.paymentRail || routeOptions.paymentMode || options.paymentMode);
@@ -109,7 +100,10 @@ export function createNibgateServer(options = {}) {
109
100
  if (gateway.handled) return gateway.response;
110
101
  const result = await unlock(resource, gateway.payment);
111
102
  if (result.ok) {
112
- return jsonResponse({ ok: true, resource, payment: gateway.payment, unlockProof: result.unlockProof, expiresInSeconds: result.expiresInSeconds });
103
+ const response = jsonResponse({ ok: true, resource, payment: gateway.payment, unlockProof: result.unlockProof, expiresInSeconds: result.expiresInSeconds });
104
+ emitHubEvent('payment_completed', resource, { ...options, ...routeOptions, payload: gateway.payment }).catch(() => {});
105
+ emitHubEvent('unlock_completed', resource, { ...options, ...routeOptions, payload: gateway.payment }).catch(() => {});
106
+ return response;
113
107
  }
114
108
  }
115
109
 
@@ -117,7 +111,7 @@ export function createNibgateServer(options = {}) {
117
111
  const txHash = request.headers?.get?.('x-nibgate-transfer-tx') || request.headers?.get?.('x-transfer-tx') || '';
118
112
  if (txHash) {
119
113
  if (!verifyTransfer) {
120
- return jsonResponse({ error: 'Transfer verification is not configured', detail: 'Pass verifyTransfer({ resource, txHash, request }) to createNibgateServer/createCircleGatewayServer before using paymentRail: transfer.' }, { status: 501 });
114
+ return jsonResponse({ ok: false, error: 'Transfer verification is not configured. Pass verifyTransfer to createNibgateServer before using paymentRail: transfer.' }, { status: 501 });
121
115
  }
122
116
  const transferPayment = {
123
117
  paymentProvider: 'direct-transfer',
@@ -130,7 +124,7 @@ export function createNibgateServer(options = {}) {
130
124
  network: routeOptions.network || options.network || serverEnv('NIBGATE_PAYMENT_NETWORK') || 'eip155:5042002'
131
125
  };
132
126
  const verified = await verifyTransfer({ resource, txHash, payment: transferPayment, request });
133
- if (!verified) return jsonResponse({ error: 'Transfer verification failed' }, { status: 402 });
127
+ if (!verified) return jsonResponse({ ok: false, error: 'Transfer verification failed' }, { status: 402 });
134
128
  const result = await unlock(resource, { ...transferPayment, verified: true });
135
129
  if (result.ok) return jsonResponse({ ok: true, resource, payment: { ...transferPayment, verified: true }, unlockProof: result.unlockProof, expiresInSeconds: result.expiresInSeconds });
136
130
  }
@@ -157,18 +151,14 @@ export function createNibgateServer(options = {}) {
157
151
  if (!gatewayResult.ok) return jsonResponse(gatewayResult, { status: gatewayResult.status || 500 });
158
152
  payment = gatewayResult.payment;
159
153
  } else {
160
- return jsonResponse({
161
- success: false,
162
- error: 'Real payments are required',
163
- detail: 'Set NIBGATE_PAYMENT_MODE=circle-gateway for real local payment tests.'
164
- }, { status: 400 });
154
+ return jsonResponse({ ok: false, error: 'Real payments are required', detail: 'Set NIBGATE_PAYMENT_MODE=circle-gateway for real local payment tests.' }, { status: 400 });
165
155
  }
166
156
 
167
157
  const result = await unlock(resource, payment);
168
- if (!result.ok) return jsonResponse({ success: false, ...result }, { status: result.status || 402 });
158
+ if (!result.ok) return jsonResponse({ ok: false, ...result }, { status: result.status || 402 });
169
159
 
170
160
  const response = jsonResponse({
171
- success: true,
161
+ ok: true,
172
162
  unlockProof: result.unlockProof,
173
163
  expiresInSeconds: result.expiresInSeconds,
174
164
  payment,
@@ -218,10 +208,12 @@ export function protect(resource, handler, options = {}) {
218
208
 
219
209
  export function verifyPayment(options = {}) {
220
210
  return async function verifyPaymentMiddleware(req, res, next) {
221
- const proofHeader = req.headers?.['x-nibgate-payment-proof'] || req.get?.('x-nibgate-payment-proof') || '';
211
+ const contentId = req.params?.id || req.body?.resourceId || '';
212
+
213
+ const proofHeader = req.headers?.['x-nibgate-payment-proof'] || '';
222
214
  if (proofHeader) {
223
215
  const nibgate = createNibgateServer(options);
224
- const payload = nibgate.verifyUnlockToken(proofHeader, { id: req.params?.id || req.body?.resourceId || '' });
216
+ const payload = nibgate.verifyUnlockToken(proofHeader, { id: contentId });
225
217
  if (payload) {
226
218
  req.nibgate = { unlock: payload, verified: true };
227
219
  return next();
@@ -229,11 +221,11 @@ export function verifyPayment(options = {}) {
229
221
  }
230
222
 
231
223
  if ((options.paymentMode || process.env.NIBGATE_PAYMENT_MODE) === 'circle-gateway') {
232
- const sigHeader = req.headers?.['payment-signature'] || req.get?.('payment-signature') || '';
224
+ const sigHeader = req.headers?.['payment-signature'] || '';
233
225
  if (sigHeader) {
234
- const resource = options.resource || { id: req.params?.id || '', price: req.body?.price || '0' };
226
+ const resource = options.resource || { id: contentId, price: req.body?.price || '0' };
235
227
  const gateway = await runCircleGatewayRequirement(
236
- { headers: new Map(Object.entries(req.headers || {})), method: req.method, url: req.originalUrl || req.url },
228
+ { headers: req.headers, method: req.method || 'GET', url: req.originalUrl || req.url || '/' },
237
229
  resource,
238
230
  options
239
231
  );
@@ -241,13 +233,11 @@ export function verifyPayment(options = {}) {
241
233
  req.nibgate = { payment: gateway.payment, verified: true };
242
234
  return next();
243
235
  }
244
- return res.status(402).json({ error: 'Payment verification failed', detail: 'The payment signature could not be verified.' });
236
+ return res.status(402).json({ ok: false, error: 'Payment verification failed', detail: 'The payment signature could not be verified.' });
245
237
  }
246
238
  }
247
239
 
248
- return res.status(402).json({
249
- error: 'Payment required',
250
- challenge: createPaymentChallenge(options.resource || { id: req.params?.id || '', price: req.body?.price || '0' }, options)
251
- });
240
+ const errBody = createPaymentChallenge(options.resource || { id: contentId, price: req.body?.price || '0' }, options);
241
+ return res.status(402).json(errBody);
252
242
  };
253
243
  }
@@ -0,0 +1,168 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>${TITLE}</title>
7
+ <style>
8
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f0;color:#1a1a18;padding:24px;max-width:960px;margin:0 auto}
10
+ h1{font-size:28px;font-weight:700;margin-bottom:8px}
11
+ p.sub{color:#666;margin-bottom:24px}
12
+ .card{background:#fff;border-radius:12px;padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,0.08)}
13
+ .card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
14
+ .card-header h3{font-size:18px;font-weight:600}
15
+ .status{font-size:12px;padding:3px 10px;border-radius:99px;font-weight:600}
16
+ .status-gated{background:#fef3c7;color:#92400e}
17
+ .status-open{background:#d1fae5;color:#065f46}
18
+ .status-hidden{background:#fce4ec;color:#c62828}
19
+ .field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px}
20
+ .field{display:flex;flex-direction:column;gap:4px}
21
+ .field label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#555}
22
+ .field input,.field select{font-size:14px;padding:8px 10px;border:1px solid #ddd;border-radius:6px;background:#fff}
23
+ .field input:focus,.field select:focus{outline:2px solid #7c9a6d;border-color:transparent}
24
+ .field .checkbox-row{display:flex;align-items:center;gap:8px;padding-top:6px}
25
+ .field .checkbox-row input[type="checkbox"]{width:18px;height:18px}
26
+ .empty{text-align:center;padding:48px;color:#888}
27
+ .toast{position:fixed;bottom:24px;right:24px;background:#1a1a18;color:#fff;padding:12px 20px;border-radius:8px;font-size:14px;opacity:0;transition:opacity 0.3s;z-index:100}
28
+ .toast.show{opacity:1}
29
+ .toast.error{background:#c62828}
30
+ .header-bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}
31
+ .header-bar a{color:#7c9a6d;text-decoration:none;font-size:14px;font-weight:600}
32
+ .header-bar a:hover{text-decoration:underline}
33
+ .help-text{font-size:12px;color:#888;margin-top:2px}
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div class="header-bar">
38
+ <div>
39
+ <h1>Nibgate Admin</h1>
40
+ <p class="sub">Manage gating, pricing, and settings for your content.</p>
41
+ </div>
42
+ <a href="/nibgate.json" target="_blank">View manifest →</a>
43
+ </div>
44
+ <div id="app"><div class="empty">Loading resources...</div></div>
45
+ <div id="toast" class="toast"></div>
46
+ <script>
47
+ const API = '${API_BASE}';
48
+ let fields = [];
49
+ let items = [];
50
+
51
+ async function load() {
52
+ try {
53
+ const res = await fetch(API + '/resources');
54
+ const data = await res.json();
55
+ if (!data.ok) throw new Error(data.error || 'Failed to load');
56
+ fields = data.fields || [];
57
+ items = data.items || [];
58
+ render();
59
+ } catch (err) {
60
+ document.getElementById('app').innerHTML = '<div class="empty">Error loading: ' + err.message + '</div>';
61
+ }
62
+ }
63
+
64
+ function statusLabel(item) {
65
+ if (item.humanAccess === 'paid') return '<span class="status status-gated">Gated</span>';
66
+ if (item.humanAccess === 'blocked') return '<span class="status status-hidden">Blocked</span>';
67
+ return '<span class="status status-open">Open</span>';
68
+ }
69
+
70
+ function render() {
71
+ const app = document.getElementById('app');
72
+ if (!items.length) {
73
+ app.innerHTML = '<div class="empty">No resources configured yet. Add resources through the Nibgate SDK and they will appear here after the next manifest sync.</div>';
74
+ return;
75
+ }
76
+ app.innerHTML = items.map(function(item) {
77
+ return '<div class="card" data-id="' + item.id + '">' +
78
+ '<div class="card-header">' +
79
+ '<h3>' + escapeHtml(item.title || item.id) + '</h3>' +
80
+ statusLabel(item) +
81
+ '</div>' +
82
+ '<div class="field-row">' +
83
+ selectField('humanAccess', 'Human access', ['free','paid','blocked'], item.humanAccess) +
84
+ selectField('agentAccess', 'Agent access', ['free','paid','blocked'], item.agentAccess) +
85
+ '</div>' +
86
+ '<div class="field-row">' +
87
+ selectField('unlockMode', 'Unlock mode', ['one_time'], item.unlockMode) +
88
+ selectField('paymentRail', 'Payment rail', ['gateway','transfer'], item.paymentRail || 'gateway') +
89
+ '</div>' +
90
+ '<div class="field-row">' +
91
+ textField('price', 'Price (' + (item.currency || 'USDC') + ')', item.price) +
92
+ textField('currency', 'Currency', item.currency || 'USDC') +
93
+ '</div>' +
94
+ '<div class="field-row">' +
95
+ textField('recipient', 'Recipient wallet', item.recipient || '') +
96
+ checkboxField('ratingsEnabled', 'Enable onchain ratings', item.ratingsEnabled !== false) +
97
+ '</div>' +
98
+ '<div class="field-row">' +
99
+ selectField('type', 'Content type', ['article','music','video','image'], item.type || 'article') +
100
+ checkboxField('publishToNibgate', 'Publish to discovery', item.publishToNibgate !== false) +
101
+ '</div>' +
102
+ '<div><button class="save-btn" onclick="save(this)">Save</button></div>' +
103
+ '</div>';
104
+ }).join('');
105
+ }
106
+
107
+ function textField(name, label, value) {
108
+ return '<div class="field"><label>' + label + '</label><input type="text" name="' + name + '" value="' + escapeAttr(value || '') + '" /></div>';
109
+ }
110
+
111
+ function selectField(name, label, options, value) {
112
+ const opts = (options || []).map(function(o) { return '<option value="' + o + '"' + (o === value ? ' selected' : '') + '>' + o + '</option>'; }).join('');
113
+ return '<div class="field"><label>' + label + '</label><select name="' + name + '">' + opts + '</select></div>';
114
+ }
115
+
116
+ function checkboxField(name, label, checked) {
117
+ return '<div class="field"><label>' + label + '</label><div class="checkbox-row"><input type="checkbox" name="' + name + '"' + (checked ? ' checked' : '') + ' /></div></div>';
118
+ }
119
+
120
+ function escapeHtml(str) {
121
+ var div = document.createElement('div');
122
+ div.textContent = str;
123
+ return div.innerHTML;
124
+ }
125
+
126
+ function escapeAttr(str) {
127
+ return String(str).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
128
+ }
129
+
130
+ async function save(btn) {
131
+ var card = btn.closest('.card');
132
+ var id = card.dataset.id;
133
+ var inputs = card.querySelectorAll('[name]');
134
+ var body = {};
135
+ inputs.forEach(function(inp) {
136
+ if (inp.type === 'checkbox') body[inp.name] = inp.checked;
137
+ else body[inp.name] = inp.value;
138
+ });
139
+ btn.disabled = true;
140
+ btn.textContent = 'Saving...';
141
+ try {
142
+ var res = await fetch(API + '/resources/' + encodeURIComponent(id), {
143
+ method: 'POST',
144
+ headers: { 'content-type': 'application/json' },
145
+ body: JSON.stringify(body)
146
+ });
147
+ var data = await res.json();
148
+ if (!data.ok) throw new Error(data.error || 'Save failed');
149
+ toast('Saved ' + (data.item.title || id), 'success');
150
+ load();
151
+ } catch (err) {
152
+ toast(err.message, 'error');
153
+ btn.disabled = false;
154
+ btn.textContent = 'Save';
155
+ }
156
+ }
157
+
158
+ function toast(msg, type) {
159
+ var el = document.getElementById('toast');
160
+ el.textContent = msg;
161
+ el.className = 'toast ' + (type==='error'?'error':'') + ' show';
162
+ setTimeout(function() { el.classList.remove('show'); }, 3000);
163
+ }
164
+
165
+ load();
166
+ </script>
167
+ </body>
168
+ </html>