You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1232 lines
40 KiB
1232 lines
40 KiB
<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; |
|
|
|
// 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 = []; |
|
} |
|
} |
|
|
|
// 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(); |
|
} |
|
</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} |
|
<!-- 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"> |
|
<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={() => 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"> |
|
<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={() => 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> |
|
|
|
<button class="refresh-btn" on:click={loadUnclassifiedUsers} disabled={isLoading}> |
|
Refresh |
|
</button> |
|
|
|
<div class="list"> |
|
{#if unclassifiedUsers.length > 0} |
|
{#each unclassifiedUsers as user} |
|
<div class="list-item"> |
|
<div class="item-main"> |
|
<span class="pubkey" title={user.pubkey}>{formatPubkey(user.pubkey)}</span> |
|
<span class="event-count">{user.total_events} events</span> |
|
</div> |
|
<div class="item-actions"> |
|
<button class="btn-success" on:click={() => trustPubkey(user.pubkey, "")}> |
|
Trust |
|
</button> |
|
<button class="btn-danger" on:click={() => 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} |
|
</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; |
|
} |
|
|
|
.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; |
|
} |
|
|
|
.empty { |
|
padding: 2rem; |
|
text-align: center; |
|
color: var(--text-color); |
|
opacity: 0.6; |
|
font-style: italic; |
|
} |
|
</style>
|
|
|