Files
next.orly.dev/app/web/src/CurationView.svelte
woikos cb50a9c5c4
Some checks failed
Go / build-and-release (push) Failing after 4s
feat(curating): add user event viewer and delete functionality (v0.50.0)
- Add geteventsforpubkey API method for viewing user events with pagination
- Add deleteeventsforpubkey API method to purge blacklisted user events
- Add clickable user detail view in curation UI showing all events
- Add event content expansion/truncation for long content
- Add kind name display for common Nostr event types
- Implement safety check requiring blacklist before event deletion

Files modified:
- app/handle-nip86-curating.go: Add event fetch/delete handlers
- pkg/database/curating-acl.go: Add GetEventsForPubkey, DeleteEventsForPubkey
- app/web/src/CurationView.svelte: Add user detail view with event listing
- pkg/version/version: Bump to v0.50.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:19:33 +01:00

1722 lines
55 KiB
Svelte

<script>
import { onMount } from "svelte";
import { curationKindCategories, parseCustomKinds, formatKindsCompact } from "./kindCategories.js";
// Props
export let userSigner;
export let userPubkey;
// State management
let activeTab = "trusted";
let isLoading = false;
let message = "";
let messageType = "info";
let isConfigured = false;
// User detail view state
let selectedUser = null;
let selectedUserType = null; // "trusted", "blacklisted", or "unclassified"
let userEvents = [];
let userEventsTotal = 0;
let userEventsOffset = 0;
let loadingEvents = false;
let expandedEvents = {}; // Track which events are expanded
// Configuration state
let config = {
daily_limit: 50,
first_ban_hours: 1,
second_ban_hours: 168,
categories: [],
custom_kinds: "",
kind_ranges: []
};
// Trusted pubkeys
let trustedPubkeys = [];
let newTrustedPubkey = "";
let newTrustedNote = "";
// Blacklisted pubkeys
let blacklistedPubkeys = [];
let newBlacklistedPubkey = "";
let newBlacklistedReason = "";
// Unclassified users
let unclassifiedUsers = [];
// Spam events
let spamEvents = [];
// Blocked IPs
let blockedIPs = [];
// Check configuration on mount
onMount(async () => {
await checkConfiguration();
});
// Create NIP-98 authentication event
async function createNIP98AuthEvent(method, url) {
if (!userSigner) {
throw new Error("No signer available. Please log in with a Nostr extension.");
}
if (!userPubkey) {
throw new Error("No user pubkey available.");
}
const fullUrl = window.location.origin + url;
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", fullUrl],
["method", method],
],
content: "",
pubkey: userPubkey,
};
const signedAuthEvent = await userSigner.signEvent(authEvent);
return `Nostr ${btoa(JSON.stringify(signedAuthEvent))}`;
}
// Make NIP-86 API call
async function callNIP86API(method, params = []) {
try {
isLoading = true;
message = "";
const request = { method, params };
const authHeader = await createNIP98AuthEvent("POST", "/api/nip86");
const response = await fetch("/api/nip86", {
method: "POST",
headers: {
"Content-Type": "application/nostr+json+rpc",
Authorization: authHeader,
},
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
return result.result;
} catch (error) {
console.error("NIP-86 API error:", error);
message = error.message;
messageType = "error";
throw error;
} finally {
isLoading = false;
}
}
// Check if curating mode is configured
async function checkConfiguration() {
try {
const result = await callNIP86API("isconfigured");
isConfigured = result === true;
if (isConfigured) {
await loadConfig();
await loadAllData();
}
} catch (error) {
console.error("Failed to check configuration:", error);
isConfigured = false;
}
}
// Load current configuration
async function loadConfig() {
try {
const result = await callNIP86API("getcuratingconfig");
if (result) {
config = {
daily_limit: result.daily_limit || 50,
first_ban_hours: result.first_ban_hours || 1,
second_ban_hours: result.second_ban_hours || 168,
categories: result.categories || [],
custom_kinds: result.custom_kinds ? result.custom_kinds.join(", ") : "",
kind_ranges: result.kind_ranges || []
};
}
} catch (error) {
console.error("Failed to load config:", error);
}
}
// Load all data
async function loadAllData() {
await Promise.all([
loadTrustedPubkeys(),
loadBlacklistedPubkeys(),
loadUnclassifiedUsers(),
loadSpamEvents(),
loadBlockedIPs(),
]);
}
// Load trusted pubkeys
async function loadTrustedPubkeys() {
try {
trustedPubkeys = await callNIP86API("listtrustedpubkeys");
} catch (error) {
console.error("Failed to load trusted pubkeys:", error);
trustedPubkeys = [];
}
}
// Load blacklisted pubkeys
async function loadBlacklistedPubkeys() {
try {
blacklistedPubkeys = await callNIP86API("listblacklistedpubkeys");
} catch (error) {
console.error("Failed to load blacklisted pubkeys:", error);
blacklistedPubkeys = [];
}
}
// Load unclassified users
async function loadUnclassifiedUsers() {
try {
unclassifiedUsers = await callNIP86API("listunclassifiedusers");
} catch (error) {
console.error("Failed to load unclassified users:", error);
unclassifiedUsers = [];
}
}
// Scan database for all pubkeys
async function scanDatabase() {
try {
const result = await callNIP86API("scanpubkeys");
showMessage(`Database scanned: ${result.total_pubkeys} pubkeys, ${result.total_events} events (${result.skipped} skipped)`, "success");
// Refresh the unclassified users list
await loadUnclassifiedUsers();
} catch (error) {
console.error("Failed to scan database:", error);
showMessage("Failed to scan database: " + error.message, "error");
}
}
// Load spam events
async function loadSpamEvents() {
try {
spamEvents = await callNIP86API("listspamevents");
} catch (error) {
console.error("Failed to load spam events:", error);
spamEvents = [];
}
}
// Load blocked IPs
async function loadBlockedIPs() {
try {
blockedIPs = await callNIP86API("listblockedips");
} catch (error) {
console.error("Failed to load blocked IPs:", error);
blockedIPs = [];
}
}
// Trust a pubkey
async function trustPubkey(pubkey = null, note = "") {
const pk = pubkey || newTrustedPubkey;
const n = pubkey ? note : newTrustedNote;
if (!pk) return;
try {
await callNIP86API("trustpubkey", [pk, n]);
message = "Pubkey trusted successfully";
messageType = "success";
newTrustedPubkey = "";
newTrustedNote = "";
await loadTrustedPubkeys();
await loadUnclassifiedUsers();
} catch (error) {
console.error("Failed to trust pubkey:", error);
}
}
// Untrust a pubkey
async function untrustPubkey(pubkey) {
try {
await callNIP86API("untrustpubkey", [pubkey]);
message = "Pubkey untrusted";
messageType = "success";
await loadTrustedPubkeys();
} catch (error) {
console.error("Failed to untrust pubkey:", error);
}
}
// Blacklist a pubkey
async function blacklistPubkey(pubkey = null, reason = "") {
const pk = pubkey || newBlacklistedPubkey;
const r = pubkey ? reason : newBlacklistedReason;
if (!pk) return;
try {
await callNIP86API("blacklistpubkey", [pk, r]);
message = "Pubkey blacklisted";
messageType = "success";
newBlacklistedPubkey = "";
newBlacklistedReason = "";
await loadBlacklistedPubkeys();
await loadUnclassifiedUsers();
} catch (error) {
console.error("Failed to blacklist pubkey:", error);
}
}
// Remove from blacklist
async function unblacklistPubkey(pubkey) {
try {
await callNIP86API("unblacklistpubkey", [pubkey]);
message = "Pubkey removed from blacklist";
messageType = "success";
await loadBlacklistedPubkeys();
} catch (error) {
console.error("Failed to remove from blacklist:", error);
}
}
// Mark event as spam
async function markSpam(eventId, reason) {
try {
await callNIP86API("markspam", [eventId, reason]);
message = "Event marked as spam";
messageType = "success";
await loadSpamEvents();
} catch (error) {
console.error("Failed to mark spam:", error);
}
}
// Unmark spam
async function unmarkSpam(eventId) {
try {
await callNIP86API("unmarkspam", [eventId]);
message = "Spam mark removed";
messageType = "success";
await loadSpamEvents();
} catch (error) {
console.error("Failed to unmark spam:", error);
}
}
// Delete event
async function deleteEvent(eventId) {
if (!confirm("Permanently delete this event?")) return;
try {
await callNIP86API("deleteevent", [eventId]);
message = "Event deleted";
messageType = "success";
await loadSpamEvents();
} catch (error) {
console.error("Failed to delete event:", error);
}
}
// Unblock IP
async function unblockIP(ip) {
try {
await callNIP86API("unblockip", [ip]);
message = "IP unblocked";
messageType = "success";
await loadBlockedIPs();
} catch (error) {
console.error("Failed to unblock IP:", error);
}
}
// Toggle category selection
function toggleCategory(categoryId) {
if (config.categories.includes(categoryId)) {
config.categories = config.categories.filter(c => c !== categoryId);
} else {
config.categories = [...config.categories, categoryId];
}
}
// Publish configuration event
async function publishConfiguration() {
if (!userSigner || !userPubkey) {
message = "Please log in with a Nostr extension to publish configuration";
messageType = "error";
return;
}
if (config.categories.length === 0 && !config.custom_kinds.trim()) {
message = "Please select at least one kind category or enter custom kinds";
messageType = "error";
return;
}
try {
isLoading = true;
message = "";
// Build tags
const tags = [
["d", "curating-config"],
["daily_limit", String(config.daily_limit)],
["first_ban_hours", String(config.first_ban_hours)],
["second_ban_hours", String(config.second_ban_hours)],
];
// Add category tags
for (const cat of config.categories) {
tags.push(["kind_category", cat]);
}
// Parse and add custom kinds
const customKinds = parseCustomKinds(config.custom_kinds);
for (const kind of customKinds) {
tags.push(["kind", String(kind)]);
}
// Create the configuration event
const configEvent = {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: "Curating relay configuration",
pubkey: userPubkey,
};
// Sign the event
const signedEvent = await userSigner.signEvent(configEvent);
// Submit to relay via WebSocket
const ws = new WebSocket(window.location.origin.replace(/^http/, 'ws'));
await new Promise((resolve, reject) => {
ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", signedEvent]));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === "OK") {
if (msg[2] === true) {
resolve();
} else {
reject(new Error(msg[3] || "Event rejected"));
}
}
};
ws.onerror = (e) => reject(new Error("WebSocket error"));
setTimeout(() => reject(new Error("Timeout")), 10000);
});
ws.close();
message = "Configuration published successfully";
messageType = "success";
isConfigured = true;
await loadAllData();
} catch (error) {
console.error("Failed to publish configuration:", error);
message = `Failed to publish: ${error.message}`;
messageType = "error";
} finally {
isLoading = false;
}
}
// Update configuration (re-publish)
async function updateConfiguration() {
await publishConfiguration();
}
// Format pubkey for display
function formatPubkey(pubkey) {
if (!pubkey || pubkey.length < 16) return pubkey;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
// Format date
function formatDate(timestamp) {
if (!timestamp) return "";
return new Date(timestamp).toLocaleString();
}
// Show message helper
function showMessage(msg, type = "info") {
message = msg;
messageType = type;
}
// Open user detail view
async function openUserDetail(pubkey, type) {
console.log("openUserDetail called:", pubkey, type);
selectedUser = pubkey;
selectedUserType = type;
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
expandedEvents = {};
console.log("selectedUser set to:", selectedUser);
await loadUserEvents();
}
// Close user detail view
function closeUserDetail() {
selectedUser = null;
selectedUserType = null;
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
expandedEvents = {};
}
// Load events for selected user
async function loadUserEvents() {
console.log("loadUserEvents called, selectedUser:", selectedUser, "loadingEvents:", loadingEvents);
if (!selectedUser || loadingEvents) return;
try {
loadingEvents = true;
console.log("Calling geteventsforpubkey API...");
const result = await callNIP86API("geteventsforpubkey", [selectedUser, 100, userEventsOffset]);
console.log("API result:", result);
if (result) {
if (userEventsOffset === 0) {
userEvents = result.events || [];
} else {
userEvents = [...userEvents, ...(result.events || [])];
}
userEventsTotal = result.total || 0;
}
} catch (error) {
console.error("Failed to load user events:", error);
showMessage("Failed to load events: " + error.message, "error");
} finally {
loadingEvents = false;
}
}
// Load more events
async function loadMoreEvents() {
userEventsOffset = userEvents.length;
await loadUserEvents();
}
// Toggle event expansion
function toggleEventExpansion(eventId) {
expandedEvents = {
...expandedEvents,
[eventId]: !expandedEvents[eventId]
};
}
// Truncate content to 6 lines (approximately 300 chars per line)
function truncateContent(content, maxLines = 6) {
if (!content) return "";
const lines = content.split('\n');
if (lines.length <= maxLines && content.length <= maxLines * 100) {
return content;
}
// Truncate by lines or characters, whichever is smaller
let truncated = lines.slice(0, maxLines).join('\n');
if (truncated.length > maxLines * 100) {
truncated = truncated.substring(0, maxLines * 100);
}
return truncated;
}
// Check if content is truncated
function isContentTruncated(content, maxLines = 6) {
if (!content) return false;
const lines = content.split('\n');
return lines.length > maxLines || content.length > maxLines * 100;
}
// Trust user from detail view and refresh
async function trustUserFromDetail() {
await trustPubkey(selectedUser, "");
// Refresh list and go back
await loadAllData();
closeUserDetail();
}
// Blacklist user from detail view and refresh
async function blacklistUserFromDetail() {
await blacklistPubkey(selectedUser, "");
// Refresh list and go back
await loadAllData();
closeUserDetail();
}
// Untrust user from detail view and refresh
async function untrustUserFromDetail() {
await untrustPubkey(selectedUser);
await loadAllData();
closeUserDetail();
}
// Unblacklist user from detail view and refresh
async function unblacklistUserFromDetail() {
await unblacklistPubkey(selectedUser);
await loadAllData();
closeUserDetail();
}
// Delete all events for a blacklisted user
async function deleteAllEventsForUser() {
if (!confirm(`Delete ALL ${userEventsTotal} events from this user? This cannot be undone.`)) {
return;
}
try {
isLoading = true;
const result = await callNIP86API("deleteeventsforpubkey", [selectedUser]);
showMessage(`Deleted ${result.deleted} events`, "success");
// Refresh the events list
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
await loadUserEvents();
} catch (error) {
console.error("Failed to delete events:", error);
showMessage("Failed to delete events: " + error.message, "error");
} finally {
isLoading = false;
}
}
// Get kind name
function getKindName(kind) {
const kindNames = {
0: "Metadata",
1: "Text Note",
3: "Follow List",
4: "Encrypted DM",
6: "Repost",
7: "Reaction",
14: "Chat Message",
16: "Order Message",
17: "Payment Receipt",
1063: "File Metadata",
10002: "Relay List",
30017: "Stall",
30018: "Product (NIP-15)",
30023: "Long-form",
30078: "App Data",
30402: "Product (NIP-99)",
30405: "Collection",
30406: "Shipping",
31555: "Review",
};
return kindNames[kind] || `Kind ${kind}`;
}
</script>
<div class="curation-view">
<h2>Curation Mode</h2>
{#if message}
<div class="message {messageType}">{message}</div>
{/if}
{#if !isConfigured}
<!-- Setup Mode -->
<div class="setup-section">
<div class="setup-header">
<h3>Initial Configuration</h3>
<p>Configure curating mode before the relay will accept events. Select which event kinds to allow and set rate limiting parameters.</p>
</div>
<div class="config-section">
<h4>Allowed Event Kinds</h4>
<p class="help-text">Select categories of events to allow. At least one must be selected.</p>
<div class="category-grid">
{#each curationKindCategories as category}
<label class="category-item" class:selected={config.categories.includes(category.id)}>
<input
type="checkbox"
checked={config.categories.includes(category.id)}
on:change={() => toggleCategory(category.id)}
/>
<div class="category-info">
<span class="category-name">{category.name}</span>
<span class="category-desc">{category.description}</span>
<span class="category-kinds">Kinds: {category.kinds.join(", ")}</span>
</div>
</label>
{/each}
</div>
<div class="custom-kinds">
<label for="custom-kinds">Custom Kinds (comma-separated, ranges allowed e.g., "100, 200-300")</label>
<input
id="custom-kinds"
type="text"
bind:value={config.custom_kinds}
placeholder="e.g., 100, 200-250, 500"
/>
</div>
</div>
<div class="config-section">
<h4>Rate Limiting</h4>
<div class="form-row">
<div class="form-group">
<label for="daily-limit">Daily Event Limit (unclassified users)</label>
<input
id="daily-limit"
type="number"
min="1"
bind:value={config.daily_limit}
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="first-ban">First IP Ban Duration (hours)</label>
<input
id="first-ban"
type="number"
min="1"
bind:value={config.first_ban_hours}
/>
</div>
<div class="form-group">
<label for="second-ban">Second+ IP Ban Duration (hours)</label>
<input
id="second-ban"
type="number"
min="1"
bind:value={config.second_ban_hours}
/>
</div>
</div>
</div>
<div class="publish-section">
<button
class="publish-btn"
on:click={publishConfiguration}
disabled={isLoading}
>
{#if isLoading}
Publishing...
{:else}
Publish Configuration
{/if}
</button>
<p class="publish-note">This will publish a kind 30078 event to activate curating mode.</p>
</div>
</div>
{:else}
<!-- User Detail View -->
{#if selectedUser}
<div class="user-detail-view">
<div class="detail-header">
<div class="detail-header-left">
<button class="back-btn" on:click={closeUserDetail}>
&larr; Back
</button>
<h3>User Events</h3>
<span class="detail-pubkey" title={selectedUser}>{formatPubkey(selectedUser)}</span>
<span class="detail-count">{userEventsTotal} events</span>
</div>
<div class="detail-header-right">
{#if selectedUserType === "trusted"}
<button class="btn-danger" on:click={untrustUserFromDetail}>Remove Trust</button>
<button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
{:else if selectedUserType === "blacklisted"}
<button class="btn-delete-all" on:click={deleteAllEventsForUser} disabled={isLoading || userEventsTotal === 0}>
Delete All Events
</button>
<button class="btn-success" on:click={unblacklistUserFromDetail}>Remove from Blacklist</button>
<button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
{:else}
<button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
<button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
{/if}
</div>
</div>
<div class="events-list">
{#if loadingEvents && userEvents.length === 0}
<div class="loading">Loading events...</div>
{:else if userEvents.length === 0}
<div class="empty">No events found for this user.</div>
{:else}
{#each userEvents as event}
<div class="event-item">
<div class="event-header">
<span class="event-kind">{getKindName(event.kind)}</span>
<span class="event-id" title={event.id}>{formatPubkey(event.id)}</span>
<span class="event-time">{formatDate(event.created_at * 1000)}</span>
</div>
<div class="event-content" class:expanded={expandedEvents[event.id]}>
{#if expandedEvents[event.id] || !isContentTruncated(event.content)}
<pre>{event.content || "(empty)"}</pre>
{:else}
<pre>{truncateContent(event.content)}...</pre>
{/if}
</div>
{#if isContentTruncated(event.content)}
<button class="expand-btn" on:click={() => toggleEventExpansion(event.id)}>
{expandedEvents[event.id] ? "Show less" : "Show more"}
</button>
{/if}
</div>
{/each}
{#if userEvents.length < userEventsTotal}
<div class="load-more">
<button on:click={loadMoreEvents} disabled={loadingEvents}>
{loadingEvents ? "Loading..." : `Load more (${userEvents.length} of ${userEventsTotal})`}
</button>
</div>
{/if}
{/if}
</div>
</div>
{:else}
<!-- Active Mode -->
<div class="tabs">
<button class="tab" class:active={activeTab === "trusted"} on:click={() => activeTab = "trusted"}>
Trusted ({trustedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "blacklist"} on:click={() => activeTab = "blacklist"}>
Blacklist ({blacklistedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "unclassified"} on:click={() => activeTab = "unclassified"}>
Unclassified ({unclassifiedUsers.length})
</button>
<button class="tab" class:active={activeTab === "spam"} on:click={() => activeTab = "spam"}>
Spam ({spamEvents.length})
</button>
<button class="tab" class:active={activeTab === "ips"} on:click={() => activeTab = "ips"}>
Blocked IPs ({blockedIPs.length})
</button>
<button class="tab" class:active={activeTab === "settings"} on:click={() => activeTab = "settings"}>
Settings
</button>
</div>
<div class="tab-content">
{#if activeTab === "trusted"}
<div class="section">
<h3>Trusted Publishers</h3>
<p class="help-text">Trusted users can publish unlimited events without rate limiting.</p>
<div class="add-form">
<input
type="text"
placeholder="Pubkey (64 hex chars)"
bind:value={newTrustedPubkey}
/>
<input
type="text"
placeholder="Note (optional)"
bind:value={newTrustedNote}
/>
<button on:click={() => trustPubkey()} disabled={isLoading}>
Trust
</button>
</div>
<div class="list">
{#if trustedPubkeys.length > 0}
{#each trustedPubkeys as item}
<div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "trusted")}>
<div class="item-main">
<span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
{#if item.note}
<span class="note">{item.note}</span>
{/if}
</div>
<div class="item-actions">
<button class="btn-danger" on:click|stopPropagation={() => untrustPubkey(item.pubkey)}>
Remove
</button>
</div>
</div>
{/each}
{:else}
<div class="empty">No trusted pubkeys yet.</div>
{/if}
</div>
</div>
{/if}
{#if activeTab === "blacklist"}
<div class="section">
<h3>Blacklisted Publishers</h3>
<p class="help-text">Blacklisted users cannot publish any events.</p>
<div class="add-form">
<input
type="text"
placeholder="Pubkey (64 hex chars)"
bind:value={newBlacklistedPubkey}
/>
<input
type="text"
placeholder="Reason (optional)"
bind:value={newBlacklistedReason}
/>
<button on:click={() => blacklistPubkey()} disabled={isLoading}>
Blacklist
</button>
</div>
<div class="list">
{#if blacklistedPubkeys.length > 0}
{#each blacklistedPubkeys as item}
<div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "blacklisted")}>
<div class="item-main">
<span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
{#if item.reason}
<span class="reason">{item.reason}</span>
{/if}
</div>
<div class="item-actions">
<button class="btn-success" on:click|stopPropagation={() => unblacklistPubkey(item.pubkey)}>
Remove
</button>
</div>
</div>
{/each}
{:else}
<div class="empty">No blacklisted pubkeys.</div>
{/if}
</div>
</div>
{/if}
{#if activeTab === "unclassified"}
<div class="section">
<h3>Unclassified Users</h3>
<p class="help-text">Users who have posted events but haven't been classified. Sorted by event count.</p>
<div class="button-row">
<button class="refresh-btn" on:click={loadUnclassifiedUsers} disabled={isLoading}>
Refresh
</button>
<button class="scan-btn" on:click={scanDatabase} disabled={isLoading}>
Scan Database
</button>
</div>
<div class="list">
{#if unclassifiedUsers.length > 0}
{#each unclassifiedUsers as user}
<div class="list-item clickable" on:click={() => openUserDetail(user.pubkey, "unclassified")}>
<div class="item-main">
<span class="pubkey" title={user.pubkey}>{formatPubkey(user.pubkey)}</span>
<span class="event-count">{user.event_count} events</span>
</div>
<div class="item-actions">
<button class="btn-success" on:click|stopPropagation={() => trustPubkey(user.pubkey, "")}>
Trust
</button>
<button class="btn-danger" on:click|stopPropagation={() => blacklistPubkey(user.pubkey, "")}>
Blacklist
</button>
</div>
</div>
{/each}
{:else}
<div class="empty">No unclassified users.</div>
{/if}
</div>
</div>
{/if}
{#if activeTab === "spam"}
<div class="section">
<h3>Spam Events</h3>
<p class="help-text">Events flagged as spam are hidden from query results but remain in the database.</p>
<button class="refresh-btn" on:click={loadSpamEvents} disabled={isLoading}>
Refresh
</button>
<div class="list">
{#if spamEvents.length > 0}
{#each spamEvents as event}
<div class="list-item">
<div class="item-main">
<span class="event-id" title={event.event_id}>{formatPubkey(event.event_id)}</span>
<span class="pubkey" title={event.pubkey}>by {formatPubkey(event.pubkey)}</span>
{#if event.reason}
<span class="reason">{event.reason}</span>
{/if}
</div>
<div class="item-actions">
<button class="btn-success" on:click={() => unmarkSpam(event.event_id)}>
Unmark
</button>
<button class="btn-danger" on:click={() => deleteEvent(event.event_id)}>
Delete
</button>
</div>
</div>
{/each}
{:else}
<div class="empty">No spam events flagged.</div>
{/if}
</div>
</div>
{/if}
{#if activeTab === "ips"}
<div class="section">
<h3>Blocked IP Addresses</h3>
<p class="help-text">IP addresses blocked due to rate limit violations.</p>
<button class="refresh-btn" on:click={loadBlockedIPs} disabled={isLoading}>
Refresh
</button>
<div class="list">
{#if blockedIPs.length > 0}
{#each blockedIPs as ip}
<div class="list-item">
<div class="item-main">
<span class="ip">{ip.ip}</span>
{#if ip.reason}
<span class="reason">{ip.reason}</span>
{/if}
{#if ip.expires_at}
<span class="expires">Expires: {formatDate(ip.expires_at)}</span>
{/if}
</div>
<div class="item-actions">
<button class="btn-success" on:click={() => unblockIP(ip.ip)}>
Unblock
</button>
</div>
</div>
{/each}
{:else}
<div class="empty">No blocked IPs.</div>
{/if}
</div>
</div>
{/if}
{#if activeTab === "settings"}
<div class="section">
<h3>Curating Configuration</h3>
<p class="help-text">Update curating mode settings. Changes will publish a new configuration event.</p>
<div class="config-section">
<h4>Allowed Event Kinds</h4>
<div class="category-grid">
{#each curationKindCategories as category}
<label class="category-item" class:selected={config.categories.includes(category.id)}>
<input
type="checkbox"
checked={config.categories.includes(category.id)}
on:change={() => toggleCategory(category.id)}
/>
<div class="category-info">
<span class="category-name">{category.name}</span>
<span class="category-kinds">Kinds: {category.kinds.join(", ")}</span>
</div>
</label>
{/each}
</div>
<div class="custom-kinds">
<label for="custom-kinds-edit">Custom Kinds</label>
<input
id="custom-kinds-edit"
type="text"
bind:value={config.custom_kinds}
placeholder="e.g., 100, 200-250, 500"
/>
</div>
</div>
<div class="config-section">
<h4>Rate Limiting</h4>
<div class="form-row">
<div class="form-group">
<label for="daily-limit-edit">Daily Event Limit</label>
<input
id="daily-limit-edit"
type="number"
min="1"
bind:value={config.daily_limit}
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="first-ban-edit">First Ban (hours)</label>
<input
id="first-ban-edit"
type="number"
min="1"
bind:value={config.first_ban_hours}
/>
</div>
<div class="form-group">
<label for="second-ban-edit">Second+ Ban (hours)</label>
<input
id="second-ban-edit"
type="number"
min="1"
bind:value={config.second_ban_hours}
/>
</div>
</div>
</div>
<div class="publish-section">
<button
class="publish-btn"
on:click={updateConfiguration}
disabled={isLoading}
>
{#if isLoading}
Updating...
{:else}
Update Configuration
{/if}
</button>
</div>
</div>
{/if}
</div>
{/if}
{/if}
</div>
<style>
.curation-view {
width: 100%;
max-width: 900px;
margin: 0;
padding: 20px;
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
}
.curation-view h2 {
margin: 0 0 1.5rem 0;
color: var(--text-color);
font-size: 1.8rem;
font-weight: 600;
}
.message {
padding: 10px 15px;
border-radius: 4px;
margin-bottom: 20px;
}
.message.success {
background-color: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success);
}
.message.error {
background-color: var(--error-bg);
color: var(--error-text);
border: 1px solid var(--danger);
}
.message.info {
background-color: var(--primary-bg);
color: var(--text-color);
border: 1px solid var(--info);
}
/* Setup Mode */
.setup-section {
background: var(--card-bg);
border-radius: 8px;
padding: 1.5em;
border: 1px solid var(--border-color);
}
.setup-header h3 {
margin: 0 0 0.5rem 0;
color: var(--text-color);
}
.setup-header p {
margin: 0 0 1.5rem 0;
color: var(--text-color);
opacity: 0.8;
}
.config-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.config-section h4 {
margin: 0 0 0.5rem 0;
color: var(--text-color);
}
.help-text {
margin: 0 0 1rem 0;
color: var(--text-color);
opacity: 0.7;
font-size: 0.9em;
}
.category-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
}
.category-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.category-item:hover {
border-color: var(--accent-color);
}
.category-item.selected {
border-color: var(--success);
background: var(--success-bg);
}
.category-item input[type="checkbox"] {
margin-top: 0.25rem;
}
.category-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.category-name {
font-weight: 600;
color: var(--text-color);
}
.category-desc {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.category-kinds {
font-size: 0.8em;
font-family: monospace;
color: var(--text-color);
opacity: 0.6;
}
.custom-kinds {
margin-top: 1rem;
}
.custom-kinds label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-color);
font-weight: 500;
}
.custom-kinds input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
}
.form-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.form-group {
flex: 1;
min-width: 150px;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-color);
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
}
.publish-section {
text-align: center;
padding: 1rem;
}
.publish-btn {
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 600;
background: var(--success);
color: var(--text-color);
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.publish-btn:hover:not(:disabled) {
filter: brightness(0.9);
}
.publish-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.publish-note {
margin-top: 0.75rem;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
/* Active Mode */
.tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1rem;
flex-wrap: wrap;
}
.tab {
padding: 0.75rem 1rem;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--text-color);
font-size: 0.9rem;
transition: all 0.2s;
}
.tab:hover {
background: var(--button-hover-bg);
}
.tab.active {
border-bottom-color: var(--accent-color);
color: var(--accent-color);
}
.tab-content {
min-height: 300px;
}
.section {
background: var(--card-bg);
border-radius: 8px;
padding: 1.5em;
border: 1px solid var(--border-color);
}
.section h3 {
margin: 0 0 0.5rem 0;
color: var(--text-color);
}
.add-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.add-form input {
flex: 1;
min-width: 150px;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
}
.add-form button {
padding: 0.5rem 1rem;
background: var(--success);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.add-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.refresh-btn {
margin-bottom: 1rem;
padding: 0.5rem 1rem;
background: var(--info);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.scan-btn {
padding: 0.5rem 1rem;
background: var(--warning, #f0ad4e);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.scan-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.list {
border: 1px solid var(--border-color);
border-radius: 4px;
max-height: 400px;
overflow-y: auto;
background: var(--bg-color);
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
gap: 1rem;
}
.list-item:last-child {
border-bottom: none;
}
.item-main {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.pubkey, .event-id, .ip {
font-family: monospace;
font-size: 0.9em;
color: var(--text-color);
}
.note, .reason, .expires {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.event-count {
font-size: 0.85em;
color: var(--success);
font-weight: 500;
}
.item-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.btn-success {
padding: 0.35rem 0.75rem;
background: var(--success);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
}
.btn-danger {
padding: 0.35rem 0.75rem;
background: var(--danger);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
}
.btn-delete-all {
padding: 0.35rem 0.75rem;
background: #8B0000;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
font-weight: 600;
}
.btn-delete-all:hover:not(:disabled) {
background: #660000;
}
.btn-delete-all:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.6;
font-style: italic;
}
/* Clickable list items */
.list-item.clickable {
cursor: pointer;
transition: background-color 0.2s;
}
.list-item.clickable:hover {
background-color: var(--button-hover-bg);
}
/* User Detail View */
.user-detail-view {
background: var(--card-bg);
border-radius: 8px;
padding: 1.5em;
border: 1px solid var(--border-color);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
gap: 1rem;
}
.detail-header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.detail-header-left h3 {
margin: 0;
color: var(--text-color);
}
.detail-header-right {
display: flex;
gap: 0.5rem;
}
.back-btn {
padding: 0.5rem 1rem;
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.back-btn:hover {
background: var(--button-hover-bg);
}
.detail-pubkey {
font-family: monospace;
font-size: 0.9em;
color: var(--text-color);
background: var(--bg-color);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.detail-count {
font-size: 0.85em;
color: var(--success);
font-weight: 500;
}
/* Events List */
.events-list {
max-height: 600px;
overflow-y: auto;
}
.event-item {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.event-header {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.event-kind {
background: var(--accent-color);
color: var(--text-color);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
}
.event-id {
font-family: monospace;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
}
.event-time {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.event-content {
background: var(--card-bg);
border-radius: 4px;
padding: 0.75rem;
overflow: hidden;
}
.event-content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.9em;
color: var(--text-color);
max-height: 150px;
overflow: hidden;
}
.event-content.expanded pre {
max-height: none;
}
.expand-btn {
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
background: transparent;
color: var(--accent-color);
border: 1px solid var(--accent-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
}
.expand-btn:hover {
background: var(--accent-color);
color: var(--text-color);
}
.load-more {
text-align: center;
padding: 1rem;
}
.load-more button {
padding: 0.5rem 1.5rem;
background: var(--info);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.load-more button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.6;
}
</style>