CurationView.svelte raw
1 <script>
2 import { onMount } from "svelte";
3 import { curationKindCategories, parseCustomKinds, formatKindsCompact } from "./kindCategories.js";
4 import { getApiBase, getWsUrl } from "./config.js";
5
6 // Props
7 export let userSigner;
8 export let userPubkey;
9
10 // State management
11 let activeTab = "trusted";
12 let isLoading = false;
13 let message = "";
14 let messageType = "info";
15 let isConfigured = false;
16
17 // User detail view state
18 let selectedUser = null;
19 let selectedUserType = null; // "trusted", "blacklisted", or "unclassified"
20 let userEvents = [];
21 let userEventsTotal = 0;
22 let userEventsOffset = 0;
23 let loadingEvents = false;
24 let expandedEvents = {}; // Track which events are expanded
25
26 // Configuration state
27 let config = {
28 daily_limit: 50,
29 first_ban_hours: 1,
30 second_ban_hours: 168,
31 categories: [],
32 custom_kinds: "",
33 kind_ranges: []
34 };
35
36 // Trusted pubkeys
37 let trustedPubkeys = [];
38 let newTrustedPubkey = "";
39 let newTrustedNote = "";
40
41 // Blacklisted pubkeys
42 let blacklistedPubkeys = [];
43 let newBlacklistedPubkey = "";
44 let newBlacklistedReason = "";
45
46 // Unclassified users
47 let unclassifiedUsers = [];
48
49 // Spam events
50 let spamEvents = [];
51
52 // Blocked IPs
53 let blockedIPs = [];
54
55 // Check configuration on mount
56 onMount(async () => {
57 await checkConfiguration();
58 });
59
60 // Create NIP-98 authentication event
61 async function createNIP98AuthEvent(method, url) {
62 if (!userSigner) {
63 throw new Error("No signer available. Please log in with a Nostr extension.");
64 }
65 if (!userPubkey) {
66 throw new Error("No user pubkey available.");
67 }
68
69 const fullUrl = getApiBase() + url;
70 const authEvent = {
71 kind: 27235,
72 created_at: Math.floor(Date.now() / 1000),
73 tags: [
74 ["u", fullUrl],
75 ["method", method],
76 ],
77 content: "",
78 pubkey: userPubkey,
79 };
80
81 const signedAuthEvent = await userSigner.signEvent(authEvent);
82 return `Nostr ${btoa(JSON.stringify(signedAuthEvent))}`;
83 }
84
85 // Make NIP-86 API call
86 async function callNIP86API(method, params = []) {
87 try {
88 isLoading = true;
89 message = "";
90
91 const request = { method, params };
92 const authHeader = await createNIP98AuthEvent("POST", "/api/nip86");
93
94 const response = await fetch("/api/nip86", {
95 method: "POST",
96 headers: {
97 "Content-Type": "application/nostr+json+rpc",
98 Authorization: authHeader,
99 },
100 body: JSON.stringify(request),
101 });
102
103 if (!response.ok) {
104 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
105 }
106
107 const result = await response.json();
108 if (result.error) {
109 throw new Error(result.error);
110 }
111
112 return result.result;
113 } catch (error) {
114 console.error("NIP-86 API error:", error);
115 message = error.message;
116 messageType = "error";
117 throw error;
118 } finally {
119 isLoading = false;
120 }
121 }
122
123 // Check if curating mode is configured
124 async function checkConfiguration() {
125 try {
126 const result = await callNIP86API("isconfigured");
127 isConfigured = result === true;
128
129 if (isConfigured) {
130 await loadConfig();
131 await loadAllData();
132 }
133 } catch (error) {
134 console.error("Failed to check configuration:", error);
135 isConfigured = false;
136 }
137 }
138
139 // Load current configuration
140 async function loadConfig() {
141 try {
142 const result = await callNIP86API("getcuratingconfig");
143 if (result) {
144 config = {
145 daily_limit: result.daily_limit || 50,
146 first_ban_hours: result.first_ban_hours || 1,
147 second_ban_hours: result.second_ban_hours || 168,
148 categories: result.categories || [],
149 custom_kinds: result.custom_kinds ? result.custom_kinds.join(", ") : "",
150 kind_ranges: result.kind_ranges || []
151 };
152 }
153 } catch (error) {
154 console.error("Failed to load config:", error);
155 }
156 }
157
158 // Load all data
159 async function loadAllData() {
160 await Promise.all([
161 loadTrustedPubkeys(),
162 loadBlacklistedPubkeys(),
163 loadUnclassifiedUsers(),
164 loadSpamEvents(),
165 loadBlockedIPs(),
166 ]);
167 }
168
169 // Load trusted pubkeys
170 async function loadTrustedPubkeys() {
171 try {
172 trustedPubkeys = await callNIP86API("listtrustedpubkeys");
173 } catch (error) {
174 console.error("Failed to load trusted pubkeys:", error);
175 trustedPubkeys = [];
176 }
177 }
178
179 // Load blacklisted pubkeys
180 async function loadBlacklistedPubkeys() {
181 try {
182 blacklistedPubkeys = await callNIP86API("listblacklistedpubkeys");
183 } catch (error) {
184 console.error("Failed to load blacklisted pubkeys:", error);
185 blacklistedPubkeys = [];
186 }
187 }
188
189 // Load unclassified users
190 async function loadUnclassifiedUsers() {
191 try {
192 unclassifiedUsers = await callNIP86API("listunclassifiedusers");
193 } catch (error) {
194 console.error("Failed to load unclassified users:", error);
195 unclassifiedUsers = [];
196 }
197 }
198
199 // Scan database for all pubkeys
200 async function scanDatabase() {
201 try {
202 const result = await callNIP86API("scanpubkeys");
203 showMessage(`Database scanned: ${result.total_pubkeys} pubkeys, ${result.total_events} events (${result.skipped} skipped)`, "success");
204 // Refresh the unclassified users list
205 await loadUnclassifiedUsers();
206 } catch (error) {
207 console.error("Failed to scan database:", error);
208 showMessage("Failed to scan database: " + error.message, "error");
209 }
210 }
211
212 // Load spam events
213 async function loadSpamEvents() {
214 try {
215 spamEvents = await callNIP86API("listspamevents");
216 } catch (error) {
217 console.error("Failed to load spam events:", error);
218 spamEvents = [];
219 }
220 }
221
222 // Load blocked IPs
223 async function loadBlockedIPs() {
224 try {
225 blockedIPs = await callNIP86API("listblockedips");
226 } catch (error) {
227 console.error("Failed to load blocked IPs:", error);
228 blockedIPs = [];
229 }
230 }
231
232 // Trust a pubkey
233 async function trustPubkey(pubkey = null, note = "") {
234 const pk = pubkey || newTrustedPubkey;
235 const n = pubkey ? note : newTrustedNote;
236
237 if (!pk) return;
238
239 try {
240 await callNIP86API("trustpubkey", [pk, n]);
241 message = "Pubkey trusted successfully";
242 messageType = "success";
243 newTrustedPubkey = "";
244 newTrustedNote = "";
245 await loadTrustedPubkeys();
246 await loadUnclassifiedUsers();
247 } catch (error) {
248 console.error("Failed to trust pubkey:", error);
249 }
250 }
251
252 // Untrust a pubkey
253 async function untrustPubkey(pubkey) {
254 try {
255 await callNIP86API("untrustpubkey", [pubkey]);
256 message = "Pubkey untrusted";
257 messageType = "success";
258 await loadTrustedPubkeys();
259 } catch (error) {
260 console.error("Failed to untrust pubkey:", error);
261 }
262 }
263
264 // Blacklist a pubkey
265 async function blacklistPubkey(pubkey = null, reason = "") {
266 const pk = pubkey || newBlacklistedPubkey;
267 const r = pubkey ? reason : newBlacklistedReason;
268
269 if (!pk) return;
270
271 try {
272 await callNIP86API("blacklistpubkey", [pk, r]);
273 message = "Pubkey blacklisted";
274 messageType = "success";
275 newBlacklistedPubkey = "";
276 newBlacklistedReason = "";
277 await loadBlacklistedPubkeys();
278 await loadUnclassifiedUsers();
279 } catch (error) {
280 console.error("Failed to blacklist pubkey:", error);
281 }
282 }
283
284 // Remove from blacklist
285 async function unblacklistPubkey(pubkey) {
286 try {
287 await callNIP86API("unblacklistpubkey", [pubkey]);
288 message = "Pubkey removed from blacklist";
289 messageType = "success";
290 await loadBlacklistedPubkeys();
291 } catch (error) {
292 console.error("Failed to remove from blacklist:", error);
293 }
294 }
295
296 // Mark event as spam
297 async function markSpam(eventId, reason) {
298 try {
299 await callNIP86API("markspam", [eventId, reason]);
300 message = "Event marked as spam";
301 messageType = "success";
302 await loadSpamEvents();
303 } catch (error) {
304 console.error("Failed to mark spam:", error);
305 }
306 }
307
308 // Unmark spam
309 async function unmarkSpam(eventId) {
310 try {
311 await callNIP86API("unmarkspam", [eventId]);
312 message = "Spam mark removed";
313 messageType = "success";
314 await loadSpamEvents();
315 } catch (error) {
316 console.error("Failed to unmark spam:", error);
317 }
318 }
319
320 // Delete event
321 async function deleteEvent(eventId) {
322 if (!confirm("Permanently delete this event?")) return;
323
324 try {
325 await callNIP86API("deleteevent", [eventId]);
326 message = "Event deleted";
327 messageType = "success";
328 await loadSpamEvents();
329 } catch (error) {
330 console.error("Failed to delete event:", error);
331 }
332 }
333
334 // Unblock IP
335 async function unblockIP(ip) {
336 try {
337 await callNIP86API("unblockip", [ip]);
338 message = "IP unblocked";
339 messageType = "success";
340 await loadBlockedIPs();
341 } catch (error) {
342 console.error("Failed to unblock IP:", error);
343 }
344 }
345
346 // Toggle category selection
347 function toggleCategory(categoryId) {
348 if (config.categories.includes(categoryId)) {
349 config.categories = config.categories.filter(c => c !== categoryId);
350 } else {
351 config.categories = [...config.categories, categoryId];
352 }
353 }
354
355 // Publish configuration event
356 async function publishConfiguration() {
357 if (!userSigner || !userPubkey) {
358 message = "Please log in with a Nostr extension to publish configuration";
359 messageType = "error";
360 return;
361 }
362
363 if (config.categories.length === 0 && !config.custom_kinds.trim()) {
364 message = "Please select at least one kind category or enter custom kinds";
365 messageType = "error";
366 return;
367 }
368
369 try {
370 isLoading = true;
371 message = "";
372
373 // Build tags
374 const tags = [
375 ["d", "curating-config"],
376 ["daily_limit", String(config.daily_limit)],
377 ["first_ban_hours", String(config.first_ban_hours)],
378 ["second_ban_hours", String(config.second_ban_hours)],
379 ];
380
381 // Add category tags
382 for (const cat of config.categories) {
383 tags.push(["kind_category", cat]);
384 }
385
386 // Parse and add custom kinds
387 const customKinds = parseCustomKinds(config.custom_kinds);
388 for (const kind of customKinds) {
389 tags.push(["kind", String(kind)]);
390 }
391
392 // Create the configuration event
393 const configEvent = {
394 kind: 30078,
395 created_at: Math.floor(Date.now() / 1000),
396 tags: tags,
397 content: "Curating relay configuration",
398 pubkey: userPubkey,
399 };
400
401 // Sign the event
402 const signedEvent = await userSigner.signEvent(configEvent);
403
404 // Submit to relay via WebSocket
405 const ws = new WebSocket(getWsUrl());
406
407 await new Promise((resolve, reject) => {
408 ws.onopen = () => {
409 ws.send(JSON.stringify(["EVENT", signedEvent]));
410 };
411 ws.onmessage = (e) => {
412 const msg = JSON.parse(e.data);
413 if (msg[0] === "OK") {
414 if (msg[2] === true) {
415 resolve();
416 } else {
417 reject(new Error(msg[3] || "Event rejected"));
418 }
419 }
420 };
421 ws.onerror = (e) => reject(new Error("WebSocket error"));
422 setTimeout(() => reject(new Error("Timeout")), 10000);
423 });
424
425 ws.close();
426
427 message = "Configuration published successfully";
428 messageType = "success";
429 isConfigured = true;
430 await loadAllData();
431 } catch (error) {
432 console.error("Failed to publish configuration:", error);
433 message = `Failed to publish: ${error.message}`;
434 messageType = "error";
435 } finally {
436 isLoading = false;
437 }
438 }
439
440 // Update configuration (re-publish)
441 async function updateConfiguration() {
442 await publishConfiguration();
443 }
444
445 // Format pubkey for display
446 function formatPubkey(pubkey) {
447 if (!pubkey || pubkey.length < 16) return pubkey;
448 return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
449 }
450
451 // Format date
452 function formatDate(timestamp) {
453 if (!timestamp) return "";
454 return new Date(timestamp).toLocaleString();
455 }
456
457 // Show message helper
458 function showMessage(msg, type = "info") {
459 message = msg;
460 messageType = type;
461 }
462
463 // Open user detail view
464 async function openUserDetail(pubkey, type) {
465 console.log("openUserDetail called:", pubkey, type);
466 selectedUser = pubkey;
467 selectedUserType = type;
468 userEvents = [];
469 userEventsTotal = 0;
470 userEventsOffset = 0;
471 expandedEvents = {};
472 console.log("selectedUser set to:", selectedUser);
473 await loadUserEvents();
474 }
475
476 // Close user detail view
477 function closeUserDetail() {
478 selectedUser = null;
479 selectedUserType = null;
480 userEvents = [];
481 userEventsTotal = 0;
482 userEventsOffset = 0;
483 expandedEvents = {};
484 }
485
486 // Load events for selected user
487 async function loadUserEvents() {
488 console.log("loadUserEvents called, selectedUser:", selectedUser, "loadingEvents:", loadingEvents);
489 if (!selectedUser || loadingEvents) return;
490
491 try {
492 loadingEvents = true;
493 console.log("Calling geteventsforpubkey API...");
494 const result = await callNIP86API("geteventsforpubkey", [selectedUser, 100, userEventsOffset]);
495 console.log("API result:", result);
496 if (result) {
497 if (userEventsOffset === 0) {
498 userEvents = result.events || [];
499 } else {
500 userEvents = [...userEvents, ...(result.events || [])];
501 }
502 userEventsTotal = result.total || 0;
503 }
504 } catch (error) {
505 console.error("Failed to load user events:", error);
506 showMessage("Failed to load events: " + error.message, "error");
507 } finally {
508 loadingEvents = false;
509 }
510 }
511
512 // Load more events
513 async function loadMoreEvents() {
514 userEventsOffset = userEvents.length;
515 await loadUserEvents();
516 }
517
518 // Toggle event expansion
519 function toggleEventExpansion(eventId) {
520 expandedEvents = {
521 ...expandedEvents,
522 [eventId]: !expandedEvents[eventId]
523 };
524 }
525
526 // Truncate content to 6 lines (approximately 300 chars per line)
527 function truncateContent(content, maxLines = 6) {
528 if (!content) return "";
529 const lines = content.split('\n');
530 if (lines.length <= maxLines && content.length <= maxLines * 100) {
531 return content;
532 }
533 // Truncate by lines or characters, whichever is smaller
534 let truncated = lines.slice(0, maxLines).join('\n');
535 if (truncated.length > maxLines * 100) {
536 truncated = truncated.substring(0, maxLines * 100);
537 }
538 return truncated;
539 }
540
541 // Check if content is truncated
542 function isContentTruncated(content, maxLines = 6) {
543 if (!content) return false;
544 const lines = content.split('\n');
545 return lines.length > maxLines || content.length > maxLines * 100;
546 }
547
548 // Trust user from detail view and refresh
549 async function trustUserFromDetail() {
550 await trustPubkey(selectedUser, "");
551 // Refresh list and go back
552 await loadAllData();
553 closeUserDetail();
554 }
555
556 // Blacklist user from detail view and refresh
557 async function blacklistUserFromDetail() {
558 await blacklistPubkey(selectedUser, "");
559 // Refresh list and go back
560 await loadAllData();
561 closeUserDetail();
562 }
563
564 // Untrust user from detail view and refresh
565 async function untrustUserFromDetail() {
566 await untrustPubkey(selectedUser);
567 await loadAllData();
568 closeUserDetail();
569 }
570
571 // Unblacklist user from detail view and refresh
572 async function unblacklistUserFromDetail() {
573 await unblacklistPubkey(selectedUser);
574 await loadAllData();
575 closeUserDetail();
576 }
577
578 // Delete all events for a blacklisted user
579 async function deleteAllEventsForUser() {
580 if (!confirm(`Delete ALL ${userEventsTotal} events from this user? This cannot be undone.`)) {
581 return;
582 }
583
584 try {
585 isLoading = true;
586 const result = await callNIP86API("deleteeventsforpubkey", [selectedUser]);
587 showMessage(`Deleted ${result.deleted} events`, "success");
588 // Refresh the events list
589 userEvents = [];
590 userEventsTotal = 0;
591 userEventsOffset = 0;
592 await loadUserEvents();
593 } catch (error) {
594 console.error("Failed to delete events:", error);
595 showMessage("Failed to delete events: " + error.message, "error");
596 } finally {
597 isLoading = false;
598 }
599 }
600
601 // Get kind name
602 function getKindName(kind) {
603 const kindNames = {
604 0: "Metadata",
605 1: "Text Note",
606 3: "Follow List",
607 4: "Encrypted DM",
608 6: "Repost",
609 7: "Reaction",
610 14: "Chat Message",
611 16: "Order Message",
612 17: "Payment Receipt",
613 1063: "File Metadata",
614 10002: "Relay List",
615 30017: "Stall",
616 30018: "Product (NIP-15)",
617 30023: "Long-form",
618 30078: "App Data",
619 30402: "Product (NIP-99)",
620 30405: "Collection",
621 30406: "Shipping",
622 31555: "Review",
623 };
624 return kindNames[kind] || `Kind ${kind}`;
625 }
626 </script>
627
628 <div class="curation-view">
629 <h2>Curation Mode</h2>
630
631 {#if message}
632 <div class="message {messageType}">{message}</div>
633 {/if}
634
635 {#if !isConfigured}
636 <!-- Setup Mode -->
637 <div class="setup-section">
638 <div class="setup-header">
639 <h3>Initial Configuration</h3>
640 <p>Configure curating mode before the relay will accept events. Select which event kinds to allow and set rate limiting parameters.</p>
641 </div>
642
643 <div class="config-section">
644 <h4>Allowed Event Kinds</h4>
645 <p class="help-text">Select categories of events to allow. At least one must be selected.</p>
646
647 <div class="category-grid">
648 {#each curationKindCategories as category}
649 <label class="category-item" class:selected={config.categories.includes(category.id)}>
650 <input
651 type="checkbox"
652 checked={config.categories.includes(category.id)}
653 on:change={() => toggleCategory(category.id)}
654 />
655 <div class="category-info">
656 <span class="category-name">{category.name}</span>
657 <span class="category-desc">{category.description}</span>
658 <span class="category-kinds">Kinds: {category.kinds.join(", ")}</span>
659 </div>
660 </label>
661 {/each}
662 </div>
663
664 <div class="custom-kinds">
665 <label for="custom-kinds">Custom Kinds (comma-separated, ranges allowed e.g., "100, 200-300")</label>
666 <input
667 id="custom-kinds"
668 type="text"
669 bind:value={config.custom_kinds}
670 placeholder="e.g., 100, 200-250, 500"
671 />
672 </div>
673 </div>
674
675 <div class="config-section">
676 <h4>Rate Limiting</h4>
677
678 <div class="form-row">
679 <div class="form-group">
680 <label for="daily-limit">Daily Event Limit (unclassified users)</label>
681 <input
682 id="daily-limit"
683 type="number"
684 min="1"
685 bind:value={config.daily_limit}
686 />
687 </div>
688 </div>
689
690 <div class="form-row">
691 <div class="form-group">
692 <label for="first-ban">First IP Ban Duration (hours)</label>
693 <input
694 id="first-ban"
695 type="number"
696 min="1"
697 bind:value={config.first_ban_hours}
698 />
699 </div>
700 <div class="form-group">
701 <label for="second-ban">Second+ IP Ban Duration (hours)</label>
702 <input
703 id="second-ban"
704 type="number"
705 min="1"
706 bind:value={config.second_ban_hours}
707 />
708 </div>
709 </div>
710 </div>
711
712 <div class="publish-section">
713 <button
714 class="publish-btn"
715 on:click={publishConfiguration}
716 disabled={isLoading}
717 >
718 {#if isLoading}
719 Publishing...
720 {:else}
721 Publish Configuration
722 {/if}
723 </button>
724 <p class="publish-note">This will publish a kind 30078 event to activate curating mode.</p>
725 </div>
726 </div>
727 {:else}
728 <!-- User Detail View -->
729 {#if selectedUser}
730 <div class="user-detail-view">
731 <div class="detail-header">
732 <div class="detail-header-left">
733 <button class="back-btn" on:click={closeUserDetail}>
734 ← Back
735 </button>
736 <h3>User Events</h3>
737 <span class="detail-pubkey" title={selectedUser}>{formatPubkey(selectedUser)}</span>
738 <span class="detail-count">{userEventsTotal} events</span>
739 </div>
740 <div class="detail-header-right">
741 {#if selectedUserType === "trusted"}
742 <button class="btn-danger" on:click={untrustUserFromDetail}>Remove Trust</button>
743 <button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
744 {:else if selectedUserType === "blacklisted"}
745 <button class="btn-delete-all" on:click={deleteAllEventsForUser} disabled={isLoading || userEventsTotal === 0}>
746 Delete All Events
747 </button>
748 <button class="btn-success" on:click={unblacklistUserFromDetail}>Remove from Blacklist</button>
749 <button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
750 {:else}
751 <button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
752 <button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
753 {/if}
754 </div>
755 </div>
756
757 <div class="events-list">
758 {#if loadingEvents && userEvents.length === 0}
759 <div class="loading">Loading events...</div>
760 {:else if userEvents.length === 0}
761 <div class="empty">No events found for this user.</div>
762 {:else}
763 {#each userEvents as event}
764 <div class="event-item">
765 <div class="event-header">
766 <span class="event-kind">{getKindName(event.kind)}</span>
767 <span class="event-id" title={event.id}>{formatPubkey(event.id)}</span>
768 <span class="event-time">{formatDate(event.created_at * 1000)}</span>
769 </div>
770 <div class="event-content" class:expanded={expandedEvents[event.id]}>
771 {#if expandedEvents[event.id] || !isContentTruncated(event.content)}
772 <pre>{event.content || "(empty)"}</pre>
773 {:else}
774 <pre>{truncateContent(event.content)}...</pre>
775 {/if}
776 </div>
777 {#if isContentTruncated(event.content)}
778 <button class="expand-btn" on:click={() => toggleEventExpansion(event.id)}>
779 {expandedEvents[event.id] ? "Show less" : "Show more"}
780 </button>
781 {/if}
782 </div>
783 {/each}
784
785 {#if userEvents.length < userEventsTotal}
786 <div class="load-more">
787 <button on:click={loadMoreEvents} disabled={loadingEvents}>
788 {loadingEvents ? "Loading..." : `Load more (${userEvents.length} of ${userEventsTotal})`}
789 </button>
790 </div>
791 {/if}
792 {/if}
793 </div>
794 </div>
795 {:else}
796 <!-- Active Mode -->
797 <div class="tabs">
798 <button class="tab" class:active={activeTab === "trusted"} on:click={() => activeTab = "trusted"}>
799 Trusted ({trustedPubkeys.length})
800 </button>
801 <button class="tab" class:active={activeTab === "blacklist"} on:click={() => activeTab = "blacklist"}>
802 Blacklist ({blacklistedPubkeys.length})
803 </button>
804 <button class="tab" class:active={activeTab === "unclassified"} on:click={() => activeTab = "unclassified"}>
805 Unclassified ({unclassifiedUsers.length})
806 </button>
807 <button class="tab" class:active={activeTab === "spam"} on:click={() => activeTab = "spam"}>
808 Spam ({spamEvents.length})
809 </button>
810 <button class="tab" class:active={activeTab === "ips"} on:click={() => activeTab = "ips"}>
811 Blocked IPs ({blockedIPs.length})
812 </button>
813 <button class="tab" class:active={activeTab === "settings"} on:click={() => activeTab = "settings"}>
814 Settings
815 </button>
816 </div>
817
818 <div class="tab-content">
819 {#if activeTab === "trusted"}
820 <div class="section">
821 <h3>Trusted Publishers</h3>
822 <p class="help-text">Trusted users can publish unlimited events without rate limiting.</p>
823
824 <div class="add-form">
825 <input
826 type="text"
827 placeholder="Pubkey (64 hex chars)"
828 bind:value={newTrustedPubkey}
829 />
830 <input
831 type="text"
832 placeholder="Note (optional)"
833 bind:value={newTrustedNote}
834 />
835 <button on:click={() => trustPubkey()} disabled={isLoading}>
836 Trust
837 </button>
838 </div>
839
840 <div class="list">
841 {#if trustedPubkeys.length > 0}
842 {#each trustedPubkeys as item}
843 <div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "trusted")}>
844 <div class="item-main">
845 <span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
846 {#if item.note}
847 <span class="note">{item.note}</span>
848 {/if}
849 </div>
850 <div class="item-actions">
851 <button class="btn-danger" on:click|stopPropagation={() => untrustPubkey(item.pubkey)}>
852 Remove
853 </button>
854 </div>
855 </div>
856 {/each}
857 {:else}
858 <div class="empty">No trusted pubkeys yet.</div>
859 {/if}
860 </div>
861 </div>
862 {/if}
863
864 {#if activeTab === "blacklist"}
865 <div class="section">
866 <h3>Blacklisted Publishers</h3>
867 <p class="help-text">Blacklisted users cannot publish any events.</p>
868
869 <div class="add-form">
870 <input
871 type="text"
872 placeholder="Pubkey (64 hex chars)"
873 bind:value={newBlacklistedPubkey}
874 />
875 <input
876 type="text"
877 placeholder="Reason (optional)"
878 bind:value={newBlacklistedReason}
879 />
880 <button on:click={() => blacklistPubkey()} disabled={isLoading}>
881 Blacklist
882 </button>
883 </div>
884
885 <div class="list">
886 {#if blacklistedPubkeys.length > 0}
887 {#each blacklistedPubkeys as item}
888 <div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "blacklisted")}>
889 <div class="item-main">
890 <span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
891 {#if item.reason}
892 <span class="reason">{item.reason}</span>
893 {/if}
894 </div>
895 <div class="item-actions">
896 <button class="btn-success" on:click|stopPropagation={() => unblacklistPubkey(item.pubkey)}>
897 Remove
898 </button>
899 </div>
900 </div>
901 {/each}
902 {:else}
903 <div class="empty">No blacklisted pubkeys.</div>
904 {/if}
905 </div>
906 </div>
907 {/if}
908
909 {#if activeTab === "unclassified"}
910 <div class="section">
911 <h3>Unclassified Users</h3>
912 <p class="help-text">Users who have posted events but haven't been classified. Sorted by event count.</p>
913
914 <div class="button-row">
915 <button class="refresh-btn" on:click={loadUnclassifiedUsers} disabled={isLoading}>
916 Refresh
917 </button>
918 <button class="scan-btn" on:click={scanDatabase} disabled={isLoading}>
919 Scan Database
920 </button>
921 </div>
922
923 <div class="list">
924 {#if unclassifiedUsers.length > 0}
925 {#each unclassifiedUsers as user}
926 <div class="list-item clickable" on:click={() => openUserDetail(user.pubkey, "unclassified")}>
927 <div class="item-main">
928 <span class="pubkey" title={user.pubkey}>{formatPubkey(user.pubkey)}</span>
929 <span class="event-count">{user.event_count} events</span>
930 </div>
931 <div class="item-actions">
932 <button class="btn-success" on:click|stopPropagation={() => trustPubkey(user.pubkey, "")}>
933 Trust
934 </button>
935 <button class="btn-danger" on:click|stopPropagation={() => blacklistPubkey(user.pubkey, "")}>
936 Blacklist
937 </button>
938 </div>
939 </div>
940 {/each}
941 {:else}
942 <div class="empty">No unclassified users.</div>
943 {/if}
944 </div>
945 </div>
946 {/if}
947
948 {#if activeTab === "spam"}
949 <div class="section">
950 <h3>Spam Events</h3>
951 <p class="help-text">Events flagged as spam are hidden from query results but remain in the database.</p>
952
953 <button class="refresh-btn" on:click={loadSpamEvents} disabled={isLoading}>
954 Refresh
955 </button>
956
957 <div class="list">
958 {#if spamEvents.length > 0}
959 {#each spamEvents as event}
960 <div class="list-item">
961 <div class="item-main">
962 <span class="event-id" title={event.event_id}>{formatPubkey(event.event_id)}</span>
963 <span class="pubkey" title={event.pubkey}>by {formatPubkey(event.pubkey)}</span>
964 {#if event.reason}
965 <span class="reason">{event.reason}</span>
966 {/if}
967 </div>
968 <div class="item-actions">
969 <button class="btn-success" on:click={() => unmarkSpam(event.event_id)}>
970 Unmark
971 </button>
972 <button class="btn-danger" on:click={() => deleteEvent(event.event_id)}>
973 Delete
974 </button>
975 </div>
976 </div>
977 {/each}
978 {:else}
979 <div class="empty">No spam events flagged.</div>
980 {/if}
981 </div>
982 </div>
983 {/if}
984
985 {#if activeTab === "ips"}
986 <div class="section">
987 <h3>Blocked IP Addresses</h3>
988 <p class="help-text">IP addresses blocked due to rate limit violations.</p>
989
990 <button class="refresh-btn" on:click={loadBlockedIPs} disabled={isLoading}>
991 Refresh
992 </button>
993
994 <div class="list">
995 {#if blockedIPs.length > 0}
996 {#each blockedIPs as ip}
997 <div class="list-item">
998 <div class="item-main">
999 <span class="ip">{ip.ip}</span>
1000 {#if ip.reason}
1001 <span class="reason">{ip.reason}</span>
1002 {/if}
1003 {#if ip.expires_at}
1004 <span class="expires">Expires: {formatDate(ip.expires_at)}</span>
1005 {/if}
1006 </div>
1007 <div class="item-actions">
1008 <button class="btn-success" on:click={() => unblockIP(ip.ip)}>
1009 Unblock
1010 </button>
1011 </div>
1012 </div>
1013 {/each}
1014 {:else}
1015 <div class="empty">No blocked IPs.</div>
1016 {/if}
1017 </div>
1018 </div>
1019 {/if}
1020
1021 {#if activeTab === "settings"}
1022 <div class="section">
1023 <h3>Curating Configuration</h3>
1024 <p class="help-text">Update curating mode settings. Changes will publish a new configuration event.</p>
1025
1026 <div class="config-section">
1027 <h4>Allowed Event Kinds</h4>
1028
1029 <div class="category-grid">
1030 {#each curationKindCategories as category}
1031 <label class="category-item" class:selected={config.categories.includes(category.id)}>
1032 <input
1033 type="checkbox"
1034 checked={config.categories.includes(category.id)}
1035 on:change={() => toggleCategory(category.id)}
1036 />
1037 <div class="category-info">
1038 <span class="category-name">{category.name}</span>
1039 <span class="category-kinds">Kinds: {category.kinds.join(", ")}</span>
1040 </div>
1041 </label>
1042 {/each}
1043 </div>
1044
1045 <div class="custom-kinds">
1046 <label for="custom-kinds-edit">Custom Kinds</label>
1047 <input
1048 id="custom-kinds-edit"
1049 type="text"
1050 bind:value={config.custom_kinds}
1051 placeholder="e.g., 100, 200-250, 500"
1052 />
1053 </div>
1054 </div>
1055
1056 <div class="config-section">
1057 <h4>Rate Limiting</h4>
1058
1059 <div class="form-row">
1060 <div class="form-group">
1061 <label for="daily-limit-edit">Daily Event Limit</label>
1062 <input
1063 id="daily-limit-edit"
1064 type="number"
1065 min="1"
1066 bind:value={config.daily_limit}
1067 />
1068 </div>
1069 </div>
1070
1071 <div class="form-row">
1072 <div class="form-group">
1073 <label for="first-ban-edit">First Ban (hours)</label>
1074 <input
1075 id="first-ban-edit"
1076 type="number"
1077 min="1"
1078 bind:value={config.first_ban_hours}
1079 />
1080 </div>
1081 <div class="form-group">
1082 <label for="second-ban-edit">Second+ Ban (hours)</label>
1083 <input
1084 id="second-ban-edit"
1085 type="number"
1086 min="1"
1087 bind:value={config.second_ban_hours}
1088 />
1089 </div>
1090 </div>
1091 </div>
1092
1093 <div class="publish-section">
1094 <button
1095 class="publish-btn"
1096 on:click={updateConfiguration}
1097 disabled={isLoading}
1098 >
1099 {#if isLoading}
1100 Updating...
1101 {:else}
1102 Update Configuration
1103 {/if}
1104 </button>
1105 </div>
1106 </div>
1107 {/if}
1108 </div>
1109 {/if}
1110 {/if}
1111 </div>
1112
1113 <style>
1114 .curation-view {
1115 width: 100%;
1116 max-width: 900px;
1117 margin: 0;
1118 padding: 20px;
1119 background: var(--header-bg);
1120 color: var(--text-color);
1121 border-radius: 8px;
1122 }
1123
1124 .curation-view h2 {
1125 margin: 0 0 1.5rem 0;
1126 color: var(--text-color);
1127 font-size: 1.8rem;
1128 font-weight: 600;
1129 }
1130
1131 .message {
1132 padding: 10px 15px;
1133 border-radius: 4px;
1134 margin-bottom: 20px;
1135 }
1136
1137 .message.success {
1138 background-color: var(--success-bg);
1139 color: var(--success-text);
1140 border: 1px solid var(--success);
1141 }
1142
1143 .message.error {
1144 background-color: var(--error-bg);
1145 color: var(--error-text);
1146 border: 1px solid var(--danger);
1147 }
1148
1149 .message.info {
1150 background-color: var(--primary-bg);
1151 color: var(--text-color);
1152 border: 1px solid var(--info);
1153 }
1154
1155 /* Setup Mode */
1156 .setup-section {
1157 background: var(--card-bg);
1158 border-radius: 8px;
1159 padding: 1.5em;
1160 border: 1px solid var(--border-color);
1161 }
1162
1163 .setup-header h3 {
1164 margin: 0 0 0.5rem 0;
1165 color: var(--text-color);
1166 }
1167
1168 .setup-header p {
1169 margin: 0 0 1.5rem 0;
1170 color: var(--text-color);
1171 opacity: 0.8;
1172 }
1173
1174 .config-section {
1175 margin-bottom: 1.5rem;
1176 padding: 1rem;
1177 background: var(--bg-color);
1178 border-radius: 6px;
1179 border: 1px solid var(--border-color);
1180 }
1181
1182 .config-section h4 {
1183 margin: 0 0 0.5rem 0;
1184 color: var(--text-color);
1185 }
1186
1187 .help-text {
1188 margin: 0 0 1rem 0;
1189 color: var(--text-color);
1190 opacity: 0.7;
1191 font-size: 0.9em;
1192 }
1193
1194 .category-grid {
1195 display: grid;
1196 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1197 gap: 0.75rem;
1198 }
1199
1200 .category-item {
1201 display: flex;
1202 align-items: flex-start;
1203 gap: 0.75rem;
1204 padding: 0.75rem;
1205 background: var(--card-bg);
1206 border: 1px solid var(--border-color);
1207 border-radius: 6px;
1208 cursor: pointer;
1209 transition: all 0.2s;
1210 }
1211
1212 .category-item:hover {
1213 border-color: var(--accent-color);
1214 }
1215
1216 .category-item.selected {
1217 border-color: var(--success);
1218 background: var(--success-bg);
1219 }
1220
1221 .category-item input[type="checkbox"] {
1222 margin-top: 0.25rem;
1223 }
1224
1225 .category-info {
1226 display: flex;
1227 flex-direction: column;
1228 gap: 0.25rem;
1229 }
1230
1231 .category-name {
1232 font-weight: 600;
1233 color: var(--text-color);
1234 }
1235
1236 .category-desc {
1237 font-size: 0.85em;
1238 color: var(--text-color);
1239 opacity: 0.7;
1240 }
1241
1242 .category-kinds {
1243 font-size: 0.8em;
1244 font-family: monospace;
1245 color: var(--text-color);
1246 opacity: 0.6;
1247 }
1248
1249 .custom-kinds {
1250 margin-top: 1rem;
1251 }
1252
1253 .custom-kinds label {
1254 display: block;
1255 margin-bottom: 0.5rem;
1256 color: var(--text-color);
1257 font-weight: 500;
1258 }
1259
1260 .custom-kinds input {
1261 width: 100%;
1262 padding: 0.5rem;
1263 border: 1px solid var(--border-color);
1264 border-radius: 4px;
1265 background: var(--input-bg);
1266 color: var(--input-text-color);
1267 }
1268
1269 .form-row {
1270 display: flex;
1271 gap: 1rem;
1272 flex-wrap: wrap;
1273 }
1274
1275 .form-group {
1276 flex: 1;
1277 min-width: 150px;
1278 }
1279
1280 .form-group label {
1281 display: block;
1282 margin-bottom: 0.5rem;
1283 color: var(--text-color);
1284 font-weight: 500;
1285 }
1286
1287 .form-group input {
1288 width: 100%;
1289 padding: 0.5rem;
1290 border: 1px solid var(--border-color);
1291 border-radius: 4px;
1292 background: var(--input-bg);
1293 color: var(--input-text-color);
1294 }
1295
1296 .publish-section {
1297 text-align: center;
1298 padding: 1rem;
1299 }
1300
1301 .publish-btn {
1302 padding: 0.75rem 2rem;
1303 font-size: 1rem;
1304 font-weight: 600;
1305 background: var(--success);
1306 color: var(--text-color);
1307 border: none;
1308 border-radius: 6px;
1309 cursor: pointer;
1310 transition: all 0.2s;
1311 }
1312
1313 .publish-btn:hover:not(:disabled) {
1314 filter: brightness(0.9);
1315 }
1316
1317 .publish-btn:disabled {
1318 opacity: 0.6;
1319 cursor: not-allowed;
1320 }
1321
1322 .publish-note {
1323 margin-top: 0.75rem;
1324 font-size: 0.85em;
1325 color: var(--text-color);
1326 opacity: 0.7;
1327 }
1328
1329 /* Active Mode */
1330 .tabs {
1331 display: flex;
1332 border-bottom: 1px solid var(--border-color);
1333 margin-bottom: 1rem;
1334 flex-wrap: wrap;
1335 }
1336
1337 .tab {
1338 padding: 0.75rem 1rem;
1339 border: none;
1340 background: none;
1341 cursor: pointer;
1342 border-bottom: 2px solid transparent;
1343 color: var(--text-color);
1344 font-size: 0.9rem;
1345 transition: all 0.2s;
1346 }
1347
1348 .tab:hover {
1349 background: var(--button-hover-bg);
1350 }
1351
1352 .tab.active {
1353 border-bottom-color: var(--accent-color);
1354 color: var(--accent-color);
1355 }
1356
1357 .tab-content {
1358 min-height: 300px;
1359 }
1360
1361 .section {
1362 background: var(--card-bg);
1363 border-radius: 8px;
1364 padding: 1.5em;
1365 border: 1px solid var(--border-color);
1366 }
1367
1368 .section h3 {
1369 margin: 0 0 0.5rem 0;
1370 color: var(--text-color);
1371 }
1372
1373 .add-form {
1374 display: flex;
1375 gap: 0.5rem;
1376 margin-bottom: 1rem;
1377 flex-wrap: wrap;
1378 }
1379
1380 .add-form input {
1381 flex: 1;
1382 min-width: 150px;
1383 padding: 0.5rem;
1384 border: 1px solid var(--border-color);
1385 border-radius: 4px;
1386 background: var(--input-bg);
1387 color: var(--input-text-color);
1388 }
1389
1390 .add-form button {
1391 padding: 0.5rem 1rem;
1392 background: var(--success);
1393 color: var(--text-color);
1394 border: none;
1395 border-radius: 4px;
1396 cursor: pointer;
1397 }
1398
1399 .add-form button:disabled {
1400 opacity: 0.6;
1401 cursor: not-allowed;
1402 }
1403
1404 .refresh-btn {
1405 margin-bottom: 1rem;
1406 padding: 0.5rem 1rem;
1407 background: var(--info);
1408 color: var(--text-color);
1409 border: none;
1410 border-radius: 4px;
1411 cursor: pointer;
1412 }
1413
1414 .refresh-btn:disabled {
1415 opacity: 0.6;
1416 cursor: not-allowed;
1417 }
1418
1419 .button-row {
1420 display: flex;
1421 gap: 0.5rem;
1422 margin-bottom: 1rem;
1423 }
1424
1425 .scan-btn {
1426 padding: 0.5rem 1rem;
1427 background: var(--warning, #f0ad4e);
1428 color: var(--text-color);
1429 border: none;
1430 border-radius: 4px;
1431 cursor: pointer;
1432 }
1433
1434 .scan-btn:disabled {
1435 opacity: 0.6;
1436 cursor: not-allowed;
1437 }
1438
1439 .list {
1440 border: 1px solid var(--border-color);
1441 border-radius: 4px;
1442 max-height: 400px;
1443 overflow-y: auto;
1444 background: var(--bg-color);
1445 }
1446
1447 .list-item {
1448 display: flex;
1449 justify-content: space-between;
1450 align-items: center;
1451 padding: 0.75rem 1rem;
1452 border-bottom: 1px solid var(--border-color);
1453 gap: 1rem;
1454 }
1455
1456 .list-item:last-child {
1457 border-bottom: none;
1458 }
1459
1460 .item-main {
1461 display: flex;
1462 flex-direction: column;
1463 gap: 0.25rem;
1464 flex: 1;
1465 min-width: 0;
1466 }
1467
1468 .pubkey, .event-id, .ip {
1469 font-family: monospace;
1470 font-size: 0.9em;
1471 color: var(--text-color);
1472 }
1473
1474 .note, .reason, .expires {
1475 font-size: 0.85em;
1476 color: var(--text-color);
1477 opacity: 0.7;
1478 }
1479
1480 .event-count {
1481 font-size: 0.85em;
1482 color: var(--success);
1483 font-weight: 500;
1484 }
1485
1486 .item-actions {
1487 display: flex;
1488 gap: 0.5rem;
1489 flex-shrink: 0;
1490 }
1491
1492 .btn-success {
1493 padding: 0.35rem 0.75rem;
1494 background: var(--success);
1495 color: var(--text-color);
1496 border: none;
1497 border-radius: 4px;
1498 cursor: pointer;
1499 font-size: 0.85em;
1500 }
1501
1502 .btn-danger {
1503 padding: 0.35rem 0.75rem;
1504 background: var(--danger);
1505 color: var(--text-color);
1506 border: none;
1507 border-radius: 4px;
1508 cursor: pointer;
1509 font-size: 0.85em;
1510 }
1511
1512 .btn-delete-all {
1513 padding: 0.35rem 0.75rem;
1514 background: #8B0000;
1515 color: white;
1516 border: none;
1517 border-radius: 4px;
1518 cursor: pointer;
1519 font-size: 0.85em;
1520 font-weight: 600;
1521 }
1522
1523 .btn-delete-all:hover:not(:disabled) {
1524 background: #660000;
1525 }
1526
1527 .btn-delete-all:disabled {
1528 opacity: 0.5;
1529 cursor: not-allowed;
1530 }
1531
1532 .empty {
1533 padding: 2rem;
1534 text-align: center;
1535 color: var(--text-color);
1536 opacity: 0.6;
1537 font-style: italic;
1538 }
1539
1540 /* Clickable list items */
1541 .list-item.clickable {
1542 cursor: pointer;
1543 transition: background-color 0.2s;
1544 }
1545
1546 .list-item.clickable:hover {
1547 background-color: var(--button-hover-bg);
1548 }
1549
1550 /* User Detail View */
1551 .user-detail-view {
1552 background: var(--card-bg);
1553 border-radius: 8px;
1554 padding: 1.5em;
1555 border: 1px solid var(--border-color);
1556 }
1557
1558 .detail-header {
1559 display: flex;
1560 justify-content: space-between;
1561 align-items: center;
1562 margin-bottom: 1.5rem;
1563 padding-bottom: 1rem;
1564 border-bottom: 1px solid var(--border-color);
1565 flex-wrap: wrap;
1566 gap: 1rem;
1567 }
1568
1569 .detail-header-left {
1570 display: flex;
1571 align-items: center;
1572 gap: 1rem;
1573 flex-wrap: wrap;
1574 }
1575
1576 .detail-header-left h3 {
1577 margin: 0;
1578 color: var(--text-color);
1579 }
1580
1581 .detail-header-right {
1582 display: flex;
1583 gap: 0.5rem;
1584 }
1585
1586 .back-btn {
1587 padding: 0.5rem 1rem;
1588 background: var(--bg-color);
1589 color: var(--text-color);
1590 border: 1px solid var(--border-color);
1591 border-radius: 4px;
1592 cursor: pointer;
1593 font-size: 0.9em;
1594 }
1595
1596 .back-btn:hover {
1597 background: var(--button-hover-bg);
1598 }
1599
1600 .detail-pubkey {
1601 font-family: monospace;
1602 font-size: 0.9em;
1603 color: var(--text-color);
1604 background: var(--bg-color);
1605 padding: 0.25rem 0.5rem;
1606 border-radius: 4px;
1607 }
1608
1609 .detail-count {
1610 font-size: 0.85em;
1611 color: var(--success);
1612 font-weight: 500;
1613 }
1614
1615 /* Events List */
1616 .events-list {
1617 max-height: 600px;
1618 overflow-y: auto;
1619 }
1620
1621 .event-item {
1622 background: var(--bg-color);
1623 border: 1px solid var(--border-color);
1624 border-radius: 6px;
1625 padding: 1rem;
1626 margin-bottom: 0.75rem;
1627 }
1628
1629 .event-header {
1630 display: flex;
1631 gap: 1rem;
1632 margin-bottom: 0.5rem;
1633 flex-wrap: wrap;
1634 align-items: center;
1635 }
1636
1637 .event-kind {
1638 background: var(--accent-color);
1639 color: var(--text-color);
1640 padding: 0.2rem 0.5rem;
1641 border-radius: 4px;
1642 font-size: 0.8em;
1643 font-weight: 500;
1644 }
1645
1646 .event-id {
1647 font-family: monospace;
1648 font-size: 0.8em;
1649 color: var(--text-color);
1650 opacity: 0.7;
1651 }
1652
1653 .event-time {
1654 font-size: 0.8em;
1655 color: var(--text-color);
1656 opacity: 0.6;
1657 }
1658
1659 .event-content {
1660 background: var(--card-bg);
1661 border-radius: 4px;
1662 padding: 0.75rem;
1663 overflow: hidden;
1664 }
1665
1666 .event-content pre {
1667 margin: 0;
1668 white-space: pre-wrap;
1669 word-break: break-word;
1670 font-family: inherit;
1671 font-size: 0.9em;
1672 color: var(--text-color);
1673 max-height: 150px;
1674 overflow: hidden;
1675 }
1676
1677 .event-content.expanded pre {
1678 max-height: none;
1679 }
1680
1681 .expand-btn {
1682 margin-top: 0.5rem;
1683 padding: 0.25rem 0.5rem;
1684 background: transparent;
1685 color: var(--accent-color);
1686 border: 1px solid var(--accent-color);
1687 border-radius: 4px;
1688 cursor: pointer;
1689 font-size: 0.8em;
1690 }
1691
1692 .expand-btn:hover {
1693 background: var(--accent-color);
1694 color: var(--text-color);
1695 }
1696
1697 .load-more {
1698 text-align: center;
1699 padding: 1rem;
1700 }
1701
1702 .load-more button {
1703 padding: 0.5rem 1.5rem;
1704 background: var(--info);
1705 color: var(--text-color);
1706 border: none;
1707 border-radius: 4px;
1708 cursor: pointer;
1709 }
1710
1711 .load-more button:disabled {
1712 opacity: 0.6;
1713 cursor: not-allowed;
1714 }
1715
1716 .loading {
1717 padding: 2rem;
1718 text-align: center;
1719 color: var(--text-color);
1720 opacity: 0.6;
1721 }
1722 </style>
1723