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.
 
 
 
 
 
 

4461 lines
149 KiB

<script>
// Svelte component imports
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 CurationView from "./CurationView.svelte";
import BlossomView from "./BlossomView.svelte";
import LogView from "./LogView.svelte";
import RelayConnectView from "./RelayConnectView.svelte";
import SearchResultsView from "./SearchResultsView.svelte";
import FilterDisplay from "./FilterDisplay.svelte";
// Utility imports
import { buildFilter } from "./helpers.tsx";
import { replaceableKinds, kindNames, CACHE_DURATION } from "./constants.js";
import { getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, aboutToHtml, copyToClipboard, showCopyFeedback } from "./utils.js";
import * as api from "./api.js";
// Nostr library imports
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 filter builder in events view
let eventsViewFilter = {}; // Active filter for events view
let searchTabs = [];
let allEvents = [];
let selectedFile = null;
let importMessage = ""; // Message shown after import completes
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;
// CACHE_DURATION is imported from constants.js
// 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 = [];
// NRC (Nostr Relay Connect) state
let nrcEnabled = false;
// ACL mode
let aclMode = "";
// Relay version
let relayVersion = "";
// Compose tab state
let composeEventJson = "";
let composePublishError = "";
// Recovery tab state
let recoverySelectedKind = null;
let recoveryCustomKind = "";
let recoveryEvents = [];
let isLoadingRecovery = false;
let recoveryHasMore = true;
let recoveryOldestTimestamp = null;
let recoveryNewestTimestamp = null;
// replaceableKinds is now imported from constants.js
// Helper functions imported from utils.js:
// - getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, copyToClipboard, showCopyFeedback
function toggleEventExpansion(eventId) {
if (expandedEvents.has(eventId)) {
expandedEvents.delete(eventId);
} else {
expandedEvents.add(eventId);
}
expandedEvents = expandedEvents; // Trigger reactivity
}
async function copyEventToClipboard(eventData, clickEvent) {
const minifiedJson = JSON.stringify(eventData);
const success = await copyToClipboard(minifiedJson);
const button = clickEvent.target.closest(".copy-json-btn");
showCopyFeedback(button, success);
if (!success) {
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 wsProtocol = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
const relayUrl = `${wsProtocol}//${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 wsProto = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
const localRelayUrl = `${wsProto}//${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);
}
}
// escapeHtml is imported from utils.js
// 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 wsProto = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
const localRelayUrl = `${wsProto}//${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 wsProto = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
const localRelayUrl = `${wsProto}//${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>")
: "";
// Detect system theme preference and listen for changes
if (typeof window !== "undefined" && window.matchMedia) {
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
isDarkTheme = darkModeQuery.matches;
// Listen for system theme changes
darkModeQuery.addEventListener("change", (e) => {
isDarkTheme = e.matches;
});
}
// Load state from localStorage
if (typeof localStorage !== "undefined") {
// 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 NRC configuration
loadNRCConfig();
// Load policy configuration
loadPolicyConfig();
// Load relay version
fetchRelayVersion();
}
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 loadNRCConfig() {
try {
const config = await api.fetchNRCConfig();
nrcEnabled = config.enabled;
} catch (error) {
console.error("Error loading NRC 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: "blossom", icon: "🌸", label: "Blossom" },
{ id: "compose", icon: "✏", label: "Compose", requiresWrite: true },
{ id: "recovery", icon: "🔄", label: "Recovery" },
{
id: "managed-acl",
icon: "🛡",
label: "Managed ACL",
requiresOwner: true,
},
{
id: "curation",
icon: "📋",
label: "Curation",
requiresOwner: true,
},
{ id: "sprocket", icon: "⚙", label: "Sprocket", requiresOwner: true },
{ id: "policy", icon: "📜", label: "Policy", requiresOwner: true },
{ id: "relay-connect", icon: "🔗", label: "Relay Connect", requiresOwner: true },
{ id: "logs", icon: "📋", label: "Logs", 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 policy tab if not enabled
if (tab.id === "policy" && !policyEnabled) {
return false;
}
// Hide relay-connect tab if NRC is not enabled
if (tab.id === "relay-connect" && !nrcEnabled) {
return false;
}
// Hide managed ACL tab if not in managed mode
if (tab.id === "managed-acl" && aclMode !== "managed") {
return false;
}
// Hide curation tab if not in curating mode
if (tab.id === "curation" && aclMode !== "curating") {
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 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;
userPrivkey = 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 toggleFilterBuilder() {
showFilterBuilder = !showFilterBuilder;
}
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;
// Build the filter for the events view
const filter = buildFilter({
searchText,
kinds: selectedKinds,
authors: pubkeys,
ids: eventIds,
tags,
since: sinceTimestamp,
until: untilTimestamp,
limit: limit || 100,
});
// Store the active filter and reload events with it
eventsViewFilter = filter;
loadAllEvents(true, null);
}
function handleFilterClear() {
// Clear the filter and reload all events
eventsViewFilter = {};
loadAllEvents(true, null);
}
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 = "";
}
}
async function fetchRelayVersion() {
try {
const info = await api.fetchRelayInfo();
if (info && info.version) {
relayVersion = info.version;
}
} catch (error) {
console.error("Error fetching relay version:", error);
}
}
// Export functionality
async function exportEvents(pubkeys = []) {
// Skip login check when ACL is "none" (open relay mode)
if (aclMode !== "none" && !isLoggedIn) {
alert("Please log in first");
return;
}
// Check permissions for exporting all events using current effective role
// Skip permission check when ACL is "none"
if (
aclMode !== "none" &&
pubkeys.length === 0 &&
currentEffectiveRole !== "admin" &&
currentEffectiveRole !== "owner"
) {
alert("Admin or owner permission required to export all events");
return;
}
try {
// Build headers - only include auth when ACL is not "none"
const headers = {
"Content-Type": "application/json",
};
if (aclMode !== "none" && isLoggedIn) {
headers.Authorization = await createNIP98AuthHeader(
"/api/export",
"POST",
);
}
const response = await fetch("/api/export", {
method: "POST",
headers,
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) {
// event.detail contains the original DOM event from the child component
selectedFile = event.detail.target.files[0];
}
async function importEvents() {
// Skip login/permission check when ACL is "none" (open relay mode)
if (aclMode !== "none" && (!isLoggedIn || (userRole !== "admin" && userRole !== "owner"))) {
importMessage = "Admin or owner permission required";
setTimeout(() => { importMessage = ""; }, 5000);
return;
}
if (!selectedFile) {
importMessage = "Please select a file";
setTimeout(() => { importMessage = ""; }, 5000);
return;
}
try {
// Show uploading message
importMessage = "Uploading...";
// Build headers - only include auth when ACL is not "none"
const headers = {};
if (aclMode !== "none" && isLoggedIn) {
headers.Authorization = await createNIP98AuthHeader(
"/api/import",
"POST",
);
}
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch("/api/import", {
method: "POST",
headers,
body: formData,
});
if (!response.ok) {
throw new Error(
`Import failed: ${response.status} ${response.statusText}`,
);
}
const result = await response.json();
importMessage = "Upload complete";
selectedFile = null;
document.getElementById("import-file").value = "";
// Clear message after 5 seconds
setTimeout(() => { importMessage = ""; }, 5000);
} catch (error) {
console.error("Import failed:", error);
importMessage = "Import failed: " + error.message;
setTimeout(() => { importMessage = ""; }, 5000);
}
}
// 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;
// Merge eventsViewFilter with pagination params
// eventsViewFilter takes precedence for authors if set, otherwise use the authors param
const filterAuthors = eventsViewFilter.authors || authors;
const events = await fetchAllEvents({
...eventsViewFilter,
limit: reset ? 100 : 200,
until: eventsViewFilter.until || untilTimestamp,
authors: filterAuthors,
});
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() {
// Clear any previous errors
composePublishError = "";
try {
if (!composeEventJson.trim()) {
composePublishError = "Please enter an event to publish";
return;
}
if (!isLoggedIn) {
composePublishError = "Please log in to publish events";
return;
}
if (!userSigner) {
composePublishError = "No signer available. Please log in with a valid authentication method.";
return;
}
let event;
try {
event = JSON.parse(composeEventJson);
} catch (parseError) {
composePublishError = `Invalid JSON: ${parseError.message}`;
return;
}
// Validate that the event has required fields
if (!event.id || !event.sig) {
composePublishError = 'Event must be signed before publishing. Please click "Sign" first.';
return;
}
// Pre-check: validate user has write permission
if (userRole === "read") {
composePublishError = `Permission denied: Your current role is "${userRole}" which does not allow publishing events. Contact a relay administrator to upgrade your permissions.`;
return;
}
// Publish to the ORLY relay using WebSocket (same address as current page)
const wsProtocol = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
const relayUrl = `${wsProtocol}//${window.location.host}/`;
// Use the authentication module to publish the event
const result = await publishEventWithAuth(
relayUrl,
event,
userSigner,
userPubkey,
);
if (result.success) {
composePublishError = "";
alert("Event published successfully to ORLY relay!");
// Optionally clear the editor after successful publish
// composeEventJson = '';
} else {
// Parse the error reason and provide helpful guidance
const reason = result.reason || "Unknown error";
composePublishError = formatPublishError(reason, event.kind);
}
} catch (error) {
console.error("Error publishing event:", error);
const errorMsg = error.message || "Unknown error";
composePublishError = formatPublishError(errorMsg, null);
}
}
// Helper function to format publish errors with helpful guidance
function formatPublishError(reason, eventKind) {
const lowerReason = reason.toLowerCase();
// Check for policy-related errors
if (lowerReason.includes("policy") || lowerReason.includes("blocked") || lowerReason.includes("denied")) {
let msg = `Policy Error: ${reason}`;
if (eventKind !== null) {
msg += `\n\nKind ${eventKind} may be restricted by the relay's policy configuration.`;
}
if (policyEnabled) {
msg += "\n\nThe relay has policy enforcement enabled. Contact a relay administrator to allow this event kind or adjust your permissions.";
}
return msg;
}
// Check for permission/auth errors
if (lowerReason.includes("auth") || lowerReason.includes("permission") || lowerReason.includes("unauthorized")) {
return `Permission Error: ${reason}\n\nYour current permissions may not allow publishing this type of event. Current role: ${userRole || "unknown"}. Contact a relay administrator to upgrade your permissions.`;
}
// Check for kind-specific restrictions
if (lowerReason.includes("kind") || lowerReason.includes("not allowed") || lowerReason.includes("restricted")) {
let msg = `Event Type Error: ${reason}`;
if (eventKind !== null) {
msg += `\n\nKind ${eventKind} is not currently allowed on this relay.`;
}
msg += "\n\nThe relay administrator may need to update the policy configuration to allow this event kind.";
return msg;
}
// Check for rate limiting
if (lowerReason.includes("rate") || lowerReason.includes("limit") || lowerReason.includes("too many")) {
return `Rate Limit Error: ${reason}\n\nPlease wait a moment before trying again.`;
}
// Check for size limits
if (lowerReason.includes("size") || lowerReason.includes("too large") || lowerReason.includes("content")) {
return `Size Limit Error: ${reason}\n\nThe event may exceed the relay's size limits. Try reducing the content length.`;
}
// Default error message
return `Publishing failed: ${reason}`;
}
// Clear the compose publish error
function clearComposeError() {
composePublishError = "";
}
// 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}
{isLoggedIn}
{userRole}
{currentEffectiveRole}
{userProfile}
{userPubkey}
on:openSettingsDrawer={openSettingsDrawer}
on:openLoginModal={openLoginModal}
/>
<!-- Main Content Area -->
<div class="app-container" class:dark-theme={isDarkTheme}>
<!-- Sidebar -->
<Sidebar
{isDarkTheme}
{tabs}
{selectedTab}
version={relayVersion}
on:selectTab={(e) => selectTab(e.detail)}
on:closeSearchTab={(e) => closeSearchTab(e.detail)}
/>
<!-- Main Content -->
<main class="main-content">
{#if selectedTab === "export"}
<ExportView
{isLoggedIn}
{currentEffectiveRole}
{aclMode}
on:exportMyEvents={exportMyEvents}
on:exportAllEvents={exportAllEvents}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "import"}
<ImportView
{isLoggedIn}
{currentEffectiveRole}
{selectedFile}
{aclMode}
{importMessage}
on:fileSelect={handleFileSelect}
on:importEvents={importEvents}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "events"}
<EventsView
{isLoggedIn}
{userRole}
{userPubkey}
{filteredEvents}
{expandedEvents}
{isLoadingEvents}
{showOnlyMyEvents}
{showFilterBuilder}
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)}
on:toggleFilterBuilder={toggleFilterBuilder}
on:filterApply={handleFilterApply}
on:filterClear={handleFilterClear}
/>
{:else if selectedTab === "blossom"}
<BlossomView
{isLoggedIn}
{userPubkey}
{userSigner}
{currentEffectiveRole}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "compose"}
<ComposeView
bind:composeEventJson
{userPubkey}
{userRole}
{policyEnabled}
publishError={composePublishError}
on:reformatJson={reformatJson}
on:signEvent={signEvent}
on:publishEvent={publishEvent}
on:clearError={clearComposeError}
/>
{: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 === "curation"}
<div class="curation-view-container">
{#if aclMode !== "curating"}
<div class="acl-mode-warning">
<h3>Curating Mode Not Active</h3>
<p>
To use the Curation interface, you need to set
the ACL mode to "curating" in your relay
configuration.
</p>
<p>
Current ACL mode: <strong
>{aclMode || "unknown"}</strong
>
</p>
<p>
Please set <code>ORLY_ACL_MODE=curating</code> in your
environment variables and restart the relay.
</p>
</div>
{:else if isLoggedIn && userRole === "owner"}
<CurationView {userSigner} {userPubkey} />
{:else}
<div class="access-denied">
<p>
Please log in with owner permissions to access
curation 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 === "relay-connect"}
<RelayConnectView
{isLoggedIn}
{userRole}
{userSigner}
{userPubkey}
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "logs"}
<LogView
{isLoggedIn}
{userRole}
{userSigner}
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(html),
:global(body) {
margin: 0;
padding: 0;
overflow: hidden;
height: 100%;
/* 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: 3em;
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;
background: var(--bg-color);
padding: 0.2em 0.5em;
border-radius: 0.5em;
width: fit-content;
}
.profile-username {
margin: 0;
font-size: 1.1rem;
color: var(--text-color);
}
.profile-nip05-inline {
font-size: 0.85rem;
color: var(--text-color);
font-family: monospace;
opacity: 0.95;
}
/* 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;
background: var(--bg-color);
padding: 0.2em 0.5em;
border-radius: 0.5em;
width: fit-content;
}
.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;
width: 100%;
max-width: 1200px;
margin: 0;
box-sizing: border-box;
}
.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;
}
</style>