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.
 
 
 
 
 
 

4551 lines
152 KiB

<script>
import LoginModal from "./LoginModal.svelte";
import ManagedACL from "./ManagedACL.svelte";
import Header from "./Header.svelte";
import Sidebar from "./Sidebar.svelte";
import ExportView from "./ExportView.svelte";
import ImportView from "./ImportView.svelte";
import EventsView from "./EventsView.svelte";
import ComposeView from "./ComposeView.svelte";
import RecoveryView from "./RecoveryView.svelte";
import SprocketView from "./SprocketView.svelte";
import PolicyView from "./PolicyView.svelte";
import SearchResultsView from "./SearchResultsView.svelte";
import FilterBuilder from "./FilterBuilder.svelte";
import FilterDisplay from "./FilterDisplay.svelte";
import { buildFilter } from "./helpers.tsx";
import {
initializeNostrClient,
fetchUserProfile,
fetchAllEvents,
fetchUserEvents,
searchEvents,
fetchEvents,
fetchEventById,
fetchDeleteEventsByTarget,
queryEvents,
queryEventsFromDB,
debugIndexedDB,
nostrClient,
NostrClient,
Nip07Signer,
PrivateKeySigner,
} from "./nostr.js";
import { publishEventWithAuth } from "./websocket-auth.js";
// Expose debug function globally for console access
if (typeof window !== "undefined") {
window.debugIndexedDB = debugIndexedDB;
}
let isDarkTheme = false;
let showLoginModal = false;
let isLoggedIn = false;
let userPubkey = "";
let authMethod = "";
let userProfile = null;
let userRole = "";
let userSigner = null;
let showSettingsDrawer = false;
let selectedTab = localStorage.getItem("selectedTab") || "export";
let showFilterBuilder = false; // Show advanced filter builder
let searchQuery = "";
let searchTabs = [];
let allEvents = [];
let selectedFile = null;
let expandedEvents = new Set();
let isLoadingEvents = false;
let hasMoreEvents = true;
let eventsPerPage = 100;
let oldestEventTimestamp = null; // For timestamp-based pagination
let newestEventTimestamp = null; // For loading newer events
let showPermissionMenu = false;
let viewAsRole = "";
// Search results state
let searchResults = new Map(); // Map of searchTabId -> { filter, events, isLoading, hasMore, oldestTimestamp }
let isLoadingSearch = false;
// Screen-filling events view state
let eventsPerScreen = 20; // Default, will be calculated based on screen size
// Global events cache system
let globalEventsCache = []; // All events cache
let globalCacheTimestamp = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Events filter toggle
let showOnlyMyEvents = false;
// My Events state
let myEvents = [];
let isLoadingMyEvents = false;
let hasMoreMyEvents = true;
let oldestMyEventTimestamp = null;
let newestMyEventTimestamp = null;
// Sprocket management state
let sprocketScript = "";
let sprocketStatus = null;
let sprocketVersions = [];
let isLoadingSprocket = false;
let sprocketMessage = "";
let sprocketMessageType = "info";
let sprocketEnabled = false;
let sprocketUploadFile = null;
// Policy management state
let policyJson = "";
let policyEnabled = false;
let isPolicyAdmin = false;
let isLoadingPolicy = false;
let policyMessage = "";
let policyMessageType = "info";
let policyValidationErrors = [];
let policyFollows = [];
// ACL mode
let aclMode = "";
// Compose tab state
let composeEventJson = "";
// Recovery tab state
let recoverySelectedKind = null;
let recoveryCustomKind = "";
let recoveryEvents = [];
let isLoadingRecovery = false;
let recoveryHasMore = true;
let recoveryOldestTimestamp = null;
let recoveryNewestTimestamp = null;
// Replaceable kinds for the recovery dropdown
// Based on NIP-01: kinds 0, 3, and 10000-19999 are replaceable
// kinds 30000-39999 are addressable (parameterized replaceable)
const replaceableKinds = [
// Basic replaceable kinds (0, 3)
{ value: 0, label: "User Metadata (0)" },
{ value: 3, label: "Follows (3)" },
// Replaceable range 10000-19999
{ value: 10000, label: "Mute list (10000)" },
{ value: 10001, label: "Pin list (10001)" },
{ value: 10002, label: "Relay List Metadata (10002)" },
{ value: 10003, label: "Bookmark list (10003)" },
{ value: 10004, label: "Communities list (10004)" },
{ value: 10005, label: "Public chats list (10005)" },
{ value: 10006, label: "Blocked relays list (10006)" },
{ value: 10007, label: "Search relays list (10007)" },
{ value: 10009, label: "User groups (10009)" },
{ value: 10012, label: "Favorite relays list (10012)" },
{ value: 10013, label: "Private event relay list (10013)" },
{ value: 10015, label: "Interests list (10015)" },
{ value: 10019, label: "Nutzap Mint Recommendation (10019)" },
{ value: 10020, label: "Media follows (10020)" },
{ value: 10030, label: "User emoji list (10030)" },
{ value: 10050, label: "Relay list to receive DMs (10050)" },
{ value: 10051, label: "KeyPackage Relays List (10051)" },
{ value: 10063, label: "User server list (10063)" },
{ value: 10096, label: "File storage server list (10096)" },
{ value: 10166, label: "Relay Monitor Announcement (10166)" },
{ value: 10312, label: "Room Presence (10312)" },
{ value: 10377, label: "Proxy Announcement (10377)" },
{ value: 11111, label: "Transport Method Announcement (11111)" },
{ value: 13194, label: "Wallet Info (13194)" },
{ value: 17375, label: "Cashu Wallet Event (17375)" },
// Addressable range 30000-39999 (parameterized replaceable)
{ value: 30000, label: "Follow sets (30000)" },
{ value: 30001, label: "Generic lists (30001)" },
{ value: 30002, label: "Relay sets (30002)" },
{ value: 30003, label: "Bookmark sets (30003)" },
{ value: 30004, label: "Curation sets (30004)" },
{ value: 30005, label: "Video sets (30005)" },
{ value: 30007, label: "Kind mute sets (30007)" },
{ value: 30008, label: "Profile Badges (30008)" },
{ value: 30009, label: "Badge Definition (30009)" },
{ value: 30015, label: "Interest sets (30015)" },
{ value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Create or update a product (30018)" },
{ value: 30019, label: "Marketplace UI/UX (30019)" },
{ value: 30020, label: "Product sold as an auction (30020)" },
{ value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30030, label: "Emoji sets (30030)" },
{ value: 30040, label: "Curated Publication Index (30040)" },
{ value: 30041, label: "Curated Publication Content (30041)" },
{ value: 30063, label: "Release artifact sets (30063)" },
{ value: 30078, label: "Application-specific Data (30078)" },
{ value: 30166, label: "Relay Discovery (30166)" },
{ value: 30267, label: "App curation sets (30267)" },
{ value: 30311, label: "Live Event (30311)" },
{ value: 30312, label: "Interactive Room (30312)" },
{ value: 30313, label: "Conference Event (30313)" },
{ value: 30315, label: "User Statuses (30315)" },
{ value: 30388, label: "Slide Set (30388)" },
{ value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 30617, label: "Repository announcements (30617)" },
{ value: 30618, label: "Repository state announcements (30618)" },
{ value: 30818, label: "Wiki article (30818)" },
{ value: 30819, label: "Redirects (30819)" },
{ value: 31234, label: "Draft Event (31234)" },
{ value: 31388, label: "Link Set (31388)" },
{ value: 31890, label: "Feed (31890)" },
{ value: 31922, label: "Date-Based Calendar Event (31922)" },
{ value: 31923, label: "Time-Based Calendar Event (31923)" },
{ value: 31924, label: "Calendar (31924)" },
{ value: 31925, label: "Calendar Event RSVP (31925)" },
{ value: 31989, label: "Handler recommendation (31989)" },
{ value: 31990, label: "Handler information (31990)" },
{ value: 32267, label: "Software Application (32267)" },
{ value: 34550, label: "Community Definition (34550)" },
{ value: 37516, label: "Geocache listing (37516)" },
{ value: 38172, label: "Cashu Mint Announcement (38172)" },
{ value: 38173, label: "Fedimint Announcement (38173)" },
{ value: 38383, label: "Peer-to-peer Order events (38383)" },
{ value: 39089, label: "Starter packs (39089)" },
{ value: 39092, label: "Media starter packs (39092)" },
{ value: 39701, label: "Web bookmarks (39701)" },
];
// Kind name mapping based on NIP specification
// Matches official Nostr event kinds from https://github.com/nostr-protocol/nips
const kindNames = {
0: "User Metadata",
1: "Short Text Note",
2: "Recommend Relay",
3: "Follows",
4: "Encrypted Direct Messages",
5: "Event Deletion Request",
6: "Repost",
7: "Reaction",
8: "Badge Award",
9: "Chat Message",
10: "Group Chat Threaded Reply",
11: "Thread",
12: "Group Thread Reply",
13: "Seal",
14: "Direct Message",
15: "File Message",
16: "Generic Repost",
17: "Reaction to a website",
20: "Picture",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1021: "Bid",
1022: "Bid Confirmation",
1040: "OpenTimestamps",
1063: "File Metadata",
1311: "Live Chat Message",
1971: "Problem Tracker",
1984: "Reporting",
1985: "Label",
4550: "Community Post Approval",
5000: "Job Request",
5999: "Job Request",
6000: "Job Result",
6999: "Job Result",
7000: "Job Feedback",
9041: "Zap Goal",
9734: "Zap Request",
9735: "Zap",
9882: "Highlights",
10000: "Mute list",
10001: "Pin list",
10002: "Relay List Metadata",
10003: "Bookmarks list",
10004: "Communities list",
10005: "Public Chats list",
10006: "Blocked Relays list",
10007: "Search Relays list",
10015: "Interests",
10030: "User Emoji list",
10050: "DM relays",
10096: "File Storage Server List",
13194: "Wallet Service Info",
21000: "Lightning pub RPC",
22242: "Client Authentication",
23194: "Wallet Request",
23195: "Wallet Response",
23196: "Wallet Notification",
23197: "Wallet Notification",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Follow sets",
30001: "Generic lists",
30002: "Relay sets",
30003: "Bookmark sets",
30004: "Curation sets",
30008: "Profile Badges",
30009: "Badge Definition",
30015: "Interest sets",
30017: "Stall Definition",
30018: "Product Definition",
30019: "Marketplace UI/UX",
30020: "Product sold as an auction",
30023: "Long-form Content",
30024: "Draft Long-form Content",
30030: "Emoji sets",
30078: "Application-specific Data",
30311: "Live Event",
30315: "User Statuses",
30402: "Classified Listing",
30403: "Draft Classified Listing",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
31924: "Calendar",
31925: "Calendar Event RSVP",
31989: "Handler recommendation",
31990: "Handler information",
34235: "Video Event Horizontal",
34236: "Video Event Vertical",
34550: "Community Definition",
};
function getKindName(kind) {
return kindNames[kind] || `Kind ${kind}`;
}
function truncatePubkey(pubkey) {
if (!pubkey) return "unknown";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
function truncateContent(content, maxLength = 100) {
if (!content) return "";
return content.length > maxLength
? content.slice(0, maxLength) + "..."
: content;
}
function formatTimestamp(timestamp) {
if (!timestamp) return "";
return new Date(timestamp * 1000).toLocaleString();
}
function toggleEventExpansion(eventId) {
if (expandedEvents.has(eventId)) {
expandedEvents.delete(eventId);
} else {
expandedEvents.add(eventId);
}
expandedEvents = expandedEvents; // Trigger reactivity
}
async function copyEventToClipboard(eventData, clickEvent) {
try {
// Create minified JSON (no indentation)
const minifiedJson = JSON.stringify(eventData);
await navigator.clipboard.writeText(minifiedJson);
// Show temporary feedback
const button = clickEvent.target.closest(".copy-json-btn");
if (button) {
const originalText = button.textContent;
button.textContent = "✅";
button.style.backgroundColor = "#4CAF50";
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = "";
}, 2000);
}
} catch (error) {
console.error("Failed to copy to clipboard:", error);
// Fallback for older browsers
try {
const textArea = document.createElement("textarea");
textArea.value = JSON.stringify(eventData);
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
const button = clickEvent.target.closest(".copy-json-btn");
if (button) {
const originalText = button.textContent;
button.textContent = "✅";
button.style.backgroundColor = "#4CAF50";
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = "";
}, 2000);
}
} catch (fallbackError) {
console.error("Fallback copy also failed:", fallbackError);
alert("Failed to copy to clipboard. Please copy manually.");
}
}
}
async function handleToggleChange() {
// Toggle state is already updated by bind:checked
console.log("Toggle changed, showOnlyMyEvents:", showOnlyMyEvents);
// Reset the attempt flag to allow reloading with new filter
hasAttemptedEventLoad = false;
// Reload events with the new filter
const authors =
showOnlyMyEvents && isLoggedIn && userPubkey ? [userPubkey] : null;
await loadAllEvents(true, authors);
}
// Events are filtered server-side, but add client-side filtering as backup
// Sort events by created_at timestamp (newest first)
$: filteredEvents = (
showOnlyMyEvents && isLoggedIn && userPubkey
? allEvents.filter(
(event) => event.pubkey && event.pubkey === userPubkey,
)
: allEvents
).sort((a, b) => b.created_at - a.created_at);
async function deleteEvent(eventId) {
if (!isLoggedIn) {
alert("Please log in first");
return;
}
// Find the event to check if user can delete it
const event = allEvents.find((e) => e.id === eventId);
if (!event) {
alert("Event not found");
return;
}
// Check permissions: admin/owner can delete any event, write users can only delete their own events
const canDelete =
userRole === "admin" ||
userRole === "owner" ||
(userRole === "write" &&
event.pubkey &&
event.pubkey === userPubkey);
if (!canDelete) {
alert("You do not have permission to delete this event");
return;
}
if (!confirm("Are you sure you want to delete this event?")) {
return;
}
try {
// Check if signer is available
if (!userSigner) {
throw new Error("Signer not available for signing");
}
// Create the delete event template (unsigned)
const deleteEventTemplate = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", eventId]], // e-tag referencing the event to delete
content: "",
// Don't set pubkey - let the signer set it
};
console.log("Created delete event template:", deleteEventTemplate);
console.log("User pubkey:", userPubkey);
console.log("Target event:", event);
console.log("Target event pubkey:", event.pubkey);
// Sign the event using the signer
const signedDeleteEvent =
await userSigner.signEvent(deleteEventTemplate);
console.log("Signed delete event:", signedDeleteEvent);
console.log(
"Signed delete event pubkey:",
signedDeleteEvent.pubkey,
);
console.log("Delete event tags:", signedDeleteEvent.tags);
// Publish to the ORLY relay using WebSocket authentication
const relayUrl = `wss://${window.location.host}`;
try {
const result = await publishEventWithAuth(
relayUrl,
signedDeleteEvent,
userSigner,
userPubkey,
);
if (result.success) {
console.log(
"Delete event published successfully to ORLY relay",
);
} else {
console.error(
"Failed to publish delete event:",
result.reason,
);
}
} catch (error) {
console.error("Error publishing delete event:", error);
}
// Determine if we should publish to external relays
// Only publish to external relays if:
// 1. User is deleting their own event, OR
// 2. User is admin/owner AND deleting their own event
const isDeletingOwnEvent =
event.pubkey && event.pubkey === userPubkey;
const isAdminOrOwner = userRole === "admin" || userRole === "owner";
const shouldPublishToExternalRelays = isDeletingOwnEvent;
if (shouldPublishToExternalRelays) {
// Publish the delete event to all relays (including external ones)
const result = await nostrClient.publish(signedDeleteEvent);
console.log("Delete event published:", result);
if (result.success && result.okCount > 0) {
// Wait a moment for the deletion to propagate
await new Promise((resolve) => setTimeout(resolve, 2000));
// Verify the event was actually deleted by trying to fetch it
try {
const deletedEvent = await fetchEventById(eventId, {
timeout: 5000,
});
if (deletedEvent) {
console.warn(
"Event still exists after deletion attempt:",
deletedEvent,
);
alert(
`Warning: Delete event was accepted by ${result.okCount} relay(s), but the event still exists on the relay. This may indicate the relay does not properly handle delete events.`,
);
} else {
console.log(
"Event successfully deleted and verified",
);
}
} catch (fetchError) {
console.log(
"Could not fetch event after deletion (likely deleted):",
fetchError.message,
);
}
// Also verify that the delete event has been saved
try {
const deleteEvents = await fetchDeleteEventsByTarget(
eventId,
{ timeout: 5000 },
);
if (deleteEvents.length > 0) {
console.log(
`Delete event verification: Found ${deleteEvents.length} delete event(s) targeting ${eventId}`,
);
// Check if our delete event is among them
const ourDeleteEvent = deleteEvents.find(
(de) => de.pubkey && de.pubkey === userPubkey,
);
if (ourDeleteEvent) {
console.log(
"Our delete event found in database:",
ourDeleteEvent.id,
);
} else {
console.warn(
"Our delete event not found in database, but other delete events exist",
);
}
} else {
console.warn(
"No delete events found in database for target event:",
eventId,
);
}
} catch (deleteFetchError) {
console.log(
"Could not verify delete event in database:",
deleteFetchError.message,
);
}
// Remove from local lists
allEvents = allEvents.filter(
(event) => event.id !== eventId,
);
myEvents = myEvents.filter((event) => event.id !== eventId);
// Remove from global cache
globalEventsCache = globalEventsCache.filter(
(event) => event.id !== eventId,
);
// Remove from search results cache
for (const [tabId, searchResult] of searchResults) {
if (searchResult.events) {
searchResult.events = searchResult.events.filter(
(event) => event.id !== eventId,
);
searchResults.set(tabId, searchResult);
}
}
// Update persistent state
savePersistentState();
// Reload events to show the new delete event at the top
console.log("Reloading events to show delete event...");
const authors =
showOnlyMyEvents && isLoggedIn && userPubkey
? [userPubkey]
: null;
await loadAllEvents(true, authors);
alert(
`Event deleted successfully (accepted by ${result.okCount} relay(s))`,
);
} else {
throw new Error("No relays accepted the delete event");
}
} else {
// Admin/owner deleting someone else's event - only publish to local relay
// We need to publish only to the local relay, not external ones
const localRelayUrl = `wss://${window.location.host}/`;
// Create a modified client that only connects to the local relay
const localClient = new NostrClient();
await localClient.connectToRelay(localRelayUrl);
const result = await localClient.publish(signedDeleteEvent);
console.log(
"Delete event published to local relay only:",
result,
);
if (result.success && result.okCount > 0) {
// Wait a moment for the deletion to propagate
await new Promise((resolve) => setTimeout(resolve, 2000));
// Verify the event was actually deleted by trying to fetch it
try {
const deletedEvent = await fetchEventById(eventId, {
timeout: 5000,
});
if (deletedEvent) {
console.warn(
"Event still exists after deletion attempt:",
deletedEvent,
);
alert(
`Warning: Delete event was accepted by ${result.okCount} relay(s), but the event still exists on the relay. This may indicate the relay does not properly handle delete events.`,
);
} else {
console.log(
"Event successfully deleted and verified",
);
}
} catch (fetchError) {
console.log(
"Could not fetch event after deletion (likely deleted):",
fetchError.message,
);
}
// Also verify that the delete event has been saved
try {
const deleteEvents = await fetchDeleteEventsByTarget(
eventId,
{ timeout: 5000 },
);
if (deleteEvents.length > 0) {
console.log(
`Delete event verification: Found ${deleteEvents.length} delete event(s) targeting ${eventId}`,
);
// Check if our delete event is among them
const ourDeleteEvent = deleteEvents.find(
(de) => de.pubkey && de.pubkey === userPubkey,
);
if (ourDeleteEvent) {
console.log(
"Our delete event found in database:",
ourDeleteEvent.id,
);
} else {
console.warn(
"Our delete event not found in database, but other delete events exist",
);
}
} else {
console.warn(
"No delete events found in database for target event:",
eventId,
);
}
} catch (deleteFetchError) {
console.log(
"Could not verify delete event in database:",
deleteFetchError.message,
);
}
// Remove from local lists
allEvents = allEvents.filter(
(event) => event.id !== eventId,
);
myEvents = myEvents.filter((event) => event.id !== eventId);
// Remove from global cache
globalEventsCache = globalEventsCache.filter(
(event) => event.id !== eventId,
);
// Remove from search results cache
for (const [tabId, searchResult] of searchResults) {
if (searchResult.events) {
searchResult.events = searchResult.events.filter(
(event) => event.id !== eventId,
);
searchResults.set(tabId, searchResult);
}
}
// Update persistent state
savePersistentState();
// Reload events to show the new delete event at the top
console.log("Reloading events to show delete event...");
const authors =
showOnlyMyEvents && isLoggedIn && userPubkey
? [userPubkey]
: null;
await loadAllEvents(true, authors);
alert(
`Event deleted successfully (local relay only - admin/owner deleting other user's event)`,
);
} else {
throw new Error(
"Local relay did not accept the delete event",
);
}
}
} catch (error) {
console.error("Failed to delete event:", error);
alert("Failed to delete event: " + error.message);
}
}
// Safely render "about" text: convert double newlines to a single HTML line break
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Recovery tab functions
async function loadRecoveryEvents() {
const kindToUse = recoveryCustomKind
? parseInt(recoveryCustomKind)
: recoverySelectedKind;
if (kindToUse === null || kindToUse === undefined || isNaN(kindToUse)) {
console.log("No valid kind to load, kindToUse:", kindToUse);
return;
}
if (!isLoggedIn) {
console.log("Not logged in, cannot load recovery events");
return;
}
console.log(
"Loading recovery events for kind:",
kindToUse,
"user:",
userPubkey,
);
isLoadingRecovery = true;
try {
// Fetch multiple versions using limit parameter
// For replaceable events, limit > 1 returns multiple versions
const filters = [
{
kinds: [kindToUse],
authors: [userPubkey],
limit: 100, // Get up to 100 versions
},
];
if (recoveryOldestTimestamp) {
filters[0].until = recoveryOldestTimestamp;
}
console.log("Recovery filters:", filters);
// Use queryEvents which checks IndexedDB cache first, then relay
const events = await queryEvents(filters, {
timeout: 30000,
cacheFirst: true, // Check cache first
});
console.log("Recovery events received:", events.length);
console.log(
"Recovery events kinds:",
events.map((e) => e.kind),
);
if (recoveryOldestTimestamp) {
// Append to existing events
recoveryEvents = [...recoveryEvents, ...events];
} else {
// Replace events
recoveryEvents = events;
}
if (events.length > 0) {
recoveryOldestTimestamp = Math.min(
...events.map((e) => e.created_at),
);
recoveryHasMore = events.length === 100;
} else {
recoveryHasMore = false;
}
} catch (error) {
console.error("Failed to load recovery events:", error);
} finally {
isLoadingRecovery = false;
}
}
// Get user's write relays from relay list event (kind 10002)
async function getUserWriteRelays() {
if (!userPubkey) {
return [];
}
try {
// Query for the user's relay list event (kind 10002)
const relayListEvents = await queryEventsFromDB([
{
kinds: [10002],
authors: [userPubkey],
limit: 1,
},
]);
if (relayListEvents.length === 0) {
console.log("No relay list event found for user");
return [];
}
const relayListEvent = relayListEvents[0];
console.log("Found relay list event:", relayListEvent);
const writeRelays = [];
// Parse r tags to extract write relays
for (const tag of relayListEvent.tags) {
if (tag[0] === "r" && tag.length >= 2) {
const relayUrl = tag[1];
const permission = tag.length >= 3 ? tag[2] : null;
// Include relay if it's explicitly marked for write or has no permission specified (default is read+write)
if (!permission || permission === "write") {
writeRelays.push(relayUrl);
}
}
}
console.log("Found write relays:", writeRelays);
return writeRelays;
} catch (error) {
console.error("Error fetching user write relays:", error);
return [];
}
}
async function repostEvent(event) {
if (!confirm("Are you sure you want to repost this event?")) {
return;
}
try {
const localRelayUrl = `wss://${window.location.host}/`;
console.log(
"Reposting event to local relay:",
localRelayUrl,
event,
);
// Create a new event with updated timestamp
const newEvent = { ...event };
newEvent.created_at = Math.floor(Date.now() / 1000);
newEvent.id = ""; // Clear the old ID so it gets recalculated
newEvent.sig = ""; // Clear the old signature
// For addressable events, ensure the d tag matches
if (event.kind >= 30000 && event.kind <= 39999) {
const dTag = event.tags.find((tag) => tag[0] === "d");
if (dTag) {
newEvent.tags = newEvent.tags.filter(
(tag) => tag[0] !== "d",
);
newEvent.tags.push(dTag);
}
}
// Sign the event before publishing
if (userSigner) {
const signedEvent = await userSigner.signEvent(newEvent);
console.log("Signed event for repost:", signedEvent);
const result = await nostrClient.publish(signedEvent, [
localRelayUrl,
]);
console.log("Repost publish result:", result);
if (result.success && result.okCount > 0) {
alert("Event reposted successfully!");
recoveryHasMore = false; // Reset to allow reloading
await loadRecoveryEvents(); // Reload the events to show the new version
} else {
alert("Failed to repost event. Check console for details.");
}
} else {
alert("No signer available. Please log in.");
}
} catch (error) {
console.error("Error reposting event:", error);
alert("Error reposting event: " + error.message);
}
}
async function repostEventToAll(event) {
if (
!confirm(
"Are you sure you want to repost this event to all your write relays?",
)
) {
return;
}
try {
// Get user's write relays
const writeRelays = await getUserWriteRelays();
const localRelayUrl = `wss://${window.location.host}/`;
// Always include local relay
const allRelays = [
localRelayUrl,
...writeRelays.filter((url) => url !== localRelayUrl),
];
if (allRelays.length === 1) {
alert(
"No write relays found in your relay list. Only posting to local relay.",
);
}
console.log("Reposting event to all relays:", allRelays, event);
// Create a new event with updated timestamp
const newEvent = { ...event };
newEvent.created_at = Math.floor(Date.now() / 1000);
newEvent.id = ""; // Clear the old ID so it gets recalculated
newEvent.sig = ""; // Clear the old signature
// For addressable events, ensure the d tag matches
if (event.kind >= 30000 && event.kind <= 39999) {
const dTag = event.tags.find((tag) => tag[0] === "d");
if (dTag) {
newEvent.tags = newEvent.tags.filter(
(tag) => tag[0] !== "d",
);
newEvent.tags.push(dTag);
}
}
// Sign the event before publishing
if (userSigner) {
const signedEvent = await userSigner.signEvent(newEvent);
console.log("Signed event for repost to all:", signedEvent);
const result = await nostrClient.publish(
signedEvent,
allRelays,
);
console.log("Repost to all publish result:", result);
if (result.success && result.okCount > 0) {
alert(
`Event reposted successfully to ${allRelays.length} relays!`,
);
recoveryHasMore = false; // Reset to allow reloading
await loadRecoveryEvents(); // Reload the events to show the new version
} else {
alert("Failed to repost event. Check console for details.");
}
} else {
alert("No signer available. Please log in.");
}
} catch (error) {
console.error("Error reposting event to all:", error);
alert("Error reposting event to all: " + error.message);
}
}
function selectRecoveryKind() {
console.log(
"selectRecoveryKind called, recoverySelectedKind:",
recoverySelectedKind,
);
if (
recoverySelectedKind === null ||
recoverySelectedKind === undefined
) {
console.log("No kind selected, skipping load");
return;
}
recoveryCustomKind = ""; // Clear custom kind when selecting from dropdown
recoveryEvents = [];
recoveryOldestTimestamp = null;
recoveryHasMore = true;
loadRecoveryEvents();
}
function handleCustomKindInput() {
console.log(
"handleCustomKindInput called, recoveryCustomKind:",
recoveryCustomKind,
);
// Check if a valid number was entered (including 0)
const kindNum = parseInt(recoveryCustomKind);
if (recoveryCustomKind !== "" && !isNaN(kindNum) && kindNum >= 0) {
recoverySelectedKind = null; // Clear dropdown selection when using custom
recoveryEvents = [];
recoveryOldestTimestamp = null;
recoveryHasMore = true;
loadRecoveryEvents();
}
}
function isCurrentVersion(event) {
// Find all events with the same kind and pubkey
const sameKindEvents = recoveryEvents.filter(
(e) => e.kind === event.kind && e.pubkey === event.pubkey,
);
// Check if this event has the highest timestamp
const maxTimestamp = Math.max(
...sameKindEvents.map((e) => e.created_at),
);
return event.created_at === maxTimestamp;
}
// Always show all versions - no filtering needed
$: aboutHtml = userProfile?.about
? escapeHtml(userProfile.about).replace(/\n{2,}/g, "<br>")
: "";
// Load theme preference from localStorage on component initialization
if (typeof localStorage !== "undefined") {
const savedTheme = localStorage.getItem("isDarkTheme");
if (savedTheme !== null) {
isDarkTheme = JSON.parse(savedTheme);
}
// Check for existing authentication
const storedAuthMethod = localStorage.getItem("nostr_auth_method");
const storedPubkey = localStorage.getItem("nostr_pubkey");
if (storedAuthMethod && storedPubkey) {
isLoggedIn = true;
userPubkey = storedPubkey;
authMethod = storedAuthMethod;
// Restore signer for extension method
if (storedAuthMethod === "extension" && window.nostr) {
userSigner = window.nostr;
}
// Fetch user role for already logged in users
fetchUserRole();
fetchACLMode();
}
// Load persistent app state
loadPersistentState();
// Load sprocket configuration
loadSprocketConfig();
// Load policy configuration
loadPolicyConfig();
}
function savePersistentState() {
if (typeof localStorage === "undefined") return;
const state = {
selectedTab,
expandedEvents: Array.from(expandedEvents),
globalEventsCache,
globalCacheTimestamp,
hasMoreEvents,
oldestEventTimestamp,
};
localStorage.setItem("app_state", JSON.stringify(state));
}
function loadPersistentState() {
if (typeof localStorage === "undefined") return;
try {
const savedState = localStorage.getItem("app_state");
if (savedState) {
const state = JSON.parse(savedState);
// Restore tab state
if (
state.selectedTab &&
baseTabs.some((tab) => tab.id === state.selectedTab)
) {
selectedTab = state.selectedTab;
}
// Restore expanded events
if (state.expandedEvents) {
expandedEvents = new Set(state.expandedEvents);
}
// Restore cache data
if (state.globalEventsCache) {
globalEventsCache = state.globalEventsCache;
}
if (state.globalCacheTimestamp) {
globalCacheTimestamp = state.globalCacheTimestamp;
}
if (state.hasMoreEvents !== undefined) {
hasMoreEvents = state.hasMoreEvents;
}
if (state.oldestEventTimestamp) {
oldestEventTimestamp = state.oldestEventTimestamp;
}
if (state.hasMoreMyEvents !== undefined) {
hasMoreMyEvents = state.hasMoreMyEvents;
}
if (state.oldestMyEventTimestamp) {
oldestMyEventTimestamp = state.oldestMyEventTimestamp;
}
// Restore events from cache
restoreEventsFromCache();
}
} catch (error) {
console.error("Failed to load persistent state:", error);
}
}
function restoreEventsFromCache() {
// Restore global events cache
if (
globalEventsCache.length > 0 &&
isCacheValid(globalCacheTimestamp)
) {
allEvents = globalEventsCache;
}
}
function isCacheValid(timestamp) {
if (!timestamp) return false;
return Date.now() - timestamp < CACHE_DURATION;
}
function updateGlobalCache(events) {
globalEventsCache = events.sort((a, b) => b.created_at - a.created_at);
globalCacheTimestamp = Date.now();
savePersistentState();
}
function clearCache() {
globalEventsCache = [];
globalCacheTimestamp = 0;
savePersistentState();
}
// Sprocket management functions
async function loadSprocketConfig() {
try {
const response = await fetch("/api/sprocket/config", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
const config = await response.json();
sprocketEnabled = config.enabled;
}
} catch (error) {
console.error("Error loading sprocket config:", error);
}
}
async function loadPolicyConfig() {
try {
const response = await fetch("/api/policy/config", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
const config = await response.json();
policyEnabled = config.enabled || false;
}
} catch (error) {
console.error("Error loading policy config:", error);
policyEnabled = false;
}
}
async function loadSprocketStatus() {
if (!isLoggedIn || userRole !== "owner" || !sprocketEnabled) return;
try {
isLoadingSprocket = true;
const response = await fetch("/api/sprocket/status", {
method: "GET",
headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/sprocket/status")}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
sprocketStatus = await response.json();
} else {
showSprocketMessage("Failed to load sprocket status", "error");
}
} catch (error) {
showSprocketMessage(
`Error loading sprocket status: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
}
}
async function loadSprocket() {
if (!isLoggedIn || userRole !== "owner") return;
try {
isLoadingSprocket = true;
const response = await fetch("/api/sprocket/status", {
method: "GET",
headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/sprocket/status")}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
const status = await response.json();
sprocketScript = status.script_content || "";
sprocketStatus = status;
showSprocketMessage("Script loaded successfully", "success");
} else {
showSprocketMessage("Failed to load script", "error");
}
} catch (error) {
showSprocketMessage(
`Error loading script: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
}
}
async function saveSprocket() {
if (!isLoggedIn || userRole !== "owner") return;
try {
isLoadingSprocket = true;
const response = await fetch("/api/sprocket/update", {
method: "POST",
headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/update")}`,
"Content-Type": "text/plain",
},
body: sprocketScript,
});
if (response.ok) {
showSprocketMessage(
"Script saved and updated successfully",
"success",
);
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(
`Failed to save script: ${errorText}`,
"error",
);
}
} catch (error) {
showSprocketMessage(
`Error saving script: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
}
}
async function restartSprocket() {
if (!isLoggedIn || userRole !== "owner") return;
try {
isLoadingSprocket = true;
const response = await fetch("/api/sprocket/restart", {
method: "POST",
headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/restart")}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
showSprocketMessage(
"Sprocket restarted successfully",
"success",
);
await loadSprocketStatus();
} else {
const errorText = await response.text();
showSprocketMessage(
`Failed to restart sprocket: ${errorText}`,
"error",
);
}
} catch (error) {
showSprocketMessage(
`Error restarting sprocket: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
}
}
async function deleteSprocket() {
if (!isLoggedIn || userRole !== "owner") return;
if (
!confirm(
"Are you sure you want to delete the sprocket script? This will stop the current process.",
)
) {
return;
}
try {
isLoadingSprocket = true;
const response = await fetch("/api/sprocket/update", {
method: "POST",
headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/update")}`,
"Content-Type": "text/plain",
},
body: "", // Empty body deletes the script
});
if (response.ok) {
sprocketScript = "";
showSprocketMessage(
"Sprocket script deleted successfully",
"success",
);
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(
`Failed to delete script: ${errorText}`,
"error",
);
}
} catch (error) {
showSprocketMessage(
`Error deleting script: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
}
}
async function loadVersions() {
if (!isLoggedIn || userRole !== "owner") return;
try {
isLoadingSprocket = true;
const response = await fetch("/api/sprocket/versions", {
method: "GET",
headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/sprocket/versions")}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
sprocketVersions = await response.json();
} else {
showSprocketMessage("Failed to load versions", "error");
}
} catch (error) {
showSprocketMessage(
`Error loading versions: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
}
}
async function loadVersion(version) {
if (!isLoggedIn || userRole !== "owner") return;
sprocketScript = version.content;
showSprocketMessage(`Loaded version: ${version.name}`, "success");
}
async function deleteVersion(filename) {
if (!isLoggedIn || userRole !== "owner") return;
if (!confirm(`Are you sure you want to delete version ${filename}?`)) {
return;
}
try {
isLoadingSprocket = true;
const response = await fetch("/api/sprocket/delete-version", {
method: "POST",
headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/delete-version")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ filename }),
});
if (response.ok) {
showSprocketMessage(
`Version ${filename} deleted successfully`,
"success",
);
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(
`Failed to delete version: ${errorText}`,
"error",
);
}
} catch (error) {
showSprocketMessage(
`Error deleting version: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
}
}
function showSprocketMessage(message, type = "info") {
sprocketMessage = message;
sprocketMessageType = type;
// Auto-hide message after 5 seconds
setTimeout(() => {
sprocketMessage = "";
}, 5000);
}
// Policy management functions
function showPolicyMessage(message, type = "info") {
policyMessage = message;
policyMessageType = type;
// Auto-hide message after 5 seconds for non-errors
if (type !== "error") {
setTimeout(() => {
policyMessage = "";
}, 5000);
}
}
async function loadPolicy() {
if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
try {
isLoadingPolicy = true;
policyValidationErrors = [];
// Query for the most recent kind 12345 event (policy config)
const filter = { kinds: [12345], limit: 1 };
const events = await queryEvents(filter);
if (events && events.length > 0) {
policyJson = events[0].content;
// Try to format it nicely
try {
policyJson = JSON.stringify(JSON.parse(policyJson), null, 2);
} catch (e) {
// Keep as-is if not valid JSON
}
showPolicyMessage("Policy loaded successfully", "success");
} else {
// No policy event found, try to load from file via API
const response = await fetch("/api/policy", {
method: "GET",
headers: {
Authorization: `Nostr ${await createNIP98Auth("GET", "/api/policy")}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
const data = await response.json();
policyJson = JSON.stringify(data, null, 2);
showPolicyMessage("Policy loaded from file", "success");
} else {
showPolicyMessage("No policy configuration found", "info");
policyJson = "";
}
}
} catch (error) {
showPolicyMessage(`Error loading policy: ${error.message}`, "error");
} finally {
isLoadingPolicy = false;
}
}
async function validatePolicy() {
policyValidationErrors = [];
if (!policyJson.trim()) {
policyValidationErrors = ["Policy JSON is empty"];
showPolicyMessage("Validation failed", "error");
return false;
}
try {
const parsed = JSON.parse(policyJson);
// Basic structure validation
if (typeof parsed !== "object" || parsed === null) {
policyValidationErrors = ["Policy must be a JSON object"];
showPolicyMessage("Validation failed", "error");
return false;
}
// Validate policy_admins if present
if (parsed.policy_admins) {
if (!Array.isArray(parsed.policy_admins)) {
policyValidationErrors.push("policy_admins must be an array");
} else {
for (const admin of parsed.policy_admins) {
if (typeof admin !== "string" || !/^[0-9a-fA-F]{64}$/.test(admin)) {
policyValidationErrors.push(`Invalid policy_admin pubkey: ${admin}`);
}
}
}
}
// Validate rules if present
if (parsed.rules) {
if (typeof parsed.rules !== "object") {
policyValidationErrors.push("rules must be an object");
} else {
for (const [kindStr, rule] of Object.entries(parsed.rules)) {
if (!/^\d+$/.test(kindStr)) {
policyValidationErrors.push(`Invalid kind number: ${kindStr}`);
}
if (rule.tag_validation && typeof rule.tag_validation === "object") {
for (const [tag, pattern] of Object.entries(rule.tag_validation)) {
try {
new RegExp(pattern);
} catch (e) {
policyValidationErrors.push(`Invalid regex for tag '${tag}': ${pattern}`);
}
}
}
}
}
}
// Validate default_policy if present
if (parsed.default_policy && !["allow", "deny"].includes(parsed.default_policy)) {
policyValidationErrors.push("default_policy must be 'allow' or 'deny'");
}
if (policyValidationErrors.length > 0) {
showPolicyMessage("Validation failed - see errors below", "error");
return false;
}
showPolicyMessage("Validation passed", "success");
return true;
} catch (error) {
policyValidationErrors = [`JSON parse error: ${error.message}`];
showPolicyMessage("Invalid JSON syntax", "error");
return false;
}
}
async function savePolicy() {
if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
// Validate first
const isValid = await validatePolicy();
if (!isValid) return;
try {
isLoadingPolicy = true;
// Create and publish kind 12345 event
const policyEvent = {
kind: 12345,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: policyJson,
};
// Sign and publish the event
const result = await publishEventWithAuth(policyEvent, userSigner);
if (result.success) {
showPolicyMessage("Policy updated successfully", "success");
} else {
showPolicyMessage(`Failed to publish policy: ${result.error || "Unknown error"}`, "error");
}
} catch (error) {
showPolicyMessage(`Error saving policy: ${error.message}`, "error");
} finally {
isLoadingPolicy = false;
}
}
function formatPolicyJson() {
try {
const parsed = JSON.parse(policyJson);
policyJson = JSON.stringify(parsed, null, 2);
showPolicyMessage("JSON formatted", "success");
} catch (error) {
showPolicyMessage(`Cannot format: ${error.message}`, "error");
}
}
// Convert npub to hex pubkey
function npubToHex(input) {
if (!input) return null;
// If already hex (64 characters)
if (/^[0-9a-fA-F]{64}$/.test(input)) {
return input.toLowerCase();
}
// If npub, decode it
if (input.startsWith("npub1")) {
try {
// Bech32 decode - simplified implementation
const ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const data = input.slice(5); // Remove "npub1" prefix
let bits = [];
for (const char of data) {
const value = ALPHABET.indexOf(char.toLowerCase());
if (value === -1) throw new Error("Invalid character in npub");
bits.push(...[...Array(5)].map((_, i) => (value >> (4 - i)) & 1));
}
// Remove checksum (last 30 bits = 6 characters * 5 bits)
bits = bits.slice(0, -30);
// Convert 5-bit groups to 8-bit bytes
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) {
byte = (byte << 1) | bits[i + j];
}
bytes.push(byte);
}
// Convert to hex
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (e) {
console.error("Failed to decode npub:", e);
return null;
}
}
return null;
}
function addPolicyAdmin(event) {
const input = event.detail;
if (!input) {
showPolicyMessage("Please enter a pubkey", "error");
return;
}
const hexPubkey = npubToHex(input);
if (!hexPubkey || hexPubkey.length !== 64) {
showPolicyMessage("Invalid pubkey format. Use hex (64 chars) or npub", "error");
return;
}
try {
const config = JSON.parse(policyJson || "{}");
if (!config.policy_admins) {
config.policy_admins = [];
}
if (config.policy_admins.includes(hexPubkey)) {
showPolicyMessage("Admin already in list", "warning");
return;
}
config.policy_admins.push(hexPubkey);
policyJson = JSON.stringify(config, null, 2);
showPolicyMessage("Admin added - click 'Save & Publish' to apply", "info");
} catch (error) {
showPolicyMessage(`Error adding admin: ${error.message}`, "error");
}
}
function removePolicyAdmin(event) {
const pubkey = event.detail;
try {
const config = JSON.parse(policyJson || "{}");
if (config.policy_admins) {
config.policy_admins = config.policy_admins.filter(p => p !== pubkey);
policyJson = JSON.stringify(config, null, 2);
showPolicyMessage("Admin removed - click 'Save & Publish' to apply", "info");
}
} catch (error) {
showPolicyMessage(`Error removing admin: ${error.message}`, "error");
}
}
async function refreshFollows() {
if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
try {
isLoadingPolicy = true;
policyFollows = [];
// Parse current policy to get admin list
let admins = [];
try {
const config = JSON.parse(policyJson || "{}");
admins = config.policy_admins || [];
} catch (e) {
showPolicyMessage("Cannot parse policy JSON to get admins", "error");
return;
}
if (admins.length === 0) {
showPolicyMessage("No policy admins configured", "warning");
return;
}
// Query kind 3 events from policy admins
const filter = {
kinds: [3],
authors: admins,
limit: admins.length
};
const events = await queryEvents(filter);
// Extract p-tags from all follow lists
const followsSet = new Set();
for (const event of events) {
if (event.tags) {
for (const tag of event.tags) {
if (tag[0] === 'p' && tag[1] && tag[1].length === 64) {
followsSet.add(tag[1]);
}
}
}
}
policyFollows = Array.from(followsSet);
showPolicyMessage(`Loaded ${policyFollows.length} follows from ${events.length} admin(s)`, "success");
} catch (error) {
showPolicyMessage(`Error loading follows: ${error.message}`, "error");
} finally {
isLoadingPolicy = false;
}
}
function handleSprocketFileSelect(event) {
sprocketUploadFile = event.target.files[0];
}
async function uploadSprocketScript() {
if (!isLoggedIn || userRole !== "owner" || !sprocketUploadFile) return;
try {
isLoadingSprocket = true;
// Read the file content
const fileContent = await sprocketUploadFile.text();
// Upload the script
const response = await fetch("/api/sprocket/update", {
method: "POST",
headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/update")}`,
"Content-Type": "text/plain",
},
body: fileContent,
});
if (response.ok) {
sprocketScript = fileContent;
showSprocketMessage(
"Script uploaded and updated successfully",
"success",
);
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(
`Failed to upload script: ${errorText}`,
"error",
);
}
} catch (error) {
showSprocketMessage(
`Error uploading script: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
sprocketUploadFile = null;
// Clear the file input
const fileInput = document.getElementById("sprocket-upload-file");
if (fileInput) {
fileInput.value = "";
}
}
}
const baseTabs = [
{ id: "export", icon: "📤", label: "Export" },
{ id: "import", icon: "💾", label: "Import", requiresAdmin: true },
{ id: "events", icon: "📡", label: "Events" },
{ id: "compose", icon: "✏", label: "Compose", requiresWrite: true },
{ id: "recovery", icon: "🔄", label: "Recovery" },
{
id: "managed-acl",
icon: "🛡",
label: "Managed ACL",
requiresOwner: true,
},
{ id: "sprocket", icon: "⚙", label: "Sprocket", requiresOwner: true },
{ id: "policy", icon: "📜", label: "Policy", requiresOwner: true },
];
// Filter tabs based on current effective role (including view-as setting)
$: filteredBaseTabs = baseTabs.filter((tab) => {
const currentRole = currentEffectiveRole;
if (
tab.requiresAdmin &&
(!isLoggedIn ||
(currentRole !== "admin" && currentRole !== "owner"))
) {
return false;
}
if (tab.requiresOwner && (!isLoggedIn || currentRole !== "owner")) {
return false;
}
if (tab.requiresWrite && (!isLoggedIn || currentRole === "read")) {
return false;
}
// Hide sprocket tab if not enabled
if (tab.id === "sprocket" && !sprocketEnabled) {
return false;
}
// Hide managed ACL tab if not in managed mode
if (tab.id === "managed-acl" && aclMode !== "managed") {
return false;
}
// Debug logging for tab filtering
console.log(`Tab ${tab.id} filter check:`, {
isLoggedIn,
userRole,
viewAsRole,
currentRole,
requiresAdmin: tab.requiresAdmin,
requiresOwner: tab.requiresOwner,
requiresWrite: tab.requiresWrite,
visible: true,
});
return true;
});
$: tabs = [...filteredBaseTabs, ...searchTabs];
// Debug logging for tabs
$: console.log("Tabs debug:", {
isLoggedIn,
userRole,
aclMode,
filteredBaseTabs: filteredBaseTabs.map((t) => t.id),
allTabs: tabs.map((t) => t.id),
});
function selectTab(tabId) {
selectedTab = tabId;
// Load sprocket data when switching to sprocket tab
if (
tabId === "sprocket" &&
isLoggedIn &&
userRole === "owner" &&
sprocketEnabled
) {
loadSprocketStatus();
loadVersions();
}
savePersistentState();
}
function toggleTheme() {
isDarkTheme = !isDarkTheme;
// Save theme preference to localStorage
if (typeof localStorage !== "undefined") {
localStorage.setItem("isDarkTheme", JSON.stringify(isDarkTheme));
}
}
function openLoginModal() {
if (!isLoggedIn) {
showLoginModal = true;
}
}
async function handleLogin(event) {
const { method, pubkey, privateKey, signer } = event.detail;
isLoggedIn = true;
userPubkey = pubkey;
authMethod = method;
userSigner = signer;
showLoginModal = false;
// Initialize Nostr client and fetch profile
try {
await initializeNostrClient();
// Set up NDK signer based on authentication method
if (method === "extension" && signer) {
// Extension signer (NIP-07 compatible)
nostrClient.setSigner(signer);
} else if (method === "nsec" && privateKey) {
// Private key signer for nsec
const keySigner = new PrivateKeySigner(privateKey);
nostrClient.setSigner(keySigner);
}
userProfile = await fetchUserProfile(pubkey);
console.log("Profile loaded:", userProfile);
} catch (error) {
console.error("Failed to load profile:", error);
}
// Fetch user role/permissions
await fetchUserRole();
await fetchACLMode();
}
function handleLogout() {
isLoggedIn = false;
userPubkey = "";
authMethod = "";
userProfile = null;
userRole = "";
userSigner = null;
showSettingsDrawer = false;
// Clear events
myEvents = [];
allEvents = [];
// Clear cache
clearCache();
// Clear stored authentication
if (typeof localStorage !== "undefined") {
localStorage.removeItem("nostr_auth_method");
localStorage.removeItem("nostr_pubkey");
localStorage.removeItem("nostr_privkey");
}
}
function closeLoginModal() {
showLoginModal = false;
}
function openSettingsDrawer() {
showSettingsDrawer = true;
}
function closeSettingsDrawer() {
showSettingsDrawer = false;
}
function toggleSearchMode() {
showFilterBuilder = !showFilterBuilder;
if (!showFilterBuilder) {
searchQuery = "";
}
}
function handleSearchKeydown(event) {
if (event.key === "Enter" && searchQuery.trim()) {
createSimpleSearchTab(searchQuery.trim());
searchQuery = "";
showFilterBuilder = false;
} else if (event.key === "Escape") {
showFilterBuilder = false;
searchQuery = "";
}
}
function createSimpleSearchTab(query) {
const filter = buildFilter({ searchText: query, limit: 100 });
createSearchTab(filter, `Search: ${query}`);
}
function createSearchTab(filter, label) {
const searchTabId = `search-${Date.now()}`;
const newSearchTab = {
id: searchTabId,
icon: "🔍",
label: label,
isSearchTab: true,
filter: filter,
};
searchTabs = [...searchTabs, newSearchTab];
selectedTab = searchTabId;
// Initialize search results for this tab
searchResults.set(searchTabId, {
filter: filter,
events: [],
isLoading: false,
hasMore: true,
oldestTimestamp: null,
});
// Start loading search results
loadSearchResults(searchTabId, true);
}
function handleFilterApply(event) {
const { searchText, selectedKinds, pubkeys, eventIds, tags, sinceTimestamp, untilTimestamp, limit } = event.detail;
const filter = buildFilter({
searchText,
kinds: selectedKinds,
authors: pubkeys,
ids: eventIds,
tags,
since: sinceTimestamp,
until: untilTimestamp,
limit: limit || 100,
});
let label = "Filter";
if (searchText) {
label = `Search: ${searchText.substring(0, 20)}${searchText.length > 20 ? '...' : ''}`;
} else if (selectedKinds.length > 0) {
label = `Kinds: ${selectedKinds.slice(0, 3).join(', ')}${selectedKinds.length > 3 ? '...' : ''}`;
} else if (pubkeys.length > 0) {
label = `Authors: ${pubkeys.length}`;
}
createSearchTab(filter, label);
showFilterBuilder = false;
}
function handleFilterClear() {
// Just close the filter builder
showFilterBuilder = false;
}
function handleFilterSweep(searchTabId) {
// Close the search tab
closeSearchTab(searchTabId);
}
function closeSearchTab(tabId) {
searchTabs = searchTabs.filter((tab) => tab.id !== tabId);
searchResults.delete(tabId); // Clean up search results
if (selectedTab === tabId) {
selectedTab = "export"; // Fall back to export tab
}
}
async function loadSearchResults(searchTabId, reset = true) {
const searchResult = searchResults.get(searchTabId);
if (!searchResult || searchResult.isLoading) return;
// Update loading state
searchResult.isLoading = true;
searchResults.set(searchTabId, searchResult);
try {
const filter = { ...searchResult.filter };
// Apply timestamp-based pagination
if (!reset && searchResult.oldestTimestamp) {
filter.until = searchResult.oldestTimestamp;
}
// Override limit for pagination
if (!reset) {
filter.limit = 200;
}
console.log(
"Loading search results with filter:",
filter,
);
// Use fetchEvents with the filter array
const events = await fetchEvents([filter], { timeout: 30000 });
console.log("Received search results:", events.length, "events");
if (reset) {
searchResult.events = events.sort(
(a, b) => b.created_at - a.created_at,
);
} else {
searchResult.events = [...searchResult.events, ...events].sort(
(a, b) => b.created_at - a.created_at,
);
}
// Update oldest timestamp for next pagination
if (events.length > 0) {
const oldestInBatch = Math.min(
...events.map((e) => e.created_at),
);
if (
!searchResult.oldestTimestamp ||
oldestInBatch < searchResult.oldestTimestamp
) {
searchResult.oldestTimestamp = oldestInBatch;
}
}
searchResult.hasMore = events.length === (reset ? filter.limit || 100 : 200);
searchResult.isLoading = false;
searchResults.set(searchTabId, searchResult);
} catch (error) {
console.error("Failed to load search results:", error);
searchResult.isLoading = false;
searchResults.set(searchTabId, searchResult);
alert("Failed to load search results: " + error.message);
}
}
async function loadMoreSearchResults(searchTabId) {
await loadSearchResults(searchTabId, false);
}
function handleSearchScroll(event, searchTabId) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
const threshold = 100; // Load more when 100px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
const searchResult = searchResults.get(searchTabId);
if (
searchResult &&
!searchResult.isLoading &&
searchResult.hasMore
) {
loadMoreSearchResults(searchTabId);
}
}
}
$: if (typeof document !== "undefined") {
if (isDarkTheme) {
document.body.classList.add("dark-theme");
} else {
document.body.classList.remove("dark-theme");
}
}
// Auto-fetch profile when user is logged in but profile is missing
$: if (isLoggedIn && userPubkey && !userProfile) {
fetchProfileIfMissing();
}
async function fetchProfileIfMissing() {
if (!isLoggedIn || !userPubkey || userProfile) {
return; // Don't fetch if not logged in, no pubkey, or profile already exists
}
try {
console.log("Auto-fetching profile for:", userPubkey);
await initializeNostrClient();
userProfile = await fetchUserProfile(userPubkey);
console.log("Profile auto-loaded:", userProfile);
} catch (error) {
console.error("Failed to auto-load profile:", error);
}
}
async function fetchUserRole() {
if (!isLoggedIn || !userPubkey) {
userRole = "";
return;
}
try {
const response = await fetch(`/api/permissions/${userPubkey}`);
if (response.ok) {
const data = await response.json();
userRole = data.permission || "";
console.log("User role loaded:", userRole);
console.log("Is owner?", userRole === "owner");
} else {
console.error("Failed to fetch user role:", response.status);
userRole = "";
}
} catch (error) {
console.error("Error fetching user role:", error);
userRole = "";
}
}
async function fetchACLMode() {
try {
const response = await fetch("/api/acl-mode");
if (response.ok) {
const data = await response.json();
aclMode = data.acl_mode || "";
console.log("ACL mode loaded:", aclMode);
} else {
console.error("Failed to fetch ACL mode:", response.status);
aclMode = "";
}
} catch (error) {
console.error("Error fetching ACL mode:", error);
aclMode = "";
}
}
// Export functionality
async function exportEvents(pubkeys = []) {
if (!isLoggedIn) {
alert("Please log in first");
return;
}
// Check permissions for exporting all events using current effective role
if (
pubkeys.length === 0 &&
currentEffectiveRole !== "admin" &&
currentEffectiveRole !== "owner"
) {
alert("Admin or owner permission required to export all events");
return;
}
try {
const authHeader = await createNIP98AuthHeader(
"/api/export",
"POST",
);
const response = await fetch("/api/export", {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify({ pubkeys }),
});
if (!response.ok) {
throw new Error(
`Export failed: ${response.status} ${response.statusText}`,
);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Get filename from response headers or use default
const contentDisposition = response.headers.get(
"Content-Disposition",
);
let filename = "events.jsonl";
if (contentDisposition) {
const filenameMatch =
contentDisposition.match(/filename="([^"]+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Export failed:", error);
alert("Export failed: " + error.message);
}
}
async function exportAllEvents() {
await exportEvents([]); // Empty array means export all events
}
async function exportMyEvents() {
await exportEvents([userPubkey]); // Export only current user's events
}
// Import functionality
function handleFileSelect(event) {
selectedFile = event.target.files[0];
}
async function importEvents() {
if (!isLoggedIn || (userRole !== "admin" && userRole !== "owner")) {
alert("Admin or owner permission required");
return;
}
if (!selectedFile) {
alert("Please select a file");
return;
}
try {
const authHeader = await createNIP98AuthHeader(
"/api/import",
"POST",
);
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch("/api/import", {
method: "POST",
headers: {
Authorization: authHeader,
},
body: formData,
});
if (!response.ok) {
throw new Error(
`Import failed: ${response.status} ${response.statusText}`,
);
}
const result = await response.json();
alert("Import started successfully");
selectedFile = null;
document.getElementById("import-file").value = "";
} catch (error) {
console.error("Import failed:", error);
alert("Import failed: " + error.message);
}
}
// Events loading functionality
async function loadMyEvents(reset = false) {
if (!isLoggedIn) {
alert("Please log in first");
return;
}
if (isLoadingMyEvents) return;
// Always load fresh data when feed becomes visible (reset = true)
// Skip cache check to ensure fresh data every time
isLoadingMyEvents = true;
// Reset timestamps when doing a fresh load
if (reset) {
oldestMyEventTimestamp = null;
newestMyEventTimestamp = null;
}
try {
// Use WebSocket REQ to fetch user events with timestamp-based pagination
// Load 1000 events on initial load, otherwise use 200 for pagination
const events = await fetchUserEvents(userPubkey, {
limit: reset ? 1000 : 200,
until: reset ? null : oldestMyEventTimestamp,
});
if (reset) {
myEvents = events.sort((a, b) => b.created_at - a.created_at);
} else {
myEvents = [...myEvents, ...events].sort(
(a, b) => b.created_at - a.created_at,
);
}
// Update oldest timestamp for next pagination
if (events.length > 0) {
const oldestInBatch = Math.min(
...events.map((e) => e.created_at),
);
if (
!oldestMyEventTimestamp ||
oldestInBatch < oldestMyEventTimestamp
) {
oldestMyEventTimestamp = oldestInBatch;
}
}
hasMoreMyEvents = events.length === (reset ? 1000 : 200);
// Auto-load more events if content doesn't fill viewport and more events are available
// Only do this on initial load (reset = true) to avoid interfering with scroll-based loading
if (reset && hasMoreMyEvents) {
setTimeout(() => {
// Only check viewport if we're currently on the My Events tab
if (selectedTab === "myevents") {
const eventsContainers = document.querySelectorAll(
".events-view-content",
);
// The My Events container should be the first one (before All Events)
const myEventsContainer = eventsContainers[0];
if (
myEventsContainer &&
myEventsContainer.scrollHeight <=
myEventsContainer.clientHeight
) {
// Content doesn't fill viewport, load more automatically
loadMoreMyEvents();
}
}
}, 100); // Small delay to ensure DOM is updated
}
} catch (error) {
console.error("Failed to load events:", error);
alert("Failed to load events: " + error.message);
} finally {
isLoadingMyEvents = false;
}
}
async function loadMoreMyEvents() {
if (!isLoadingMyEvents && hasMoreMyEvents) {
await loadMyEvents(false);
}
}
function handleMyEventsScroll(event) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
const threshold = 100; // Load more when 100px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMoreMyEvents();
}
}
async function loadAllEvents(reset = false, authors = null) {
if (
!isLoggedIn ||
(userRole !== "read" &&
userRole !== "write" &&
userRole !== "admin" &&
userRole !== "owner")
) {
alert("Read, write, admin, or owner permission required");
return;
}
if (isLoadingEvents) return;
// Always load fresh data when feed becomes visible (reset = true)
// Skip cache check to ensure fresh data every time
isLoadingEvents = true;
// Reset timestamps when doing a fresh load
if (reset) {
oldestEventTimestamp = null;
newestEventTimestamp = null;
}
try {
// Use Nostr WebSocket to fetch events with timestamp-based pagination
// Load 100 events on initial load, otherwise use 200 for pagination
console.log(
"Loading events with authors filter:",
authors,
"including delete events",
);
// For reset, use current timestamp to get the most recent events
const untilTimestamp = reset
? Math.floor(Date.now() / 1000)
: oldestEventTimestamp;
const events = await fetchAllEvents({
limit: reset ? 100 : 200,
until: untilTimestamp,
authors: authors,
});
console.log("Received events:", events.length, "events");
if (authors && events.length > 0) {
const nonUserEvents = events.filter(
(event) => event.pubkey && event.pubkey !== userPubkey,
);
if (nonUserEvents.length > 0) {
console.warn(
"Server returned non-user events:",
nonUserEvents.length,
"out of",
events.length,
);
}
}
if (reset) {
allEvents = events.sort((a, b) => b.created_at - a.created_at);
// Update global cache
updateGlobalCache(events);
} else {
allEvents = [...allEvents, ...events].sort(
(a, b) => b.created_at - a.created_at,
);
// Update global cache with all events
updateGlobalCache(allEvents);
}
// Update oldest timestamp for next pagination
if (events.length > 0) {
const oldestInBatch = Math.min(
...events.map((e) => e.created_at),
);
if (
!oldestEventTimestamp ||
oldestInBatch < oldestEventTimestamp
) {
oldestEventTimestamp = oldestInBatch;
}
}
hasMoreEvents = events.length === (reset ? 1000 : 200);
// Auto-load more events if content doesn't fill viewport and more events are available
// Only do this on initial load (reset = true) to avoid interfering with scroll-based loading
if (reset && hasMoreEvents) {
setTimeout(() => {
// Only check viewport if we're currently on the All Events tab
if (selectedTab === "events") {
const eventsContainers = document.querySelectorAll(
".events-view-content",
);
// The All Events container should be the first one (only container now)
const allEventsContainer = eventsContainers[0];
if (
allEventsContainer &&
allEventsContainer.scrollHeight <=
allEventsContainer.clientHeight
) {
// Content doesn't fill viewport, load more automatically
loadMoreEvents();
}
}
}, 100); // Small delay to ensure DOM is updated
}
} catch (error) {
console.error("Failed to load events:", error);
alert("Failed to load events: " + error.message);
} finally {
isLoadingEvents = false;
}
}
async function loadMoreEvents() {
await loadAllEvents(false);
}
function handleScroll(event) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
const threshold = 100; // Load more when 100px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMoreEvents();
}
}
// Load events when events tab is selected (only if no events loaded yet)
// Track if we've already attempted to load events to prevent infinite loops
let hasAttemptedEventLoad = false;
$: if (
selectedTab === "events" &&
isLoggedIn &&
(userRole === "read" ||
userRole === "write" ||
userRole === "admin" ||
userRole === "owner") &&
allEvents.length === 0 &&
!hasAttemptedEventLoad &&
!isLoadingEvents
) {
hasAttemptedEventLoad = true;
const authors = showOnlyMyEvents && userPubkey ? [userPubkey] : null;
loadAllEvents(true, authors);
}
// Reset the attempt flag when switching tabs or when showOnlyMyEvents changes
$: if (
selectedTab !== "events" ||
(selectedTab === "events" && allEvents.length > 0)
) {
hasAttemptedEventLoad = false;
}
// NIP-98 authentication helper
async function createNIP98AuthHeader(url, method) {
if (!isLoggedIn || !userPubkey) {
throw new Error("Not logged in");
}
// Create NIP-98 auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", window.location.origin + url],
["method", method.toUpperCase()],
],
content: "",
pubkey: userPubkey,
};
let signedEvent;
if (userSigner && authMethod === "extension") {
// Use the signer from the extension
try {
signedEvent = await userSigner.signEvent(authEvent);
} catch (error) {
throw new Error(
"Failed to sign with extension: " + error.message,
);
}
} else if (authMethod === "nsec") {
// For nsec method, we need to implement proper signing
// For now, create a mock signature (in production, use proper crypto)
authEvent.id = "mock-id-" + Date.now();
authEvent.sig = "mock-signature-" + Date.now();
signedEvent = authEvent;
} else {
throw new Error("No valid signer available");
}
// Encode as base64
const eventJson = JSON.stringify(signedEvent);
const base64Event = btoa(eventJson);
return `Nostr ${base64Event}`;
}
// NIP-98 authentication helper (for sprocket functions)
async function createNIP98Auth(method, url) {
if (!isLoggedIn || !userPubkey) {
throw new Error("Not logged in");
}
// Create NIP-98 auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", window.location.origin + url],
["method", method.toUpperCase()],
],
content: "",
pubkey: userPubkey,
};
let signedEvent;
if (userSigner && authMethod === "extension") {
// Use the signer from the extension
try {
signedEvent = await userSigner.signEvent(authEvent);
} catch (error) {
throw new Error(
"Failed to sign with extension: " + error.message,
);
}
} else if (authMethod === "nsec") {
// For nsec method, we need to implement proper signing
// For now, create a mock signature (in production, use proper crypto)
authEvent.id = "mock-id-" + Date.now();
authEvent.sig = "mock-signature-" + Date.now();
signedEvent = authEvent;
} else {
throw new Error("No valid signer available");
}
// Encode as base64
const eventJson = JSON.stringify(signedEvent);
const base64Event = btoa(eventJson);
return base64Event;
}
// Compose tab functions
function reformatJson() {
try {
if (!composeEventJson.trim()) {
alert("Please enter some JSON to reformat");
return;
}
const parsed = JSON.parse(composeEventJson);
composeEventJson = JSON.stringify(parsed, null, 2);
} catch (error) {
alert("Invalid JSON: " + error.message);
}
}
async function signEvent() {
try {
if (!composeEventJson.trim()) {
alert("Please enter an event to sign");
return;
}
if (!isLoggedIn || !userPubkey) {
alert("Please log in to sign events");
return;
}
if (!userSigner) {
alert(
"No signer available. Please log in with a valid authentication method.",
);
return;
}
const event = JSON.parse(composeEventJson);
// Update event with current user's pubkey and timestamp
event.pubkey = userPubkey;
event.created_at = Math.floor(Date.now() / 1000);
// Remove any existing id and sig to ensure fresh signing
delete event.id;
delete event.sig;
// Sign the event using the real signer
const signedEvent = await userSigner.signEvent(event);
// Update the compose area with the signed event
composeEventJson = JSON.stringify(signedEvent, null, 2);
// Show success feedback
alert("Event signed successfully!");
} catch (error) {
console.error("Error signing event:", error);
alert("Error signing event: " + error.message);
}
}
async function publishEvent() {
try {
if (!composeEventJson.trim()) {
alert("Please enter an event to publish");
return;
}
if (!isLoggedIn) {
alert("Please log in to publish events");
return;
}
if (!userSigner) {
alert(
"No signer available. Please log in with a valid authentication method.",
);
return;
}
const event = JSON.parse(composeEventJson);
// Validate that the event has required fields
if (!event.id || !event.sig) {
alert(
'Event must be signed before publishing. Please click "Sign" first.',
);
return;
}
// Publish to the ORLY relay using WebSocket (same address as current page)
const relayUrl = `wss://${window.location.host}`;
// Use the authentication module to publish the event
const result = await publishEventWithAuth(
relayUrl,
event,
userSigner,
userPubkey,
);
if (result.success) {
alert("Event published successfully to ORLY relay!");
// Optionally clear the editor after successful publish
// composeEventJson = '';
} else {
alert(
`Event publishing failed: ${result.reason || "Unknown error"}`,
);
}
} catch (error) {
console.error("Error publishing event:", error);
alert("Error publishing event: " + error.message);
}
}
// Persist selected tab to local storage
$: {
localStorage.setItem("selectedTab", selectedTab);
}
// Handle permission view switching
function setViewAsRole(role) {
viewAsRole = role;
localStorage.setItem("viewAsRole", role);
console.log(
"View as role changed to:",
role,
"Current effective role:",
currentEffectiveRole,
);
}
// Reactive statement for current effective role
$: currentEffectiveRole =
viewAsRole && viewAsRole !== "" ? viewAsRole : userRole;
// Initialize viewAsRole from local storage if available
viewAsRole = localStorage.getItem("viewAsRole") || "";
// Get available roles based on user's actual role
function getAvailableRoles() {
const allRoles = ["owner", "admin", "write", "read"];
const userRoleIndex = allRoles.indexOf(userRole);
if (userRoleIndex === -1) return ["read"]; // Default to read if role not found
return allRoles.slice(userRoleIndex); // Return current role and all lower roles
}
</script>
<!-- Header -->
<Header
{isDarkTheme}
isSearchMode={showFilterBuilder}
bind:searchQuery
{isLoggedIn}
{userRole}
{currentEffectiveRole}
{userProfile}
{userPubkey}
on:searchKeydown={handleSearchKeydown}
on:toggleSearchMode={toggleSearchMode}
on:toggleTheme={toggleTheme}
on:openSettingsDrawer={openSettingsDrawer}
on:openLoginModal={openLoginModal}
/>
<!-- FilterBuilder - shown when search button is clicked -->
{#if showFilterBuilder}
<div class="filter-builder-overlay">
<div class="filter-builder-container">
<FilterBuilder
on:apply={handleFilterApply}
on:clear={handleFilterClear}
/>
</div>
</div>
{/if}
<!-- Main Content Area -->
<div class="app-container" class:dark-theme={isDarkTheme}>
<!-- Sidebar -->
<Sidebar
{isDarkTheme}
{tabs}
{selectedTab}
on:selectTab={(e) => selectTab(e.detail)}
on:closeSearchTab={(e) => closeSearchTab(e.detail)}
/>
<!-- Main Content -->
<main class="main-content">
{#if selectedTab === "export"}
<ExportView
{isLoggedIn}
{currentEffectiveRole}
on:exportMyEvents={exportMyEvents}
on:exportAllEvents={exportAllEvents}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "import"}
<ImportView
{isLoggedIn}
{currentEffectiveRole}
{selectedFile}
on:fileSelect={handleFileSelect}
on:importEvents={importEvents}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "events"}
<EventsView
{isLoggedIn}
{userRole}
{userPubkey}
{filteredEvents}
{expandedEvents}
{isLoadingEvents}
{showOnlyMyEvents}
on:scroll={handleScroll}
on:toggleEventExpansion={(e) => toggleEventExpansion(e.detail)}
on:deleteEvent={(e) => deleteEvent(e.detail)}
on:copyEventToClipboard={(e) =>
copyEventToClipboard(e.detail.event, e.detail.e)}
on:toggleChange={handleToggleChange}
on:loadAllEvents={(e) =>
loadAllEvents(e.detail.refresh, e.detail.authors)}
/>
{:else if selectedTab === "compose"}
<ComposeView
bind:composeEventJson
on:reformatJson={reformatJson}
on:signEvent={signEvent}
on:publishEvent={publishEvent}
/>
{:else if selectedTab === "managed-acl"}
<div class="managed-acl-view">
{#if aclMode !== "managed"}
<div class="acl-mode-warning">
<h3> Managed ACL Mode Not Active</h3>
<p>
To use the Managed ACL interface, you need to set
the ACL mode to "managed" in your relay
configuration.
</p>
<p>
Current ACL mode: <strong
>{aclMode || "unknown"}</strong
>
</p>
<p>
Please set <code>ORLY_ACL_MODE=managed</code> in your
environment variables and restart the relay.
</p>
</div>
{:else if isLoggedIn && userRole === "owner"}
<ManagedACL {userSigner} {userPubkey} />
{:else}
<div class="access-denied">
<p>
Please log in with owner permissions to access
managed ACL configuration.
</p>
<button class="login-btn" on:click={openLoginModal}
>Log In</button
>
</div>
{/if}
</div>
{:else if selectedTab === "sprocket"}
<SprocketView
{isLoggedIn}
{userRole}
{sprocketStatus}
{isLoadingSprocket}
{sprocketUploadFile}
bind:sprocketScript
{sprocketMessage}
{sprocketMessageType}
{sprocketVersions}
on:restartSprocket={restartSprocket}
on:deleteSprocket={deleteSprocket}
on:sprocketFileSelect={handleSprocketFileSelect}
on:uploadSprocketScript={uploadSprocketScript}
on:saveSprocket={saveSprocket}
on:loadSprocket={loadSprocket}
on:loadVersions={loadVersions}
on:loadVersion={(e) => loadVersion(e.detail)}
on:deleteVersion={(e) => deleteVersion(e.detail)}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "policy"}
<PolicyView
{isLoggedIn}
{userRole}
{isPolicyAdmin}
{policyEnabled}
bind:policyJson
{isLoadingPolicy}
{policyMessage}
{policyMessageType}
validationErrors={policyValidationErrors}
{policyFollows}
on:loadPolicy={loadPolicy}
on:validatePolicy={validatePolicy}
on:savePolicy={savePolicy}
on:formatJson={formatPolicyJson}
on:addPolicyAdmin={addPolicyAdmin}
on:removePolicyAdmin={removePolicyAdmin}
on:refreshFollows={refreshFollows}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "recovery"}
<div class="recovery-tab">
<div>
<h3>Event Recovery</h3>
<p>Search and recover old versions of replaceable events</p>
</div>
<div class="recovery-controls-card">
<div class="recovery-controls">
<div class="kind-selector">
<label for="recovery-kind">Select Event Kind:</label
>
<select
id="recovery-kind"
bind:value={recoverySelectedKind}
on:change={selectRecoveryKind}
>
<option value={null}
>Choose a replaceable kind...</option
>
{#each replaceableKinds as kind}
<option value={kind.value}
>{kind.label}</option
>
{/each}
</select>
</div>
<div class="custom-kind-input">
<label for="custom-kind"
>Or enter custom kind number:</label
>
<input
id="custom-kind"
type="number"
bind:value={recoveryCustomKind}
on:input={handleCustomKindInput}
placeholder="e.g., 10001"
min="0"
/>
</div>
</div>
</div>
{#if (recoverySelectedKind !== null && recoverySelectedKind !== undefined && recoverySelectedKind >= 0) || (recoveryCustomKind !== "" && parseInt(recoveryCustomKind) >= 0)}
<div class="recovery-results">
{#if isLoadingRecovery}
<div class="loading">Loading events...</div>
{:else if recoveryEvents.length === 0}
<div class="no-events">
No events found for this kind
</div>
{:else}
<div class="events-list">
{#each recoveryEvents as event}
{@const isCurrent = isCurrentVersion(event)}
<div
class="event-item"
class:old-version={!isCurrent}
>
<div class="event-header">
<div class="event-header-left">
<span class="event-kind">
{#if isCurrent}
Current Version{/if}</span
>
<span class="event-timestamp">
{new Date(
event.created_at * 1000,
).toLocaleString()}
</span>
</div>
<div class="event-header-actions">
{#if !isCurrent}
<button
class="repost-all-button"
on:click={() =>
repostEventToAll(
event,
)}
>
🌐 Repost to All
</button>
{#if currentEffectiveRole !== "read"}
<button
class="repost-button"
on:click={() =>
repostEvent(
event,
)}
>
🔄 Repost
</button>
{/if}
{/if}
<button
class="copy-json-btn"
on:click|stopPropagation={(
e,
) =>
copyEventToClipboard(
event,
e,
)}
>
📋 Copy JSON
</button>
</div>
</div>
<div class="event-content">
<pre
class="event-json">{JSON.stringify(
event,
null,
2,
)}</pre>
</div>
</div>
{/each}
</div>
{#if recoveryHasMore}
<button
class="load-more"
on:click={loadRecoveryEvents}
disabled={isLoadingRecovery}
>
Load More Events
</button>
{/if}
{/if}
</div>
{/if}
</div>
{:else if searchTabs.some((tab) => tab.id === selectedTab)}
{#each searchTabs as searchTab}
{#if searchTab.id === selectedTab}
<div class="search-results-view">
<div class="search-results-header">
<h2>🔍 {searchTab.label}</h2>
<button
class="refresh-btn"
on:click={() =>
loadSearchResults(
searchTab.id,
true,
)}
disabled={searchResults.get(searchTab.id)
?.isLoading}
>
🔄 Refresh
</button>
</div>
<!-- FilterDisplay - show active filter -->
<FilterDisplay
filter={searchResults.get(searchTab.id)?.filter || {}}
on:sweep={() => handleFilterSweep(searchTab.id)}
/>
<div
class="search-results-content"
on:scroll={(e) =>
handleSearchScroll(e, searchTab.id)}
>
{#if searchResults.get(searchTab.id)?.events?.length > 0}
{#each searchResults.get(searchTab.id).events as event}
<div
class="search-result-item"
class:expanded={expandedEvents.has(
event.id,
)}
>
<div
class="search-result-row"
on:click={() =>
toggleEventExpansion(event.id)}
on:keydown={(e) =>
e.key === "Enter" &&
toggleEventExpansion(event.id)}
role="button"
tabindex="0"
>
<div class="search-result-avatar">
<div class="avatar-placeholder">
👤
</div>
</div>
<div class="search-result-info">
<div
class="search-result-author"
>
{truncatePubkey(
event.pubkey,
)}
</div>
<div class="search-result-kind">
<span class="kind-number"
>{event.kind}</span
>
<span class="kind-name"
>{getKindName(
event.kind,
)}</span
>
</div>
</div>
<div class="search-result-content">
<div class="event-timestamp">
{formatTimestamp(
event.created_at,
)}
</div>
<div
class="event-content-single-line"
>
{truncateContent(
event.content,
)}
</div>
</div>
{#if event.kind !== 5 && (userRole === "admin" || userRole === "owner" || (userRole === "write" && event.pubkey && event.pubkey === userPubkey))}
<button
class="delete-btn"
on:click|stopPropagation={() =>
deleteEvent(event.id)}
>
🗑
</button>
{/if}
</div>
{#if expandedEvents.has(event.id)}
<div class="search-result-details">
<div class="json-container">
<pre
class="event-json">{JSON.stringify(
event,
null,
2,
)}</pre>
<button
class="copy-json-btn"
on:click|stopPropagation={(
e,
) =>
copyEventToClipboard(
event,
e,
)}
title="Copy minified JSON to clipboard"
>
📋
</button>
</div>
</div>
{/if}
</div>
{/each}
{:else if !searchResults.get(searchTab.id)?.isLoading}
<div class="no-search-results">
<p>
No search results found.
</p>
</div>
{/if}
{#if searchResults.get(searchTab.id)?.isLoading}
<div class="loading-search-results">
<div class="loading-spinner"></div>
<p>Searching...</p>
</div>
{/if}
{#if !searchResults.get(searchTab.id)?.hasMore && searchResults.get(searchTab.id)?.events?.length > 0}
<div class="end-of-search-results">
<p>No more search results to load.</p>
</div>
{/if}
</div>
</div>
{/if}
{/each}
{:else}
<div class="welcome-message">
{#if isLoggedIn}
<p>
Welcome {userProfile?.name ||
userPubkey.slice(0, 8) + "..."}
</p>
{:else}
<p>Log in to access your user dashboard</p>
{/if}
</div>
{/if}
</main>
</div>
<!-- Settings Drawer -->
{#if showSettingsDrawer}
<div
class="drawer-overlay"
on:click={closeSettingsDrawer}
on:keydown={(e) => e.key === "Escape" && closeSettingsDrawer()}
role="button"
tabindex="0"
>
<div
class="settings-drawer"
class:dark-theme={isDarkTheme}
on:click|stopPropagation
on:keydown|stopPropagation
>
<div class="drawer-header">
<h2>Settings</h2>
<button class="close-btn" on:click={closeSettingsDrawer}
>✕</button
>
</div>
<div class="drawer-content">
{#if userProfile}
<div class="profile-section">
<div class="profile-hero">
{#if userProfile.banner}
<img
src={userProfile.banner}
alt="Profile banner"
class="profile-banner"
/>
{/if}
<!-- Logout button floating in top-right corner of banner -->
<button
class="logout-btn floating"
on:click={handleLogout}>Log out</button
>
<!-- Avatar overlaps the bottom edge of the banner by 50% -->
{#if userProfile.picture}
<img
src={userProfile.picture}
alt="User avatar"
class="profile-avatar overlap"
/>
{:else}
<div class="profile-avatar-placeholder overlap">
👤
</div>
{/if}
<!-- Username and nip05 to the right of the avatar, above the bottom edge -->
<div class="name-row">
<h3 class="profile-username">
{userProfile.name || "Unknown User"}
</h3>
{#if userProfile.nip05}
<span class="profile-nip05-inline"
>{userProfile.nip05}</span
>
{/if}
</div>
</div>
<!-- About text in a box underneath, with avatar overlapping its top edge -->
{#if userProfile.about}
<div class="about-card">
<p class="profile-about">{@html aboutHtml}</p>
</div>
{/if}
</div>
<!-- View as section -->
{#if userRole && userRole !== "read"}
<div class="view-as-section">
<h3>View as Role</h3>
<p>
See the interface as it appears for different
permission levels:
</p>
<div class="radio-group">
{#each getAvailableRoles() as role}
<label class="radio-label">
<input
type="radio"
name="viewAsRole"
value={role}
checked={currentEffectiveRole ===
role}
on:change={() =>
setViewAsRole(
role === userRole
? ""
: role,
)}
/>
{role.charAt(0).toUpperCase() +
role.slice(1)}{role === userRole
? " (Default)"
: ""}
</label>
{/each}
</div>
</div>
{/if}
{:else if isLoggedIn && userPubkey}
<div class="profile-loading-section">
<!-- Logout button in top-right corner -->
<button
class="logout-btn floating"
on:click={handleLogout}>Log out</button
>
<h3>Profile Loading</h3>
<p>Your profile metadata is being loaded...</p>
<button
class="retry-profile-btn"
on:click={fetchProfileIfMissing}
>
Retry Loading Profile
</button>
<div class="user-pubkey-display">
<strong>Public Key:</strong>
{userPubkey.slice(0, 16)}...{userPubkey.slice(-8)}
</div>
</div>
{/if}
<!-- Additional settings can be added here -->
</div>
</div>
</div>
{/if}
<!-- Login Modal -->
<LoginModal
bind:showModal={showLoginModal}
{isDarkTheme}
on:login={handleLogin}
on:close={closeLoginModal}
/>
<style>
:global(body) {
margin: 0;
padding: 0;
/* Base colors */
--bg-color: #ddd;
--header-bg: #eee;
--sidebar-bg: #eee;
--card-bg: #f8f9fa;
--panel-bg: #f8f9fa;
--border-color: #dee2e6;
--text-color: #444444;
--text-muted: #6c757d;
--input-border: #ccc;
--input-bg: #ffffff;
--input-text-color: #495057;
--button-bg: #ddd;
--button-hover-bg: #eee;
--button-text: #444444;
--button-hover-border: #adb5bd;
/* Theme colors */
--primary: #00bcd4;
--primary-bg: rgba(0, 188, 212, 0.1);
--secondary: #6c757d;
--success: #28a745;
--success-bg: #d4edda;
--success-text: #155724;
--info: #17a2b8;
--warning: #ff3e00;
--warning-bg: #fff3cd;
--danger: #dc3545;
--danger-bg: #f8d7da;
--danger-text: #721c24;
--error-bg: #f8d7da;
--error-text: #721c24;
/* Code colors */
--code-bg: #f8f9fa;
--code-text: #495057;
/* Tab colors */
--tab-inactive-bg: #bbb;
/* Accent colors */
--accent-color: #007bff;
--accent-hover-color: #0056b3;
}
:global(body.dark-theme) {
/* Base colors */
--bg-color: #263238;
--header-bg: #1e272c;
--sidebar-bg: #1e272c;
--card-bg: #37474f;
--panel-bg: #37474f;
--border-color: #404040;
--text-color: #ffffff;
--text-muted: #adb5bd;
--input-border: #555;
--input-bg: #37474f;
--input-text-color: #ffffff;
--button-bg: #263238;
--button-hover-bg: #1e272c;
--button-text: #ffffff;
--button-hover-border: #6c757d;
/* Theme colors */
--primary: #00bcd4;
--primary-bg: rgba(0, 188, 212, 0.2);
--secondary: #6c757d;
--success: #28a745;
--success-bg: #1e4620;
--success-text: #d4edda;
--info: #17a2b8;
--warning: #ff3e00;
--warning-bg: #4d1f00;
--danger: #dc3545;
--danger-bg: #4d1319;
--danger-text: #f8d7da;
--error-bg: #4d1319;
--error-text: #f8d7da;
/* Code colors */
--code-bg: #1e272c;
--code-text: #ffffff;
/* Tab colors */
--tab-inactive-bg: #1a1a1a;
/* Accent colors */
--accent-color: #007bff;
--accent-hover-color: #0056b3;
}
.login-btn {
padding: 0.5em 1em;
border: none;
border-radius: 6px;
background-color: #4caf50;
color: var(--text-color);
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background-color 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
margin: 0 auto;
padding: 0.5em 1em;
}
.login-btn:hover {
background-color: #45a049;
}
.acl-mode-warning {
padding: 1em;
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
color: #856404;
margin: 20px 0;
}
.acl-mode-warning h3 {
margin: 0 0 15px 0;
color: #856404;
}
.acl-mode-warning p {
margin: 10px 0;
line-height: 1.5;
}
.acl-mode-warning code {
background-color: #f8f9fa;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
color: #495057;
}
/* App Container */
.app-container {
display: flex;
margin-top: 3em;
height: calc(100vh - 3em);
}
/* Main Content */
.main-content {
position: fixed;
left: 200px;
top: 2.5em;
right: 0;
bottom: 0;
padding: 0;
overflow-y: auto;
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
display: flex;
}
.welcome-message {
text-align: center;
}
.welcome-message p {
font-size: 1.2rem;
}
@media (max-width: 640px) {
.main-content {
left: 160px;
padding: 1rem;
}
}
.logout-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
background-color: var(--warning);
color: var(--text-color);
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.logout-btn:hover {
background-color: #e53935;
}
.logout-btn.floating {
position: absolute;
top: 0.5em;
right: 0.5em;
z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* Settings Drawer */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
justify-content: flex-end;
}
.settings-drawer {
width: 640px;
height: 100%;
background: var(--bg-color);
/*border-left: 1px solid var(--border-color);*/
overflow-y: auto;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--header-bg);
}
.drawer-header h2 {
margin: 0;
color: var(--text-color);
font-size: 1em;
padding: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1em;
cursor: pointer;
color: var(--text-color);
padding: 0.5em;
transition: background-color 0.2s;
align-items: center;
}
.close-btn:hover {
background: var(--button-hover-bg);
}
.profile-section {
margin-bottom: 2rem;
}
.profile-hero {
position: relative;
}
.profile-banner {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 0;
display: block;
}
/* Avatar sits half over the bottom edge of the banner */
.profile-avatar,
.profile-avatar-placeholder {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
border: 2px solid var(--bg-color);
}
.overlap {
position: absolute;
left: 12px;
bottom: -36px; /* half out of the banner */
z-index: 2;
background: var(--button-hover-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
/* Username and nip05 on the banner, to the right of avatar */
.name-row {
position: absolute;
left: calc(12px + 72px + 12px);
bottom: 8px;
right: 12px;
display: flex;
align-items: baseline;
gap: 8px;
z-index: 1;
}
.profile-username {
margin: 0;
font-size: 1.1rem;
color: var(--text-color); /* contrasting over banner */
text-shadow: 0 3px 6px rgba(255, 255, 255, 1);
}
.profile-nip05-inline {
font-size: 0.85rem;
color: var(--text-color); /* subtle but contrasting */
font-family: monospace;
opacity: 0.95;
text-shadow: 0 3px 6px rgba(255, 255, 255, 1);
}
/* About box below with overlap space for avatar */
.about-card {
background: var(--header-bg);
padding: 12px 12px 12px 96px; /* offset text from overlapping avatar */
position: relative;
word-break: auto-phrase;
}
.profile-about {
margin: 0;
color: var(--text-color);
font-size: 0.9rem;
line-height: 1.4;
}
.profile-loading-section {
padding: 1rem;
text-align: center;
position: relative; /* Allow absolute positioning of floating logout button */
}
.profile-loading-section h3 {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.1rem;
}
.profile-loading-section p {
margin: 0 0 1rem 0;
color: var(--text-color);
opacity: 0.8;
}
.retry-profile-btn {
padding: 0.5rem 1rem;
background: var(--primary);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
margin-bottom: 1rem;
transition: background-color 0.2s;
}
.retry-profile-btn:hover {
background: #00acc1;
}
.user-pubkey-display {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-color);
opacity: 0.7;
background: var(--button-bg);
padding: 0.5rem;
border-radius: 4px;
word-break: break-all;
}
/* Managed ACL View */
.managed-acl-view {
padding: 20px;
max-width: 1200px;
margin: 0;
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
}
.refresh-btn {
padding: 0.5rem 1rem;
background: var(--primary);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background-color 0.2s;
display: inline-flex;
align-items: center;
gap: 0.25rem;
height: 2em;
margin: 1em;
}
.refresh-btn:hover {
background: #00acc1;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* View as Section */
.view-as-section {
color: var(--text-color);
padding: 1rem;
border-radius: 0.5em;
margin-bottom: 1rem;
}
.view-as-section h3 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 1rem;
color: var(--primary);
}
.view-as-section p {
margin: 0.5rem 0;
font-size: 0.9rem;
opacity: 0.8;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.5em;
transition: background 0.2s;
}
.radio-label:hover {
background: rgba(255, 255, 255, 0.1);
}
.radio-label input {
margin: 0;
}
.avatar-placeholder {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--button-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
}
.kind-number {
background: var(--primary);
color: var(--text-color);
padding: 0.125rem 0.375rem;
border-radius: 0.5em;
font-size: 0.7rem;
font-weight: 500;
font-family: monospace;
}
.kind-name {
font-size: 0.75rem;
color: var(--text-color);
opacity: 0.7;
font-weight: 500;
}
.event-timestamp {
font-size: 0.75rem;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 0.25rem;
font-weight: 500;
}
.event-content-single-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.delete-btn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
padding: 0.2rem;
border-radius: 0.5em;
transition: background-color 0.2s;
font-size: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
}
.delete-btn:hover {
background: var(--warning);
color: var(--text-color);
}
.json-container {
position: relative;
}
.copy-json-btn {
color: var(--text-color);
background: var(--accent-color);
border: 0;
border-radius: 0.5rem;
padding: 0.5rem;
font-size: 1rem;
cursor: pointer;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
}
.copy-json-btn:hover {
background: var(--accent-hover-color);
}
.event-json {
background: var(--bg-color);
padding: 1rem;
margin: 0;
font-family: "Courier New", monospace;
font-size: 0.8rem;
line-height: 1.4;
color: var(--text-color);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.no-events {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.7;
}
.loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--border-color);
border-top: 3px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1rem auto;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Search Results Styles */
.search-results-view {
position: fixed;
top: 3em;
left: 200px;
right: 0;
bottom: 0;
background: var(--bg-color);
color: var(--text-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-results-header {
padding: 0.5rem 1rem;
background: var(--header-bg);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
height: 2.5em;
}
.search-results-header h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
}
.search-results-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
.search-result-item {
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
}
.search-result-item:hover {
background: var(--button-hover-bg);
}
.search-result-item.expanded {
background: var(--button-hover-bg);
}
.search-result-row {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
gap: 0.75rem;
min-height: 3rem;
}
.search-result-avatar {
flex-shrink: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.search-result-info {
flex-shrink: 0;
width: 12rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.search-result-author {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-color);
opacity: 0.8;
}
.search-result-kind {
display: flex;
align-items: center;
gap: 0.5rem;
}
.search-result-content {
flex: 1;
color: var(--text-color);
font-size: 0.9rem;
line-height: 1.3;
word-break: break-word;
}
.search-result-content .event-timestamp {
font-size: 0.75rem;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 0.25rem;
font-weight: 500;
}
.search-result-content .event-content-single-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.search-result-details {
border-top: 1px solid var(--border-color);
background: var(--header-bg);
padding: 1rem;
}
.no-search-results {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.7;
}
.no-search-results p {
margin: 0;
font-size: 1rem;
}
.loading-search-results {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.7;
}
.loading-search-results p {
margin: 0;
font-size: 0.9rem;
}
.end-of-search-results {
padding: 1rem;
text-align: center;
color: var(--text-color);
opacity: 0.5;
font-size: 0.8rem;
border-top: 1px solid var(--border-color);
}
.end-of-search-results p {
margin: 0;
}
@media (max-width: 1280px) {
.main-content {
left: 60px;
}
.search-results-view {
left: 60px;
}
}
@media (max-width: 640px) {
.settings-drawer {
width: 100%;
}
.name-row {
left: calc(8px + 56px + 8px);
bottom: 6px;
right: 8px;
gap: 6px;
}
.profile-username {
font-size: 1rem;
color: var(--text-color);
}
.profile-nip05-inline {
font-size: 0.8rem;
color: var(--text-color);
}
.managed-acl-view {
padding: 1rem;
}
.kind-name {
font-size: 0.7rem;
}
.search-results-view {
left: 160px;
}
.search-result-info {
width: 8rem;
}
.search-result-author {
font-size: 0.7rem;
}
.search-result-content {
font-size: 0.8rem;
}
}
/* Recovery Tab Styles */
.recovery-tab {
padding: 20px;
max-width: 1200px;
margin: 0;
}
.recovery-tab h3 {
margin: 0 0 10px 0;
color: var(--text-color);
}
.recovery-tab p {
margin: 0;
color: var(--text-color);
opacity: 0.7;
padding: 0.5em;
}
.recovery-controls-card {
background-color: transparent;
border: none;
border-radius: 0.5em;
padding: 0;
}
.recovery-controls {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.kind-selector {
display: flex;
flex-direction: column;
gap: 5px;
}
.kind-selector label {
font-weight: 500;
color: var(--text-color);
}
.kind-selector select {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 0.5em;
background: var(--bg-color);
color: var(--text-color);
min-width: 300px;
}
.custom-kind-input {
display: flex;
flex-direction: column;
gap: 5px;
}
.custom-kind-input label {
font-weight: 500;
color: var(--text-color);
}
.custom-kind-input input {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 0.5em;
background: var(--bg-color);
color: var(--text-color);
min-width: 200px;
}
.custom-kind-input input::placeholder {
color: var(--text-color);
opacity: 0.6;
}
.recovery-results {
margin-top: 20px;
}
.loading,
.no-events {
text-align: left;
padding: 40px 20px;
color: var(--text-color);
opacity: 0.7;
}
.events-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.event-item {
background: var(--surface-bg);
border: 2px solid var(--primary);
border-radius: 0.5em;
padding: 20px;
transition: all 0.2s ease;
background: var(--header-bg);
}
.event-item.old-version {
opacity: 0.85;
border: none;
background: var(--header-bg);
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.event-header-left {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.event-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.event-kind {
font-weight: 600;
color: var(--primary);
}
.event-timestamp {
color: var(--text-color);
font-size: 0.9em;
opacity: 0.7;
}
.repost-all-button {
background: #059669;
color: var(--text-color);
border: none;
padding: 6px 12px;
border-radius: 0.5em;
cursor: pointer;
font-size: 0.9em;
transition: background 0.2s ease;
margin-right: 8px;
}
.repost-all-button:hover {
background: #047857;
}
.repost-button {
background: var(--primary);
color: var(--text-color);
border: none;
padding: 6px 12px;
border-radius: 0.5em;
cursor: pointer;
font-size: 0.9em;
transition: background 0.2s ease;
}
.repost-button:hover {
background: #00acc1;
}
.event-content {
margin-bottom: 15px;
}
.load-more {
width: 100%;
padding: 12px;
background: var(--primary);
color: var(--text-color);
border: none;
border-radius: 0.5em;
cursor: pointer;
font-size: 1em;
margin-top: 20px;
transition: background 0.2s ease;
}
.load-more:hover:not(:disabled) {
background: #00acc1;
}
.load-more:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Dark theme adjustments for recovery tab */
:global(body.dark-theme) .event-item.old-version {
background: var(--header-bg);
border: none;
}
/* Filter Builder Overlay */
.filter-builder-overlay {
position: fixed;
top: 3.5em; /* Below the header */
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
justify-content: center;
align-items: flex-start;
overflow-y: auto;
padding: 1em;
}
.filter-builder-container {
width: 100%;
max-width: 900px;
background: var(--bg-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
margin-top: 2em;
max-height: calc(100vh - 7em);
overflow-y: auto;
}
@media (max-width: 768px) {
.filter-builder-overlay {
top: 3em;
}
.filter-builder-container {
margin-top: 0;
max-height: calc(100vh - 5em);
}
}
</style>