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.
 
 
 
 
 
 

5516 lines
177 KiB

<script>
import LoginModal from "./LoginModal.svelte";
import ManagedACL from "./ManagedACL.svelte";
import {
initializeNostrClient,
fetchUserProfile,
fetchAllEvents,
fetchUserEvents,
searchEvents,
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 isSearchMode = false;
let searchQuery = "";
let searchTabs = [];
let allEvents = [];
let selectedFile = null;
let expandedEvents = new Set();
let isLoadingEvents = false;
let hasMoreEvents = true;
let eventsPerPage = 100;
let oldestEventTimestamp = null; // For timestamp-based pagination
let newestEventTimestamp = null; // For loading newer events
let showPermissionMenu = false;
let viewAsRole = "";
// Search results state
let searchResults = new Map(); // Map of searchTabId -> { events, isLoading, hasMore, oldestTimestamp }
let isLoadingSearch = false;
// Screen-filling events view state
let eventsPerScreen = 20; // Default, will be calculated based on screen size
// Global events cache system
let globalEventsCache = []; // All events cache
let globalCacheTimestamp = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Events filter toggle
let showOnlyMyEvents = false;
// My Events state
let myEvents = [];
let isLoadingMyEvents = false;
let hasMoreMyEvents = true;
let oldestMyEventTimestamp = null;
let newestMyEventTimestamp = null;
// Sprocket management state
let sprocketScript = "";
let sprocketStatus = null;
let sprocketVersions = [];
let isLoadingSprocket = false;
let sprocketMessage = "";
let sprocketMessageType = "info";
let sprocketEnabled = false;
let sprocketUploadFile = null;
// ACL mode
let aclMode = "";
// Compose tab state
let composeEventJson = "";
// Recovery tab state
let recoverySelectedKind = null;
let recoveryCustomKind = "";
let recoveryEvents = [];
let isLoadingRecovery = false;
let recoveryHasMore = true;
let recoveryOldestTimestamp = null;
let recoveryNewestTimestamp = null;
// Replaceable kinds for the recovery dropdown
// Based on NIP-01: kinds 0, 3, and 10000-19999 are replaceable
// kinds 30000-39999 are addressable (parameterized replaceable)
const replaceableKinds = [
// Basic replaceable kinds (0, 3)
{ value: 0, label: "User Metadata (0)" },
{ value: 3, label: "Follows (3)" },
// Replaceable range 10000-19999
{ value: 10000, label: "Mute list (10000)" },
{ value: 10001, label: "Pin list (10001)" },
{ value: 10002, label: "Relay List Metadata (10002)" },
{ value: 10003, label: "Bookmark list (10003)" },
{ value: 10004, label: "Communities list (10004)" },
{ value: 10005, label: "Public chats list (10005)" },
{ value: 10006, label: "Blocked relays list (10006)" },
{ value: 10007, label: "Search relays list (10007)" },
{ value: 10009, label: "User groups (10009)" },
{ value: 10012, label: "Favorite relays list (10012)" },
{ value: 10013, label: "Private event relay list (10013)" },
{ value: 10015, label: "Interests list (10015)" },
{ value: 10019, label: "Nutzap Mint Recommendation (10019)" },
{ value: 10020, label: "Media follows (10020)" },
{ value: 10030, label: "User emoji list (10030)" },
{ value: 10050, label: "Relay list to receive DMs (10050)" },
{ value: 10051, label: "KeyPackage Relays List (10051)" },
{ value: 10063, label: "User server list (10063)" },
{ value: 10096, label: "File storage server list (10096)" },
{ value: 10166, label: "Relay Monitor Announcement (10166)" },
{ value: 10312, label: "Room Presence (10312)" },
{ value: 10377, label: "Proxy Announcement (10377)" },
{ value: 11111, label: "Transport Method Announcement (11111)" },
{ value: 13194, label: "Wallet Info (13194)" },
{ value: 17375, label: "Cashu Wallet Event (17375)" },
// Addressable range 30000-39999 (parameterized replaceable)
{ value: 30000, label: "Follow sets (30000)" },
{ value: 30001, label: "Generic lists (30001)" },
{ value: 30002, label: "Relay sets (30002)" },
{ value: 30003, label: "Bookmark sets (30003)" },
{ value: 30004, label: "Curation sets (30004)" },
{ value: 30005, label: "Video sets (30005)" },
{ value: 30007, label: "Kind mute sets (30007)" },
{ value: 30008, label: "Profile Badges (30008)" },
{ value: 30009, label: "Badge Definition (30009)" },
{ value: 30015, label: "Interest sets (30015)" },
{ value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Create or update a product (30018)" },
{ value: 30019, label: "Marketplace UI/UX (30019)" },
{ value: 30020, label: "Product sold as an auction (30020)" },
{ value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30030, label: "Emoji sets (30030)" },
{ value: 30040, label: "Curated Publication Index (30040)" },
{ value: 30041, label: "Curated Publication Content (30041)" },
{ value: 30063, label: "Release artifact sets (30063)" },
{ value: 30078, label: "Application-specific Data (30078)" },
{ value: 30166, label: "Relay Discovery (30166)" },
{ value: 30267, label: "App curation sets (30267)" },
{ value: 30311, label: "Live Event (30311)" },
{ value: 30312, label: "Interactive Room (30312)" },
{ value: 30313, label: "Conference Event (30313)" },
{ value: 30315, label: "User Statuses (30315)" },
{ value: 30388, label: "Slide Set (30388)" },
{ value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 30617, label: "Repository announcements (30617)" },
{ value: 30618, label: "Repository state announcements (30618)" },
{ value: 30818, label: "Wiki article (30818)" },
{ value: 30819, label: "Redirects (30819)" },
{ value: 31234, label: "Draft Event (31234)" },
{ value: 31388, label: "Link Set (31388)" },
{ value: 31890, label: "Feed (31890)" },
{ value: 31922, label: "Date-Based Calendar Event (31922)" },
{ value: 31923, label: "Time-Based Calendar Event (31923)" },
{ value: 31924, label: "Calendar (31924)" },
{ value: 31925, label: "Calendar Event RSVP (31925)" },
{ value: 31989, label: "Handler recommendation (31989)" },
{ value: 31990, label: "Handler information (31990)" },
{ value: 32267, label: "Software Application (32267)" },
{ value: 34550, label: "Community Definition (34550)" },
{ value: 37516, label: "Geocache listing (37516)" },
{ value: 38172, label: "Cashu Mint Announcement (38172)" },
{ value: 38173, label: "Fedimint Announcement (38173)" },
{ value: 38383, label: "Peer-to-peer Order events (38383)" },
{ value: 39089, label: "Starter packs (39089)" },
{ value: 39092, label: "Media starter packs (39092)" },
{ value: 39701, label: "Web bookmarks (39701)" },
];
// Kind name mapping based on NIP specification
// Matches official Nostr event kinds from https://github.com/nostr-protocol/nips
const kindNames = {
0: "User Metadata",
1: "Short Text Note",
2: "Recommend Relay",
3: "Follows",
4: "Encrypted Direct Messages",
5: "Event Deletion Request",
6: "Repost",
7: "Reaction",
8: "Badge Award",
9: "Chat Message",
10: "Group Chat Threaded Reply",
11: "Thread",
12: "Group Thread Reply",
13: "Seal",
14: "Direct Message",
15: "File Message",
16: "Generic Repost",
17: "Reaction to a website",
20: "Picture",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1021: "Bid",
1022: "Bid Confirmation",
1040: "OpenTimestamps",
1063: "File Metadata",
1311: "Live Chat Message",
1971: "Problem Tracker",
1984: "Reporting",
1985: "Label",
4550: "Community Post Approval",
5000: "Job Request",
5999: "Job Request",
6000: "Job Result",
6999: "Job Result",
7000: "Job Feedback",
9041: "Zap Goal",
9734: "Zap Request",
9735: "Zap",
9882: "Highlights",
10000: "Mute list",
10001: "Pin list",
10002: "Relay List Metadata",
10003: "Bookmarks list",
10004: "Communities list",
10005: "Public Chats list",
10006: "Blocked Relays list",
10007: "Search Relays list",
10015: "Interests",
10030: "User Emoji list",
10050: "DM relays",
10096: "File Storage Server List",
13194: "Wallet Service Info",
21000: "Lightning pub RPC",
22242: "Client Authentication",
23194: "Wallet Request",
23195: "Wallet Response",
23196: "Wallet Notification",
23197: "Wallet Notification",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Follow sets",
30001: "Generic lists",
30002: "Relay sets",
30003: "Bookmark sets",
30004: "Curation sets",
30008: "Profile Badges",
30009: "Badge Definition",
30015: "Interest sets",
30017: "Stall Definition",
30018: "Product Definition",
30019: "Marketplace UI/UX",
30020: "Product sold as an auction",
30023: "Long-form Content",
30024: "Draft Long-form Content",
30030: "Emoji sets",
30078: "Application-specific Data",
30311: "Live Event",
30315: "User Statuses",
30402: "Classified Listing",
30403: "Draft Classified Listing",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
31924: "Calendar",
31925: "Calendar Event RSVP",
31989: "Handler recommendation",
31990: "Handler information",
34235: "Video Event Horizontal",
34236: "Video Event Vertical",
34550: "Community Definition",
};
function getKindName(kind) {
return kindNames[kind] || `Kind ${kind}`;
}
function truncatePubkey(pubkey) {
if (!pubkey) return "unknown";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
function truncateContent(content, maxLength = 100) {
if (!content) return "";
return content.length > maxLength
? content.slice(0, maxLength) + "..."
: content;
}
function formatTimestamp(timestamp) {
if (!timestamp) return "";
return new Date(timestamp * 1000).toLocaleString();
}
function toggleEventExpansion(eventId) {
if (expandedEvents.has(eventId)) {
expandedEvents.delete(eventId);
} else {
expandedEvents.add(eventId);
}
expandedEvents = expandedEvents; // Trigger reactivity
}
async function copyEventToClipboard(eventData, clickEvent) {
try {
// Create minified JSON (no indentation)
const minifiedJson = JSON.stringify(eventData);
await navigator.clipboard.writeText(minifiedJson);
// Show temporary feedback
const button = clickEvent.target.closest(".copy-json-btn");
if (button) {
const originalText = button.textContent;
button.textContent = "✅";
button.style.backgroundColor = "#4CAF50";
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = "";
}, 2000);
}
} catch (error) {
console.error("Failed to copy to clipboard:", error);
// Fallback for older browsers
try {
const textArea = document.createElement("textarea");
textArea.value = JSON.stringify(eventData);
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
const button = clickEvent.target.closest(".copy-json-btn");
if (button) {
const originalText = button.textContent;
button.textContent = "✅";
button.style.backgroundColor = "#4CAF50";
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = "";
}, 2000);
}
} catch (fallbackError) {
console.error("Fallback copy also failed:", fallbackError);
alert("Failed to copy to clipboard. Please copy manually.");
}
}
}
async function handleToggleChange() {
// Toggle state is already updated by bind:checked
console.log("Toggle changed, showOnlyMyEvents:", showOnlyMyEvents);
// Reload events with the new filter
const authors =
showOnlyMyEvents && isLoggedIn && userPubkey ? [userPubkey] : null;
await loadAllEvents(true, authors);
}
// Events are filtered server-side, but add client-side filtering as backup
// Sort events by created_at timestamp (newest first)
$: filteredEvents = (
showOnlyMyEvents && isLoggedIn && userPubkey
? allEvents.filter(
(event) => event.pubkey && event.pubkey === userPubkey,
)
: allEvents
).sort((a, b) => b.created_at - a.created_at);
async function deleteEvent(eventId) {
if (!isLoggedIn) {
alert("Please log in first");
return;
}
// Find the event to check if user can delete it
const event = allEvents.find((e) => e.id === eventId);
if (!event) {
alert("Event not found");
return;
}
// Check permissions: admin/owner can delete any event, write users can only delete their own events
const canDelete =
userRole === "admin" ||
userRole === "owner" ||
(userRole === "write" &&
event.pubkey &&
event.pubkey === userPubkey);
if (!canDelete) {
alert("You do not have permission to delete this event");
return;
}
if (!confirm("Are you sure you want to delete this event?")) {
return;
}
try {
// Check if signer is available
if (!userSigner) {
throw new Error("Signer not available for signing");
}
// Create the delete event template (unsigned)
const deleteEventTemplate = {
kind: 5,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", eventId]], // e-tag referencing the event to delete
content: "",
// Don't set pubkey - let the signer set it
};
console.log("Created delete event template:", deleteEventTemplate);
console.log("User pubkey:", userPubkey);
console.log("Target event:", event);
console.log("Target event pubkey:", event.pubkey);
// Sign the event using the signer
const signedDeleteEvent =
await userSigner.signEvent(deleteEventTemplate);
console.log("Signed delete event:", signedDeleteEvent);
console.log(
"Signed delete event pubkey:",
signedDeleteEvent.pubkey,
);
console.log("Delete event tags:", signedDeleteEvent.tags);
// Publish to the ORLY relay using WebSocket authentication
const relayUrl = `wss://${window.location.host}`;
try {
const result = await publishEventWithAuth(
relayUrl,
signedDeleteEvent,
userSigner,
userPubkey,
);
if (result.success) {
console.log(
"Delete event published successfully to ORLY relay",
);
} else {
console.error(
"Failed to publish delete event:",
result.reason,
);
}
} catch (error) {
console.error("Error publishing delete event:", error);
}
// Determine if we should publish to external relays
// Only publish to external relays if:
// 1. User is deleting their own event, OR
// 2. User is admin/owner AND deleting their own event
const isDeletingOwnEvent =
event.pubkey && event.pubkey === userPubkey;
const isAdminOrOwner = userRole === "admin" || userRole === "owner";
const shouldPublishToExternalRelays = isDeletingOwnEvent;
if (shouldPublishToExternalRelays) {
// Publish the delete event to all relays (including external ones)
const result = await nostrClient.publish(signedDeleteEvent);
console.log("Delete event published:", result);
if (result.success && result.okCount > 0) {
// Wait a moment for the deletion to propagate
await new Promise((resolve) => setTimeout(resolve, 2000));
// Verify the event was actually deleted by trying to fetch it
try {
const deletedEvent = await fetchEventById(eventId, {
timeout: 5000,
});
if (deletedEvent) {
console.warn(
"Event still exists after deletion attempt:",
deletedEvent,
);
alert(
`Warning: Delete event was accepted by ${result.okCount} relay(s), but the event still exists on the relay. This may indicate the relay does not properly handle delete events.`,
);
} else {
console.log(
"Event successfully deleted and verified",
);
}
} catch (fetchError) {
console.log(
"Could not fetch event after deletion (likely deleted):",
fetchError.message,
);
}
// Also verify that the delete event has been saved
try {
const deleteEvents = await fetchDeleteEventsByTarget(
eventId,
{ timeout: 5000 },
);
if (deleteEvents.length > 0) {
console.log(
`Delete event verification: Found ${deleteEvents.length} delete event(s) targeting ${eventId}`,
);
// Check if our delete event is among them
const ourDeleteEvent = deleteEvents.find(
(de) => de.pubkey && de.pubkey === userPubkey,
);
if (ourDeleteEvent) {
console.log(
"Our delete event found in database:",
ourDeleteEvent.id,
);
} else {
console.warn(
"Our delete event not found in database, but other delete events exist",
);
}
} else {
console.warn(
"No delete events found in database for target event:",
eventId,
);
}
} catch (deleteFetchError) {
console.log(
"Could not verify delete event in database:",
deleteFetchError.message,
);
}
// Remove from local lists
allEvents = allEvents.filter(
(event) => event.id !== eventId,
);
myEvents = myEvents.filter((event) => event.id !== eventId);
// Remove from global cache
globalEventsCache = globalEventsCache.filter(
(event) => event.id !== eventId,
);
// Remove from search results cache
for (const [tabId, searchResult] of searchResults) {
if (searchResult.events) {
searchResult.events = searchResult.events.filter(
(event) => event.id !== eventId,
);
searchResults.set(tabId, searchResult);
}
}
// Update persistent state
savePersistentState();
// Reload events to show the new delete event at the top
console.log("Reloading events to show delete event...");
const authors =
showOnlyMyEvents && isLoggedIn && userPubkey
? [userPubkey]
: null;
await loadAllEvents(true, authors);
alert(
`Event deleted successfully (accepted by ${result.okCount} relay(s))`,
);
} else {
throw new Error("No relays accepted the delete event");
}
} else {
// Admin/owner deleting someone else's event - only publish to local relay
// We need to publish only to the local relay, not external ones
const localRelayUrl = `wss://${window.location.host}/`;
// Create a modified client that only connects to the local relay
const localClient = new NostrClient();
await localClient.connectToRelay(localRelayUrl);
const result = await localClient.publish(signedDeleteEvent);
console.log(
"Delete event published to local relay only:",
result,
);
if (result.success && result.okCount > 0) {
// Wait a moment for the deletion to propagate
await new Promise((resolve) => setTimeout(resolve, 2000));
// Verify the event was actually deleted by trying to fetch it
try {
const deletedEvent = await fetchEventById(eventId, {
timeout: 5000,
});
if (deletedEvent) {
console.warn(
"Event still exists after deletion attempt:",
deletedEvent,
);
alert(
`Warning: Delete event was accepted by ${result.okCount} relay(s), but the event still exists on the relay. This may indicate the relay does not properly handle delete events.`,
);
} else {
console.log(
"Event successfully deleted and verified",
);
}
} catch (fetchError) {
console.log(
"Could not fetch event after deletion (likely deleted):",
fetchError.message,
);
}
// Also verify that the delete event has been saved
try {
const deleteEvents = await fetchDeleteEventsByTarget(
eventId,
{ timeout: 5000 },
);
if (deleteEvents.length > 0) {
console.log(
`Delete event verification: Found ${deleteEvents.length} delete event(s) targeting ${eventId}`,
);
// Check if our delete event is among them
const ourDeleteEvent = deleteEvents.find(
(de) => de.pubkey && de.pubkey === userPubkey,
);
if (ourDeleteEvent) {
console.log(
"Our delete event found in database:",
ourDeleteEvent.id,
);
} else {
console.warn(
"Our delete event not found in database, but other delete events exist",
);
}
} else {
console.warn(
"No delete events found in database for target event:",
eventId,
);
}
} catch (deleteFetchError) {
console.log(
"Could not verify delete event in database:",
deleteFetchError.message,
);
}
// Remove from local lists
allEvents = allEvents.filter(
(event) => event.id !== eventId,
);
myEvents = myEvents.filter((event) => event.id !== eventId);
// Remove from global cache
globalEventsCache = globalEventsCache.filter(
(event) => event.id !== eventId,
);
// Remove from search results cache
for (const [tabId, searchResult] of searchResults) {
if (searchResult.events) {
searchResult.events = searchResult.events.filter(
(event) => event.id !== eventId,
);
searchResults.set(tabId, searchResult);
}
}
// Update persistent state
savePersistentState();
// Reload events to show the new delete event at the top
console.log("Reloading events to show delete event...");
const authors =
showOnlyMyEvents && isLoggedIn && userPubkey
? [userPubkey]
: null;
await loadAllEvents(true, authors);
alert(
`Event deleted successfully (local relay only - admin/owner deleting other user's event)`,
);
} else {
throw new Error(
"Local relay did not accept the delete event",
);
}
}
} catch (error) {
console.error("Failed to delete event:", error);
alert("Failed to delete event: " + error.message);
}
}
// Safely render "about" text: convert double newlines to a single HTML line break
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Recovery tab functions
async function loadRecoveryEvents() {
const kindToUse = recoveryCustomKind
? parseInt(recoveryCustomKind)
: recoverySelectedKind;
if (kindToUse === null || kindToUse === undefined || isNaN(kindToUse)) {
console.log("No valid kind to load, kindToUse:", kindToUse);
return;
}
if (!isLoggedIn) {
console.log("Not logged in, cannot load recovery events");
return;
}
console.log(
"Loading recovery events for kind:",
kindToUse,
"user:",
userPubkey,
);
isLoadingRecovery = true;
try {
// Fetch multiple versions using limit parameter
// For replaceable events, limit > 1 returns multiple versions
const filters = [
{
kinds: [kindToUse],
authors: [userPubkey],
limit: 100, // Get up to 100 versions
},
];
if (recoveryOldestTimestamp) {
filters[0].until = recoveryOldestTimestamp;
}
console.log("Recovery filters:", filters);
// Use queryEvents which checks IndexedDB cache first, then relay
const events = await queryEvents(filters, {
timeout: 30000,
cacheFirst: true, // Check cache first
});
console.log("Recovery events received:", events.length);
console.log(
"Recovery events kinds:",
events.map((e) => e.kind),
);
if (recoveryOldestTimestamp) {
// Append to existing events
recoveryEvents = [...recoveryEvents, ...events];
} else {
// Replace events
recoveryEvents = events;
}
if (events.length > 0) {
recoveryOldestTimestamp = Math.min(
...events.map((e) => e.created_at),
);
recoveryHasMore = events.length === 100;
} else {
recoveryHasMore = false;
}
} catch (error) {
console.error("Failed to load recovery events:", error);
} finally {
isLoadingRecovery = false;
}
}
async function repostEvent(event) {
if (!confirm("Are you sure you want to repost this event?")) {
return;
}
try {
const localRelayUrl = `wss://${window.location.host}/`;
console.log(
"Reposting event to local relay:",
localRelayUrl,
event,
);
// Create a new event with updated timestamp
const newEvent = { ...event };
newEvent.created_at = Math.floor(Date.now() / 1000);
newEvent.id = ""; // Clear the old ID so it gets recalculated
newEvent.sig = ""; // Clear the old signature
// For addressable events, ensure the d tag matches
if (event.kind >= 30000 && event.kind <= 39999) {
const dTag = event.tags.find((tag) => tag[0] === "d");
if (dTag) {
newEvent.tags = newEvent.tags.filter(
(tag) => tag[0] !== "d",
);
newEvent.tags.push(dTag);
}
}
// Sign the event before publishing
if (userSigner) {
const signedEvent = await userSigner.sign(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);
}
}
function selectRecoveryKind() {
console.log(
"selectRecoveryKind called, recoverySelectedKind:",
recoverySelectedKind,
);
if (
recoverySelectedKind === null ||
recoverySelectedKind === undefined
) {
console.log("No kind selected, skipping load");
return;
}
recoveryCustomKind = ""; // Clear custom kind when selecting from dropdown
recoveryEvents = [];
recoveryOldestTimestamp = null;
recoveryHasMore = true;
loadRecoveryEvents();
}
function handleCustomKindInput() {
console.log(
"handleCustomKindInput called, recoveryCustomKind:",
recoveryCustomKind,
);
// Check if a valid number was entered (including 0)
const kindNum = parseInt(recoveryCustomKind);
if (recoveryCustomKind !== "" && !isNaN(kindNum) && kindNum >= 0) {
recoverySelectedKind = null; // Clear dropdown selection when using custom
recoveryEvents = [];
recoveryOldestTimestamp = null;
recoveryHasMore = true;
loadRecoveryEvents();
}
}
function isCurrentVersion(event) {
// Find all events with the same kind and pubkey
const sameKindEvents = recoveryEvents.filter(
(e) => e.kind === event.kind && e.pubkey === event.pubkey,
);
// Check if this event has the highest timestamp
const maxTimestamp = Math.max(
...sameKindEvents.map((e) => e.created_at),
);
return event.created_at === maxTimestamp;
}
// Always show all versions - no filtering needed
$: aboutHtml = userProfile?.about
? escapeHtml(userProfile.about).replace(/\n{2,}/g, "<br>")
: "";
// Load theme preference from localStorage on component initialization
if (typeof localStorage !== "undefined") {
const savedTheme = localStorage.getItem("isDarkTheme");
if (savedTheme !== null) {
isDarkTheme = JSON.parse(savedTheme);
}
// Check for existing authentication
const storedAuthMethod = localStorage.getItem("nostr_auth_method");
const storedPubkey = localStorage.getItem("nostr_pubkey");
if (storedAuthMethod && storedPubkey) {
isLoggedIn = true;
userPubkey = storedPubkey;
authMethod = storedAuthMethod;
// Restore signer for extension method
if (storedAuthMethod === "extension" && window.nostr) {
userSigner = window.nostr;
}
// Fetch user role for already logged in users
fetchUserRole();
fetchACLMode();
}
// Load persistent app state
loadPersistentState();
// Load sprocket configuration
loadSprocketConfig();
}
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 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);
}
function handleSprocketFileSelect(event) {
sprocketUploadFile = event.target.files[0];
}
async function uploadSprocketScript() {
if (!isLoggedIn || userRole !== "owner" || !sprocketUploadFile) return;
try {
isLoadingSprocket = true;
// Read the file content
const fileContent = await sprocketUploadFile.text();
// Upload the script
const response = await fetch("/api/sprocket/update", {
method: "POST",
headers: {
Authorization: `Nostr ${await createNIP98Auth("POST", "/api/sprocket/update")}`,
"Content-Type": "text/plain",
},
body: fileContent,
});
if (response.ok) {
sprocketScript = fileContent;
showSprocketMessage(
"Script uploaded and updated successfully",
"success",
);
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(
`Failed to upload script: ${errorText}`,
"error",
);
}
} catch (error) {
showSprocketMessage(
`Error uploading script: ${error.message}`,
"error",
);
} finally {
isLoadingSprocket = false;
sprocketUploadFile = null;
// Clear the file input
const fileInput = document.getElementById("sprocket-upload-file");
if (fileInput) {
fileInput.value = "";
}
}
}
const baseTabs = [
{ id: "export", icon: "📤", label: "Export" },
{ id: "import", icon: "💾", label: "Import", requiresAdmin: true },
{ id: "events", icon: "📡", label: "Events" },
{ id: "compose", icon: "✏", label: "Compose", requiresWrite: true },
{ id: "recovery", icon: "🔄", label: "Recovery" },
{
id: "managed-acl",
icon: "🛡",
label: "Managed ACL",
requiresOwner: true,
},
{ id: "sprocket", icon: "⚙", label: "Sprocket", requiresOwner: true },
];
// Filter tabs based on current effective role (including view-as setting)
$: filteredBaseTabs = baseTabs.filter((tab) => {
const currentRole = currentEffectiveRole;
if (
tab.requiresAdmin &&
(!isLoggedIn ||
(currentRole !== "admin" && currentRole !== "owner"))
) {
return false;
}
if (tab.requiresOwner && (!isLoggedIn || currentRole !== "owner")) {
return false;
}
if (tab.requiresWrite && (!isLoggedIn || currentRole === "read")) {
return false;
}
// Hide sprocket tab if not enabled
if (tab.id === "sprocket" && !sprocketEnabled) {
return false;
}
// Hide managed ACL tab if not in managed mode
if (tab.id === "managed-acl" && aclMode !== "managed") {
return false;
}
// Debug logging for tab filtering
console.log(`Tab ${tab.id} filter check:`, {
isLoggedIn,
userRole,
viewAsRole,
currentRole,
requiresAdmin: tab.requiresAdmin,
requiresOwner: tab.requiresOwner,
requiresWrite: tab.requiresWrite,
visible: true,
});
return true;
});
$: tabs = [...filteredBaseTabs, ...searchTabs];
// Debug logging for tabs
$: console.log("Tabs debug:", {
isLoggedIn,
userRole,
aclMode,
filteredBaseTabs: filteredBaseTabs.map((t) => t.id),
allTabs: tabs.map((t) => t.id),
});
function selectTab(tabId) {
selectedTab = tabId;
// Load sprocket data when switching to sprocket tab
if (
tabId === "sprocket" &&
isLoggedIn &&
userRole === "owner" &&
sprocketEnabled
) {
loadSprocketStatus();
loadVersions();
}
savePersistentState();
}
function toggleTheme() {
isDarkTheme = !isDarkTheme;
// Save theme preference to localStorage
if (typeof localStorage !== "undefined") {
localStorage.setItem("isDarkTheme", JSON.stringify(isDarkTheme));
}
}
function openLoginModal() {
if (!isLoggedIn) {
showLoginModal = true;
}
}
async function handleLogin(event) {
const { method, pubkey, privateKey, signer } = event.detail;
isLoggedIn = true;
userPubkey = pubkey;
authMethod = method;
userSigner = signer;
showLoginModal = false;
// Initialize Nostr client and fetch profile
try {
await initializeNostrClient();
// Set up NDK signer based on authentication method
if (method === "extension" && signer) {
// Extension signer (NIP-07 compatible)
nostrClient.setSigner(signer);
} else if (method === "nsec" && privateKey) {
// Private key signer for nsec
const keySigner = new PrivateKeySigner(privateKey);
nostrClient.setSigner(keySigner);
}
userProfile = await fetchUserProfile(pubkey);
console.log("Profile loaded:", userProfile);
} catch (error) {
console.error("Failed to load profile:", error);
}
// Fetch user role/permissions
await fetchUserRole();
await fetchACLMode();
}
function handleLogout() {
isLoggedIn = false;
userPubkey = "";
authMethod = "";
userProfile = null;
userRole = "";
userSigner = null;
showSettingsDrawer = false;
// Clear events
myEvents = [];
allEvents = [];
// Clear cache
clearCache();
// Clear stored authentication
if (typeof localStorage !== "undefined") {
localStorage.removeItem("nostr_auth_method");
localStorage.removeItem("nostr_pubkey");
localStorage.removeItem("nostr_privkey");
}
}
function closeLoginModal() {
showLoginModal = false;
}
function openSettingsDrawer() {
showSettingsDrawer = true;
}
function closeSettingsDrawer() {
showSettingsDrawer = false;
}
function toggleSearchMode() {
isSearchMode = !isSearchMode;
if (!isSearchMode) {
searchQuery = "";
}
}
function handleSearchKeydown(event) {
if (event.key === "Enter" && searchQuery.trim()) {
createSearchTab(searchQuery.trim());
searchQuery = "";
isSearchMode = false;
} else if (event.key === "Escape") {
isSearchMode = false;
searchQuery = "";
}
}
function createSearchTab(query) {
const searchTabId = `search-${Date.now()}`;
const newSearchTab = {
id: searchTabId,
icon: "🔍",
label: query,
isSearchTab: true,
query: query,
};
searchTabs = [...searchTabs, newSearchTab];
selectedTab = searchTabId;
// Initialize search results for this tab
searchResults.set(searchTabId, {
events: [],
isLoading: false,
hasMore: true,
oldestTimestamp: null,
});
// Start loading search results
loadSearchResults(searchTabId, query);
}
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, query, reset = true) {
const searchResult = searchResults.get(searchTabId);
if (!searchResult || searchResult.isLoading) return;
// Update loading state
searchResult.isLoading = true;
searchResults.set(searchTabId, searchResult);
try {
const options = {
limit: reset ? 100 : 200,
until: reset
? Math.floor(Date.now() / 1000)
: searchResult.oldestTimestamp,
};
console.log(
"Loading search results for query:",
query,
"with options:",
options,
);
const events = await searchEvents(query, options);
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 ? 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) {
const searchTab = searchTabs.find((tab) => tab.id === searchTabId);
if (searchTab) {
await loadSearchResults(searchTabId, searchTab.query, false);
}
}
function handleSearchScroll(event, searchTabId) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
const threshold = 100; // Load more when 100px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
const searchResult = searchResults.get(searchTabId);
if (
searchResult &&
!searchResult.isLoading &&
searchResult.hasMore
) {
loadMoreSearchResults(searchTabId);
}
}
}
$: if (typeof document !== "undefined") {
if (isDarkTheme) {
document.body.classList.add("dark-theme");
} else {
document.body.classList.remove("dark-theme");
}
}
// Auto-fetch profile when user is logged in but profile is missing
$: if (isLoggedIn && userPubkey && !userProfile) {
fetchProfileIfMissing();
}
async function fetchProfileIfMissing() {
if (!isLoggedIn || !userPubkey || userProfile) {
return; // Don't fetch if not logged in, no pubkey, or profile already exists
}
try {
console.log("Auto-fetching profile for:", userPubkey);
await initializeNostrClient();
userProfile = await fetchUserProfile(userPubkey);
console.log("Profile auto-loaded:", userProfile);
} catch (error) {
console.error("Failed to auto-load profile:", error);
}
}
async function fetchUserRole() {
if (!isLoggedIn || !userPubkey) {
userRole = "";
return;
}
try {
const response = await fetch(`/api/permissions/${userPubkey}`);
if (response.ok) {
const data = await response.json();
userRole = data.permission || "";
console.log("User role loaded:", userRole);
console.log("Is owner?", userRole === "owner");
} else {
console.error("Failed to fetch user role:", response.status);
userRole = "";
}
} catch (error) {
console.error("Error fetching user role:", error);
userRole = "";
}
}
async function fetchACLMode() {
try {
const response = await fetch("/api/acl-mode");
if (response.ok) {
const data = await response.json();
aclMode = data.acl_mode || "";
console.log("ACL mode loaded:", aclMode);
} else {
console.error("Failed to fetch ACL mode:", response.status);
aclMode = "";
}
} catch (error) {
console.error("Error fetching ACL mode:", error);
aclMode = "";
}
}
// Export functionality
async function exportEvents(pubkeys = []) {
if (!isLoggedIn) {
alert("Please log in first");
return;
}
// Check permissions for exporting all events using current effective role
if (
pubkeys.length === 0 &&
currentEffectiveRole !== "admin" &&
currentEffectiveRole !== "owner"
) {
alert("Admin or owner permission required to export all events");
return;
}
try {
const authHeader = await createNIP98AuthHeader(
"/api/export",
"POST",
);
const response = await fetch("/api/export", {
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify({ pubkeys }),
});
if (!response.ok) {
throw new Error(
`Export failed: ${response.status} ${response.statusText}`,
);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Get filename from response headers or use default
const contentDisposition = response.headers.get(
"Content-Disposition",
);
let filename = "events.jsonl";
if (contentDisposition) {
const filenameMatch =
contentDisposition.match(/filename="([^"]+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Export failed:", error);
alert("Export failed: " + error.message);
}
}
async function exportAllEvents() {
await exportEvents([]); // Empty array means export all events
}
async function exportMyEvents() {
await exportEvents([userPubkey]); // Export only current user's events
}
// Import functionality
function handleFileSelect(event) {
selectedFile = event.target.files[0];
}
async function importEvents() {
if (!isLoggedIn || (userRole !== "admin" && userRole !== "owner")) {
alert("Admin or owner permission required");
return;
}
if (!selectedFile) {
alert("Please select a file");
return;
}
try {
const authHeader = await createNIP98AuthHeader(
"/api/import",
"POST",
);
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch("/api/import", {
method: "POST",
headers: {
Authorization: authHeader,
},
body: formData,
});
if (!response.ok) {
throw new Error(
`Import failed: ${response.status} ${response.statusText}`,
);
}
const result = await response.json();
alert("Import started successfully");
selectedFile = null;
document.getElementById("import-file").value = "";
} catch (error) {
console.error("Import failed:", error);
alert("Import failed: " + error.message);
}
}
// Events loading functionality
async function loadMyEvents(reset = false) {
if (!isLoggedIn) {
alert("Please log in first");
return;
}
if (isLoadingMyEvents) return;
// Always load fresh data when feed becomes visible (reset = true)
// Skip cache check to ensure fresh data every time
isLoadingMyEvents = true;
// Reset timestamps when doing a fresh load
if (reset) {
oldestMyEventTimestamp = null;
newestMyEventTimestamp = null;
}
try {
// Use WebSocket REQ to fetch user events with timestamp-based pagination
// Load 1000 events on initial load, otherwise use 200 for pagination
const events = await fetchUserEvents(userPubkey, {
limit: reset ? 1000 : 200,
until: reset ? null : oldestMyEventTimestamp,
});
if (reset) {
myEvents = events.sort((a, b) => b.created_at - a.created_at);
} else {
myEvents = [...myEvents, ...events].sort(
(a, b) => b.created_at - a.created_at,
);
}
// Update oldest timestamp for next pagination
if (events.length > 0) {
const oldestInBatch = Math.min(
...events.map((e) => e.created_at),
);
if (
!oldestMyEventTimestamp ||
oldestInBatch < oldestMyEventTimestamp
) {
oldestMyEventTimestamp = oldestInBatch;
}
}
hasMoreMyEvents = events.length === (reset ? 1000 : 200);
// Auto-load more events if content doesn't fill viewport and more events are available
// Only do this on initial load (reset = true) to avoid interfering with scroll-based loading
if (reset && hasMoreMyEvents) {
setTimeout(() => {
// Only check viewport if we're currently on the My Events tab
if (selectedTab === "myevents") {
const eventsContainers = document.querySelectorAll(
".events-view-content",
);
// The My Events container should be the first one (before All Events)
const myEventsContainer = eventsContainers[0];
if (
myEventsContainer &&
myEventsContainer.scrollHeight <=
myEventsContainer.clientHeight
) {
// Content doesn't fill viewport, load more automatically
loadMoreMyEvents();
}
}
}, 100); // Small delay to ensure DOM is updated
}
} catch (error) {
console.error("Failed to load events:", error);
alert("Failed to load events: " + error.message);
} finally {
isLoadingMyEvents = false;
}
}
async function loadMoreMyEvents() {
if (!isLoadingMyEvents && hasMoreMyEvents) {
await loadMyEvents(false);
}
}
function handleMyEventsScroll(event) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
const threshold = 100; // Load more when 100px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMoreMyEvents();
}
}
async function loadAllEvents(reset = false, authors = null) {
if (
!isLoggedIn ||
(userRole !== "write" &&
userRole !== "admin" &&
userRole !== "owner")
) {
alert("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",
);
const events = await fetchAllEvents({
limit: reset ? 100 : 200,
until: reset
? Math.floor(Date.now() / 1000)
: oldestEventTimestamp,
authors: authors,
});
console.log("Received events:", events.length, "events");
if (authors && events.length > 0) {
const nonUserEvents = events.filter(
(event) => event.pubkey && event.pubkey !== userPubkey,
);
if (nonUserEvents.length > 0) {
console.warn(
"Server returned non-user events:",
nonUserEvents.length,
"out of",
events.length,
);
}
}
if (reset) {
allEvents = events.sort((a, b) => b.created_at - a.created_at);
// Update global cache
updateGlobalCache(events);
} else {
allEvents = [...allEvents, ...events].sort(
(a, b) => b.created_at - a.created_at,
);
// Update global cache with all events
updateGlobalCache(allEvents);
}
// Update oldest timestamp for next pagination
if (events.length > 0) {
const oldestInBatch = Math.min(
...events.map((e) => e.created_at),
);
if (
!oldestEventTimestamp ||
oldestInBatch < oldestEventTimestamp
) {
oldestEventTimestamp = oldestInBatch;
}
}
hasMoreEvents = events.length === (reset ? 1000 : 200);
// Auto-load more events if content doesn't fill viewport and more events are available
// Only do this on initial load (reset = true) to avoid interfering with scroll-based loading
if (reset && hasMoreEvents) {
setTimeout(() => {
// Only check viewport if we're currently on the All Events tab
if (selectedTab === "events") {
const eventsContainers = document.querySelectorAll(
".events-view-content",
);
// The All Events container should be the first one (only container now)
const allEventsContainer = eventsContainers[0];
if (
allEventsContainer &&
allEventsContainer.scrollHeight <=
allEventsContainer.clientHeight
) {
// Content doesn't fill viewport, load more automatically
loadMoreEvents();
}
}
}, 100); // Small delay to ensure DOM is updated
}
} catch (error) {
console.error("Failed to load events:", error);
alert("Failed to load events: " + error.message);
} finally {
isLoadingEvents = false;
}
}
async function loadMoreEvents() {
await loadAllEvents(false);
}
function handleScroll(event) {
const { scrollTop, scrollHeight, clientHeight } = event.target;
const threshold = 100; // Load more when 100px from bottom
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadMoreEvents();
}
}
// Load events when events tab is selected (only if no events loaded yet)
$: if (
selectedTab === "events" &&
isLoggedIn &&
(userRole === "write" ||
userRole === "admin" ||
userRole === "owner") &&
allEvents.length === 0
) {
const authors = showOnlyMyEvents && userPubkey ? [userPubkey] : null;
loadAllEvents(true, authors);
}
// NIP-98 authentication helper
async function createNIP98AuthHeader(url, method) {
if (!isLoggedIn || !userPubkey) {
throw new Error("Not logged in");
}
// Create NIP-98 auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", window.location.origin + url],
["method", method.toUpperCase()],
],
content: "",
pubkey: userPubkey,
};
let signedEvent;
if (userSigner && authMethod === "extension") {
// Use the signer from the extension
try {
signedEvent = await userSigner.signEvent(authEvent);
} catch (error) {
throw new Error(
"Failed to sign with extension: " + error.message,
);
}
} else if (authMethod === "nsec") {
// For nsec method, we need to implement proper signing
// For now, create a mock signature (in production, use proper crypto)
authEvent.id = "mock-id-" + Date.now();
authEvent.sig = "mock-signature-" + Date.now();
signedEvent = authEvent;
} else {
throw new Error("No valid signer available");
}
// Encode as base64
const eventJson = JSON.stringify(signedEvent);
const base64Event = btoa(eventJson);
return `Nostr ${base64Event}`;
}
// NIP-98 authentication helper (for sprocket functions)
async function createNIP98Auth(method, url) {
if (!isLoggedIn || !userPubkey) {
throw new Error("Not logged in");
}
// Create NIP-98 auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", window.location.origin + url],
["method", method.toUpperCase()],
],
content: "",
pubkey: userPubkey,
};
let signedEvent;
if (userSigner && authMethod === "extension") {
// Use the signer from the extension
try {
signedEvent = await userSigner.signEvent(authEvent);
} catch (error) {
throw new Error(
"Failed to sign with extension: " + error.message,
);
}
} else if (authMethod === "nsec") {
// For nsec method, we need to implement proper signing
// For now, create a mock signature (in production, use proper crypto)
authEvent.id = "mock-id-" + Date.now();
authEvent.sig = "mock-signature-" + Date.now();
signedEvent = authEvent;
} else {
throw new Error("No valid signer available");
}
// Encode as base64
const eventJson = JSON.stringify(signedEvent);
const base64Event = btoa(eventJson);
return base64Event;
}
// Compose tab functions
function reformatJson() {
try {
if (!composeEventJson.trim()) {
alert("Please enter some JSON to reformat");
return;
}
const parsed = JSON.parse(composeEventJson);
composeEventJson = JSON.stringify(parsed, null, 2);
} catch (error) {
alert("Invalid JSON: " + error.message);
}
}
async function signEvent() {
try {
if (!composeEventJson.trim()) {
alert("Please enter an event to sign");
return;
}
if (!isLoggedIn || !userPubkey) {
alert("Please log in to sign events");
return;
}
if (!userSigner) {
alert(
"No signer available. Please log in with a valid authentication method.",
);
return;
}
const event = JSON.parse(composeEventJson);
// Update event with current user's pubkey and timestamp
event.pubkey = userPubkey;
event.created_at = Math.floor(Date.now() / 1000);
// Remove any existing id and sig to ensure fresh signing
delete event.id;
delete event.sig;
// Sign the event using the real signer
const signedEvent = await userSigner.signEvent(event);
// Update the compose area with the signed event
composeEventJson = JSON.stringify(signedEvent, null, 2);
// Show success feedback
alert("Event signed successfully!");
} catch (error) {
console.error("Error signing event:", error);
alert("Error signing event: " + error.message);
}
}
async function publishEvent() {
try {
if (!composeEventJson.trim()) {
alert("Please enter an event to publish");
return;
}
if (!isLoggedIn) {
alert("Please log in to publish events");
return;
}
if (!userSigner) {
alert(
"No signer available. Please log in with a valid authentication method.",
);
return;
}
const event = JSON.parse(composeEventJson);
// Validate that the event has required fields
if (!event.id || !event.sig) {
alert(
'Event must be signed before publishing. Please click "Sign" first.',
);
return;
}
// Publish to the ORLY relay using WebSocket (same address as current page)
const relayUrl = `wss://${window.location.host}`;
// Use the authentication module to publish the event
const result = await publishEventWithAuth(
relayUrl,
event,
userSigner,
userPubkey,
);
if (result.success) {
alert("Event published successfully to ORLY relay!");
// Optionally clear the editor after successful publish
// composeEventJson = '';
} else {
alert(
`Event publishing failed: ${result.reason || "Unknown error"}`,
);
}
} catch (error) {
console.error("Error publishing event:", error);
alert("Error publishing event: " + error.message);
}
}
// Persist selected tab to local storage
$: {
localStorage.setItem("selectedTab", selectedTab);
}
// Handle permission view switching
function setViewAsRole(role) {
viewAsRole = role;
localStorage.setItem("viewAsRole", role);
console.log(
"View as role changed to:",
role,
"Current effective role:",
currentEffectiveRole,
);
}
// Reactive statement for current effective role
$: currentEffectiveRole =
viewAsRole && viewAsRole !== "" ? viewAsRole : userRole;
// Initialize viewAsRole from local storage if available
viewAsRole = localStorage.getItem("viewAsRole") || "";
// Get available roles based on user's actual role
function getAvailableRoles() {
const allRoles = ["owner", "admin", "write", "read"];
const userRoleIndex = allRoles.indexOf(userRole);
if (userRoleIndex === -1) return ["read"]; // Default to read if role not found
return allRoles.slice(userRoleIndex); // Return current role and all lower roles
}
</script>
<!-- Header -->
<header class="main-header" class:dark-theme={isDarkTheme}>
<div class="header-content">
<img src="/orly.png" alt="ORLY Logo" class="logo" />
{#if isSearchMode}
<div class="search-input-container">
<input
type="text"
class="search-input"
bind:value={searchQuery}
on:keydown={handleSearchKeydown}
placeholder="Search..."
/>
</div>
{:else}
<div class="header-title">
<span class="app-title">
ORLY? dashboard
{#if isLoggedIn && userRole}
<span class="permission-badge"
>{currentEffectiveRole}</span
>
{/if}
</span>
</div>
{/if}
<button class="search-btn" on:click={toggleSearchMode}> 🔍 </button>
<button class="theme-toggle-btn" on:click={toggleTheme}>
{isDarkTheme ? "☀" : "🌙"}
</button>
{#if isLoggedIn}
<div class="user-info">
<button class="user-profile-btn" on:click={openSettingsDrawer}>
{#if userProfile?.picture}
<img
src={userProfile.picture}
alt="User avatar"
class="user-avatar"
/>
{:else}
<div class="user-avatar-placeholder">👤</div>
{/if}
<span class="user-name">
{userProfile?.name || userPubkey.slice(0, 8) + "..."}
</span>
</button>
</div>
{:else}
<button class="login-btn" on:click={openLoginModal}>Log in</button>
{/if}
</div>
</header>
<!-- Main Content Area -->
<div class="app-container" class:dark-theme={isDarkTheme}>
<!-- Sidebar -->
<aside class="sidebar" class:dark-theme={isDarkTheme}>
<div class="sidebar-content">
<div class="tabs">
{#each tabs as tab}
<button
class="tab"
class:active={selectedTab === tab.id}
on:click={() => selectTab(tab.id)}
>
<span class="tab-icon">{tab.icon}</span>
<span class="tab-label">{tab.label}</span>
{#if tab.isSearchTab}
<span
class="tab-close-icon"
on:click|stopPropagation={() =>
closeSearchTab(tab.id)}
on:keydown={(e) =>
e.key === "Enter" && closeSearchTab(tab.id)}
role="button"
tabindex="0"></span
>
{/if}
</button>
{/each}
</div>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
{#if selectedTab === "export"}
{#if isLoggedIn}
<div class="export-section">
<h3>Export My Events</h3>
<p>Download your personal events as a JSONL file.</p>
<button class="export-btn" on:click={exportMyEvents}>
📤 Export My Events
</button>
</div>
{#if currentEffectiveRole === "admin" || currentEffectiveRole === "owner"}
<div class="export-section">
<h3>Export All Events</h3>
<p>
Download the complete database as a JSONL file. This
includes all events from all users.
</p>
<button class="export-btn" on:click={exportAllEvents}>
📤 Export All Events
</button>
</div>
{/if}
{:else}
<div class="login-prompt">
<p>Please log in to access export functionality.</p>
<button class="login-btn" on:click={openLoginModal}
>Log In</button
>
</div>
{/if}
{:else if selectedTab === "import"}
<div class="import-section">
{#if isLoggedIn && (currentEffectiveRole === "admin" || currentEffectiveRole === "owner")}
<h3>Import Events</h3>
<p>
Upload a JSONL file to import events into the database.
</p>
<div class="recovery-controls-card">
<input
type="file"
id="import-file"
accept=".jsonl,.txt"
on:change={handleFileSelect}
/>
<button
class="import-btn"
on:click={importEvents}
disabled={!selectedFile}
>
Import Events
</button>
</div>
{:else if isLoggedIn}
<div class="permission-denied">
<h3 class="recovery-header">Import Events</h3>
<p class="recovery-description">
❌ Admin or owner permission required for import
functionality.
</p>
</div>
{:else}
<div class="login-prompt">
<h3 class="recovery-header">Import Events</h3>
<p class="recovery-description">
Please log in to access import functionality.
</p>
<button class="login-btn" on:click={openLoginModal}
>Log In</button
>
</div>
{/if}
</div>
{:else if selectedTab === "events"}
<div class="events-view-container">
{#if isLoggedIn && (userRole === "write" || userRole === "admin" || userRole === "owner")}
<div class="events-view-content" on:scroll={handleScroll}>
{#if filteredEvents.length > 0}
{#each filteredEvents as event}
<div
class="events-view-item"
class:expanded={expandedEvents.has(
event.id,
)}
>
<div
class="events-view-row"
on:click={() =>
toggleEventExpansion(event.id)}
on:keydown={(e) =>
e.key === "Enter" &&
toggleEventExpansion(event.id)}
role="button"
tabindex="0"
>
<div class="events-view-avatar">
<div class="avatar-placeholder">
👤
</div>
</div>
<div class="events-view-info">
<div class="events-view-author">
{truncatePubkey(event.pubkey)}
</div>
<div class="events-view-kind">
<span
class="kind-number"
class:delete-event={event.kind ===
5}>{event.kind}</span
>
<span class="kind-name"
>{getKindName(
event.kind,
)}</span
>
</div>
</div>
<div class="events-view-content">
<div class="event-timestamp">
{formatTimestamp(
event.created_at,
)}
</div>
{#if event.kind === 5}
<div class="delete-event-info">
<span
class="delete-event-label"
>🗑 Delete Event</span
>
{#if event.tags && event.tags.length > 0}
<div
class="delete-targets"
>
{#each event.tags.filter((tag) => tag[0] === "e") as eTag}
<span
class="delete-target"
>Target: {eTag[1].slice(
0,
8,
)}...{eTag[1].slice(
-8,
)}</span
>
{/each}
</div>
{/if}
</div>
{:else}
<div
class="event-content-single-line"
>
{truncateContent(
event.content,
)}
</div>
{/if}
</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="events-view-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 !isLoadingEvents}
<div class="no-events">
<p>No events found.</p>
</div>
{/if}
{#if isLoadingEvents}
<div class="loading-events">
<div class="loading-spinner"></div>
<p>Loading events...</p>
</div>
{/if}
{#if !hasMoreEvents && allEvents.length > 0}
<div class="end-of-events">
<p>No more events to load.</p>
</div>
{/if}
</div>
{:else}
<div class="permission-denied">
<p>
❌ Write, admin, or owner permission required to
view all events.
</p>
</div>
{/if}
{#if isLoggedIn && (userRole === "write" || userRole === "admin" || userRole === "owner")}
<div class="events-view-header">
<div class="events-view-toggle">
<label class="toggle-container">
<input
type="checkbox"
bind:checked={showOnlyMyEvents}
on:change={() => handleToggleChange()}
/>
<span class="toggle-slider"></span>
<span class="toggle-label"
>Only show my events</span
>
</label>
</div>
<div class="events-view-buttons">
<button
class="refresh-btn"
on:click={() => {
const authors =
showOnlyMyEvents && userPubkey
? [userPubkey]
: null;
loadAllEvents(false, authors);
}}
disabled={isLoadingEvents}
>
🔄 Load More
</button>
<button
class="reload-btn"
on:click={() => {
const authors =
showOnlyMyEvents && userPubkey
? [userPubkey]
: null;
loadAllEvents(true, authors);
}}
disabled={isLoadingEvents}
>
{#if isLoadingEvents}
<div class="spinner"></div>
{:else}
🔄
{/if}
</button>
</div>
</div>
{/if}
</div>
{:else if selectedTab === "compose"}
<div class="compose-view">
<div class="compose-header">
<button
class="compose-btn reformat-btn"
on:click={reformatJson}>Reformat</button
>
<button class="compose-btn sign-btn" on:click={signEvent}
>Sign</button
>
<button
class="compose-btn publish-btn"
on:click={publishEvent}>Publish</button
>
</div>
<div class="compose-editor">
<textarea
bind:value={composeEventJson}
class="compose-textarea"
placeholder="Enter your Nostr event JSON here..."
spellcheck="false"
></textarea>
</div>
</div>
{:else if selectedTab === "managed-acl"}
<div class="managed-acl-view">
{#if aclMode !== "managed"}
<div class="acl-mode-warning">
<h3> Managed ACL Mode Not Active</h3>
<p>
To use the Managed ACL interface, you need to set
the ACL mode to "managed" in your relay
configuration.
</p>
<p>
Current ACL mode: <strong
>{aclMode || "unknown"}</strong
>
</p>
<p>
Please set <code>ORLY_ACL_MODE=managed</code> in your
environment variables and restart the relay.
</p>
</div>
{:else if isLoggedIn && userRole === "owner"}
<ManagedACL {userSigner} {userPubkey} />
{:else}
<div class="access-denied">
<p>
Please log in with owner permissions to access
managed ACL configuration.
</p>
<button class="login-btn" on:click={openLoginModal}
>Log In</button
>
</div>
{/if}
</div>
{:else if selectedTab === "sprocket"}
<div class="sprocket-view">
<h2>Sprocket Script Management</h2>
{#if isLoggedIn && userRole === "owner"}
<div class="sprocket-section">
<div class="sprocket-header">
<h3>Script Editor</h3>
<div class="sprocket-controls">
<button
class="sprocket-btn restart-btn"
on:click={restartSprocket}
disabled={isLoadingSprocket}
>
🔄 Restart
</button>
<button
class="sprocket-btn delete-btn"
on:click={deleteSprocket}
disabled={isLoadingSprocket ||
!sprocketStatus?.script_exists}
>
🗑 Delete Script
</button>
</div>
</div>
<div class="sprocket-upload-section">
<h4>Upload Script</h4>
<div class="upload-controls">
<input
type="file"
id="sprocket-upload-file"
accept=".sh,.bash"
on:change={handleSprocketFileSelect}
disabled={isLoadingSprocket}
/>
<button
class="sprocket-btn upload-btn"
on:click={uploadSprocketScript}
disabled={isLoadingSprocket ||
!sprocketUploadFile}
>
📤 Upload & Update
</button>
</div>
</div>
<div class="sprocket-status">
<div class="status-item">
<span class="status-label">Status:</span>
<span
class="status-value"
class:running={sprocketStatus?.is_running}
>
{sprocketStatus?.is_running
? "🟢 Running"
: "🔴 Stopped"}
</span>
</div>
{#if sprocketStatus?.pid}
<div class="status-item">
<span class="status-label">PID:</span>
<span class="status-value"
>{sprocketStatus.pid}</span
>
</div>
{/if}
<div class="status-item">
<span class="status-label">Script:</span>
<span class="status-value"
>{sprocketStatus?.script_exists
? "✅ Exists"
: "❌ Not found"}</span
>
</div>
</div>
<div class="script-editor-container">
<textarea
class="script-editor"
bind:value={sprocketScript}
placeholder="#!/bin/bash&#10;# Enter your sprocket script here..."
disabled={isLoadingSprocket}
></textarea>
</div>
<div class="script-actions">
<button
class="sprocket-btn save-btn"
on:click={saveSprocket}
disabled={isLoadingSprocket}
>
💾 Save & Update
</button>
<button
class="sprocket-btn load-btn"
on:click={loadSprocket}
disabled={isLoadingSprocket}
>
📥 Load Current
</button>
</div>
{#if sprocketMessage}
<div
class="sprocket-message"
class:error={sprocketMessageType === "error"}
>
{sprocketMessage}
</div>
{/if}
</div>
<div class="sprocket-section">
<h3>Script Versions</h3>
<div class="versions-list">
{#each sprocketVersions as version}
<div
class="version-item"
class:current={version.is_current}
>
<div class="version-info">
<div class="version-name">
{version.name}
</div>
<div class="version-date">
{new Date(
version.modified,
).toLocaleString()}
{#if version.is_current}
<span class="current-badge"
>Current</span
>
{/if}
</div>
</div>
<div class="version-actions">
<button
class="version-btn load-btn"
on:click={() =>
loadVersion(version)}
disabled={isLoadingSprocket}
>
📥 Load
</button>
{#if !version.is_current}
<button
class="version-btn delete-btn"
on:click={() =>
deleteVersion(version.name)}
disabled={isLoadingSprocket}
>
🗑 Delete
</button>
{/if}
</div>
</div>
{/each}
</div>
<button
class="sprocket-btn refresh-btn"
on:click={loadVersions}
disabled={isLoadingSprocket}
>
🔄 Refresh Versions
</button>
</div>
{:else if isLoggedIn}
<div class="permission-denied">
<p>
❌ Owner permission required for sprocket
management.
</p>
<p>
To enable sprocket functionality, set the <code
>ORLY_OWNERS</code
> environment variable with your npub when starting the
relay.
</p>
<p>
Current user role: <strong
>{userRole || "none"}</strong
>
</p>
</div>
{:else}
<div class="login-prompt">
<p>Please log in to access sprocket management.</p>
<button class="login-btn" on:click={openLoginModal}
>Log In</button
>
</div>
{/if}
</div>
{: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-button"
on:click={() =>
repostEvent(event)}
>
🔄 Repost
</button>
{/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>🔍 Search Results: "{searchTab.query}"</h2>
<button
class="refresh-btn"
on:click={() =>
loadSearchResults(
searchTab.id,
searchTab.query,
true,
)}
disabled={searchResults.get(searchTab.id)
?.isLoading}
>
🔄 Refresh
</button>
</div>
<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 for "{searchTab.query}".
</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">
<h3>Profile Loading</h3>
<p>Your profile metadata is being loaded...</p>
<button
class="retry-profile-btn"
on:click={fetchProfileIfMissing}
>
Retry Loading Profile
</button>
<div class="user-pubkey-display">
<strong>Public Key:</strong>
{userPubkey.slice(0, 16)}...{userPubkey.slice(-8)}
</div>
</div>
{/if}
<!-- Additional settings can be added here -->
</div>
</div>
</div>
{/if}
<!-- Login Modal -->
<LoginModal
bind:showModal={showLoginModal}
{isDarkTheme}
on:login={handleLogin}
on:close={closeLoginModal}
/>
<style>
:global(body) {
margin: 0;
padding: 0;
--bg-color: #ddd;
--header-bg: #eee;
--border-color: #dee2e6;
--text-color: #444444;
--input-border: #ccc;
--button-bg: #ddd;
--button-hover-bg: #eee;
--primary: #00bcd4;
--warning: #ff3e00;
--tab-inactive-bg: #bbb;
}
:global(body.dark-theme) {
--bg-color: #263238;
--header-bg: #1e272c;
--border-color: #404040;
--text-color: #ffffff;
--input-border: #555;
--button-bg: #263238;
--button-hover-bg: #1e272c;
--primary: #00bcd4;
--warning: #ff3e00;
--tab-inactive-bg: #1a1a1a;
}
/* Header Styles */
.main-header {
height: 3em;
background-color: var(--header-bg);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
color: var(--text-color);
}
.header-content {
height: 100%;
display: flex;
align-items: center;
padding: 0;
gap: 0;
}
.logo {
height: 2.5em;
width: 2.5em;
object-fit: contain;
flex-shrink: 0;
transition: opacity 0.2s ease;
}
.logo:hover {
content: url("/favicon.png");
}
.header-title {
flex: 1;
height: 100%;
display: flex;
align-items: center;
gap: 0;
padding: 0 1rem;
}
.app-title {
font-size: 1em;
font-weight: 600;
color: var(--text-color);
display: flex;
align-items: center;
gap: 0.5rem;
}
.permission-badge {
font-size: 0.7em;
font-weight: 500;
padding: 0.2em 0.5em;
border-radius: 0.3em;
background-color: var(--primary);
color: white;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.search-input-container {
flex: 1;
height: 100%;
display: flex;
align-items: center;
padding: 0 1rem;
}
.search-input {
width: 100%;
height: 2em;
padding: 0.5rem;
border: 1px solid var(--input-border);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 1em;
outline: none;
}
.search-input:focus {
border-color: var(--primary);
}
.search-btn {
border: 0 none;
border-radius: 0;
display: flex;
align-items: center;
background-color: var(--button-hover-bg);
cursor: pointer;
color: var(--text-color);
height: 3em;
width: auto;
min-width: 3em;
flex-shrink: 0;
line-height: 1;
transition: background-color 0.2s;
justify-content: center;
padding: 1em 1em 1em 1em;
margin: 0;
}
.search-btn:hover {
background-color: var(--button-bg);
}
.theme-toggle-btn {
border: 0 none;
border-radius: 0;
display: flex;
align-items: center;
background-color: var(--button-hover-bg);
cursor: pointer;
color: var(--text-color);
height: 3em;
width: auto;
min-width: 3em;
flex-shrink: 0;
line-height: 1;
transition: background-color 0.2s;
justify-content: center;
padding: 1em 1em 1em 1em;
margin: 0;
}
.theme-toggle-btn:hover {
background-color: var(--button-bg);
}
.login-btn {
padding: 0.5em 1em;
border: none;
border-radius: 6px;
background-color: #4caf50;
color: white;
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);
}
/* Sidebar Styles */
.sidebar {
position: fixed;
left: 0;
top: 3em;
bottom: 0;
width: 200px;
background-color: var(--header-bg);
color: var(--text-color);
z-index: 100;
}
.sidebar-content {
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
}
.tabs {
display: flex;
flex-direction: column;
}
.tab {
height: 3em;
display: flex;
align-items: center;
padding: 0 1rem;
cursor: pointer;
border: none;
background: transparent;
color: var(--text-color);
transition: background-color 0.2s ease;
gap: 0.75rem;
text-align: left;
width: 100%;
}
.tab:hover {
background-color: var(--bg-color);
}
.tab.active {
background-color: var(--bg-color);
}
.tab-icon {
font-size: 1.2em;
flex-shrink: 0;
width: 1.5em;
text-align: center;
}
.tab-label {
font-size: 0.9em;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.tab-close-icon {
cursor: pointer;
transition: opacity 0.2s;
font-size: 0.8em;
margin-left: auto;
padding: 0.25rem;
border-radius: 0.25rem;
flex-shrink: 0;
}
.tab-close-icon:hover {
opacity: 0.7;
background-color: var(--warning);
color: white;
}
/* Main Content */
.main-content {
position: fixed;
left: 200px;
top: 3em;
right: 0;
bottom: 0;
padding: 1em;
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;
}
/* Sprocket Styles */
.sprocket-view {
width: 100%;
max-width: 1200px;
margin: 0;
padding: 20px;
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
}
.sprocket-section {
background-color: var(--card-bg);
border-radius: 8px;
padding: 1em;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
width: 32em;
}
.sprocket-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.sprocket-controls {
display: flex;
gap: 0.5rem;
}
.sprocket-upload-section {
margin-bottom: 1rem;
padding: 1rem;
background-color: var(--bg-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.sprocket-upload-section h4 {
margin: 0 0 0.75rem 0;
color: var(--text-color);
font-size: 1rem;
font-weight: 500;
}
.upload-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.upload-controls input[type="file"] {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
}
.sprocket-btn.upload-btn {
background-color: #8b5cf6;
color: white;
}
.sprocket-btn.upload-btn:hover:not(:disabled) {
background-color: #7c3aed;
}
.sprocket-status {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background-color: var(--bg-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-label {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.status-value {
font-size: 0.9rem;
font-weight: 600;
}
.status-value.running {
color: #22c55e;
}
.script-editor-container {
margin-bottom: 1rem;
}
.script-editor {
width: 100%;
height: 300px;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--bg-color);
color: var(--text-color);
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9rem;
line-height: 1.4;
resize: vertical;
outline: none;
}
.script-editor:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.script-editor:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.script-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.sprocket-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sprocket-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sprocket-btn.save-btn {
background-color: #22c55e;
color: white;
}
.sprocket-btn.save-btn:hover:not(:disabled) {
background-color: #16a34a;
}
.sprocket-btn.load-btn {
background-color: #3b82f6;
color: white;
}
.sprocket-btn.load-btn:hover:not(:disabled) {
background-color: #2563eb;
}
.sprocket-btn.restart-btn {
background-color: #f59e0b;
color: white;
}
.sprocket-btn.restart-btn:hover:not(:disabled) {
background-color: #d97706;
}
.sprocket-btn.delete-btn {
background-color: #ef4444;
color: white;
}
.sprocket-btn.delete-btn:hover:not(:disabled) {
background-color: #dc2626;
}
.sprocket-btn.refresh-btn {
background-color: #6b7280;
color: white;
}
.sprocket-btn.refresh-btn:hover:not(:disabled) {
background-color: #4b5563;
}
.sprocket-message {
padding: 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
background-color: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}
.sprocket-message.error {
background-color: #fee2e2;
color: #dc2626;
border-color: #fca5a5;
}
.versions-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s ease;
}
.version-item.current {
border-color: var(--primary-color);
background-color: rgba(59, 130, 246, 0.05);
}
.version-item:hover {
border-color: var(--primary-color);
}
.version-info {
flex: 1;
}
.version-name {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.version-date {
font-size: 0.8rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.5rem;
}
.current-badge {
background-color: var(--primary-color);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
}
.version-actions {
display: flex;
gap: 0.5rem;
}
.version-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.25rem;
}
.version-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.version-btn.load-btn {
background-color: #3b82f6;
color: white;
}
.version-btn.load-btn:hover:not(:disabled) {
background-color: #2563eb;
}
.version-btn.delete-btn {
background-color: #ef4444;
color: white;
}
.version-btn.delete-btn:hover:not(:disabled) {
background-color: #dc2626;
}
@media (max-width: 640px) {
.header-content {
padding: 0;
}
.sidebar {
width: 160px;
}
.main-content {
left: 160px;
padding: 1rem;
}
}
/* User Info Styles */
.user-info {
display: flex;
align-items: flex-start;
padding: 0;
height: 3em;
}
.logout-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
background-color: var(--warning);
color: white;
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);
}
/* User Profile Button */
.user-profile-btn {
border: 0 none;
border-radius: 0;
display: flex;
align-items: center;
background-color: var(--button-hover-bg);
cursor: pointer;
color: var(--text-color);
height: 3em;
width: auto;
min-width: 3em;
flex-shrink: 0;
line-height: 1;
transition: background-color 0.2s;
justify-content: center;
padding: 0;
margin: 0;
}
.user-profile-btn:hover {
background-color: var(--button-bg);
padding: 0;
}
.user-avatar,
.user-avatar-placeholder {
width: 2.5em;
height: 2.5em;
object-fit: cover;
border-radius: 50%;
}
.user-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.5em;
padding: 0.5em;
}
.user-name {
font-size: 1.2em;
font-weight: 500;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.5em;
}
/* Settings Drawer */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
justify-content: flex-end;
}
.settings-drawer {
width: 640px;
height: 100%;
background: var(--bg-color);
/*border-left: 1px solid var(--border-color);*/
overflow-y: auto;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--header-bg);
}
.drawer-header h2 {
margin: 0;
color: var(--text-color);
font-size: 1em;
padding: 1rem;
}
.close-btn {
background: none;
border: none;
font-size: 1em;
cursor: pointer;
color: var(--text-color);
padding: 0.5em;
transition: background-color 0.2s;
align-items: center;
}
.close-btn:hover {
background: var(--button-hover-bg);
}
.profile-section {
margin-bottom: 2rem;
}
.profile-hero {
position: relative;
}
.profile-banner {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 0;
display: block;
}
/* Avatar sits half over the bottom edge of the banner */
.profile-avatar,
.profile-avatar-placeholder {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
border: 2px solid var(--bg-color);
}
.overlap {
position: absolute;
left: 12px;
bottom: -36px; /* half out of the banner */
z-index: 2;
background: var(--button-hover-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
/* Username and nip05 on the banner, to the right of avatar */
.name-row {
position: absolute;
left: calc(12px + 72px + 12px);
bottom: 8px;
right: 12px;
display: flex;
align-items: baseline;
gap: 8px;
z-index: 1;
}
.profile-username {
margin: 0;
font-size: 1.1rem;
color: var(--text-color); /* contrasting over banner */
text-shadow: 0 3px 6px rgba(255, 255, 255, 1);
}
.profile-nip05-inline {
font-size: 0.85rem;
color: var(--text-color); /* subtle but contrasting */
font-family: monospace;
opacity: 0.95;
text-shadow: 0 3px 6px rgba(255, 255, 255, 1);
}
/* About box below with overlap space for avatar */
.about-card {
background: var(--header-bg);
padding: 12px 12px 12px 96px; /* offset text from overlapping avatar */
position: relative;
word-break: auto-phrase;
}
.profile-about {
margin: 0;
color: var(--text-color);
font-size: 0.9rem;
line-height: 1.4;
}
.profile-loading-section {
padding: 1rem;
text-align: center;
}
.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: white;
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;
}
/* Export/Import Views */
.import-view {
padding: 1em;
max-width: 32em;
margin: 0;
background: transparent;
color: var(--text-color);
border-radius: 0;
}
/* Managed ACL View */
.managed-acl-view {
padding: 20px;
max-width: 1200px;
margin: 0;
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
}
.compose-view {
position: fixed;
top: 3em;
left: 200px;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
background: transparent;
}
.compose-header {
display: flex;
gap: 0.5em;
padding: 0.5em;
background: transparent;
border-bottom: 1px solid var(--border-color);
}
.compose-btn {
padding: 0.5em 1em;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
background: var(--button-bg);
color: var(--button-text);
cursor: pointer;
font-size: 0.9rem;
transition:
background-color 0.2s,
border-color 0.2s;
}
.compose-btn:hover {
background: var(--button-hover-bg);
border-color: var(--button-hover-border);
}
.publish-btn {
background: var(--accent-color, #007bff);
color: white;
border-color: var(--accent-color, #007bff);
}
.publish-btn:hover {
background: var(--accent-hover-color, #0056b3);
border-color: var(--accent-hover-color, #0056b3);
}
.compose-editor {
flex: 1;
padding: 0.5em;
}
.compose-textarea {
width: 100%;
height: 100%;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
background: var(--bg-color);
color: var(--text-color);
font-family: "Courier New", monospace;
font-size: 0.9rem;
padding: 1rem;
resize: none;
outline: none;
}
.compose-textarea:focus {
border-color: var(--accent-color, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.import-view h3 {
margin: 0 0 2rem 0;
color: var(--text-color);
font-size: 1.5rem;
font-weight: 600;
}
.export-section,
.import-section {
background: transparent;
padding: 1em;
border-radius: 8px;
margin-bottom: 1.5rem;
width: 32em;
}
.export-section h3,
.import-section h3 {
margin: 0 0 10px 0;
color: var(--text-color);
}
.export-section p,
.import-section p {
margin: 0;
color: var(--text-color);
opacity: 0.7;
padding: 0.5em;
}
.events-view-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
.export-btn,
.import-btn,
.refresh-btn,
.reload-btn {
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
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;
}
.export-btn:hover,
.import-btn:hover,
.refresh-btn:hover,
.reload-btn:hover {
background: #00acc1;
}
.reload-btn {
min-width: 2em;
justify-content: center;
}
.spinner {
width: 1em;
height: 1em;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.export-btn:disabled,
.import-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#import-file {
margin: 1rem 0;
padding: 0.5rem;
border: 1px solid var(--input-border);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 1rem;
}
.login-prompt {
text-align: center;
padding: 1em;
background: var(--header-bg);
border-radius: 8px;
}
.login-prompt p {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.1rem;
}
.permission-denied {
text-align: center;
padding: 1em;
background: var(--header-bg);
border-radius: 8px;
border: 2px solid var(--warning);
}
.permission-denied p {
margin: 0;
color: var(--warning);
font-size: 1.1rem;
font-weight: 500;
}
/* View as Section */
.view-as-section {
color: var(--text-color);
padding: 1rem;
border-radius: 8px;
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: 4px;
transition: background 0.2s;
}
.radio-label:hover {
background: rgba(255, 255, 255, 0.1);
}
.radio-label input {
margin: 0;
}
/* Events View Container */
.events-view-container {
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;
}
.events-view-header {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.5rem 1rem;
background: var(--header-bg);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
height: 2.5em;
z-index: 10;
}
.events-view-toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.toggle-container {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-color);
}
.toggle-container input[type="checkbox"] {
display: none;
}
.toggle-slider {
position: relative;
width: 2.5em;
height: 1.25em;
background: var(--border-color);
border-radius: 1.25em;
transition: background-color 0.3s;
}
.toggle-slider::before {
content: "";
position: absolute;
top: 0.125em;
left: 0.125em;
width: 1em;
height: 1em;
background: white;
border-radius: 50%;
transition: transform 0.3s;
}
.toggle-container input[type="checkbox"]:checked + .toggle-slider {
background: var(--primary);
}
.toggle-container input[type="checkbox"]:checked + .toggle-slider::before {
transform: translateX(1.25em);
}
.toggle-label {
font-size: 0.875rem;
font-weight: 500;
user-select: none;
}
.events-view-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
.events-view-item {
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
}
.events-view-item:hover {
background: var(--button-hover-bg);
}
.events-view-item.expanded {
background: var(--button-hover-bg);
}
.events-view-row {
display: flex;
align-items: center;
padding: 0.4rem 1rem;
cursor: pointer;
gap: 0.75rem;
min-height: 2rem;
}
.events-view-avatar {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.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;
}
.events-view-info {
flex-shrink: 0;
width: 12rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.events-view-author {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-color);
opacity: 0.8;
}
.events-view-kind {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind-number {
background: var(--primary);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
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;
}
.events-view-content {
flex: 1;
color: var(--text-color);
font-size: 0.9rem;
line-height: 1.3;
word-break: break-word;
}
.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.25rem;
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: white;
}
.kind-number.delete-event {
background: var(--warning);
}
.delete-event-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.delete-event-label {
font-weight: 500;
color: var(--warning);
}
.delete-targets {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.delete-target {
font-size: 0.75rem;
font-family: monospace;
color: var(--text-color);
opacity: 0.7;
}
.events-view-details {
border-top: 1px solid var(--border-color);
background: var(--header-bg);
padding: 1rem;
}
.json-container {
position: relative;
}
.copy-json-btn {
background: var(--active-tab-bg);
color: var(--button-text);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
transition:
background-color 0.2s,
border-color 0.2s;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
}
.copy-json-btn:hover {
background: var(--button-hover-bg);
border-color: var(--button-hover-border);
}
.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;
}
.no-events p {
margin: 0;
font-size: 1rem;
}
.loading-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);
}
}
.loading-events p {
margin: 0;
font-size: 0.9rem;
}
.end-of-events {
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-events p {
margin: 0;
}
/* 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) {
.sidebar {
width: 60px;
}
.main-content {
left: 60px;
}
.events-view-container {
left: 60px;
}
.compose-view {
left: 60px;
}
.search-results-view {
left: 60px;
}
.tab-label {
display: none;
}
.tab {
justify-content: center;
padding: 0 0.5rem;
}
.tab-icon {
width: 2em;
height: 2em;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
background-color: var(--bg-color);
}
.tab.active .tab-icon {
background-color: var(--primary);
color: white;
}
}
@media (max-width: 640px) {
.settings-drawer {
width: 100%;
}
.name-row {
left: calc(8px + 56px + 8px);
bottom: 6px;
right: 8px;
gap: 6px;
}
.profile-username {
font-size: 1rem;
color: var(--text-color);
}
.profile-nip05-inline {
font-size: 0.8rem;
color: var(--text-color);
}
.import-view {
padding: 1em;
}
.managed-acl-view {
padding: 1rem;
}
.export-section,
.import-section {
padding: 1em;
width: 32em;
}
.events-view-container {
left: 160px;
}
.compose-view {
left: 160px;
}
.events-view-info {
width: 8rem;
}
.events-view-author {
font-size: 0.7rem;
}
.kind-name {
font-size: 0.7rem;
}
.events-view-content {
font-size: 0.8rem;
}
.search-results-view {
left: 160px;
}
.search-result-info {
width: 8rem;
}
.search-result-author {
font-size: 0.7rem;
}
.search-result-content {
font-size: 0.8rem;
}
}
/* Recovery Tab Styles */
.recovery-tab {
padding: 20px;
max-width: 1200px;
margin: 0;
}
.recovery-header {
margin-bottom: 30px;
text-align: left;
}
.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;
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: 4px;
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: 4px;
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: 8px;
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-button {
background: var(--primary);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background 0.2s ease;
}
.repost-button:hover {
background: #00acc1;
}
.event-content {
margin-bottom: 15px;
}
.profile-content pre,
.generic-content pre {
background: var(--bg-color);
padding: 15px;
border-radius: 4px;
overflow-x: auto;
font-size: 0.9em;
margin: 0;
border: 1px solid var(--border-color);
white-space: pre-wrap;
word-wrap: break-word;
}
.follow-list p {
margin: 0 0 10px 0;
font-weight: 500;
color: var(--text-color);
}
.follow-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pubkey-tag {
background: var(--bg-color);
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
font-size: 0.8em;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.event-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
background: var(--bg-color);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
border: 1px solid var(--border-color);
}
.load-more {
width: 100%;
padding: 12px;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
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;
}
.tab-panel {
display: none;
padding: 1em;
background-color: var(--panel-bg);
border-radius: 0 0 8px 8px;
color: var(--text-color);
text-align: left;
}
.tab-panel.active {
display: block;
}
.recovery-header {
font-size: 1.5em;
font-weight: bold;
margin-bottom: 0.5em;
color: var(--header-text-color);
}
.recovery-description {
margin-bottom: 1.5em;
color: var(--description-text-color);
}
.recovery-controls-card {
background-color: transparent;
border-radius: 8px;
padding: 1em;
margin-bottom: 1em;
width: 100%;
}
.form-group {
margin-bottom: 1em;
}
.form-group label {
display: block;
margin-bottom: 0.5em;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.5em;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--input-bg);
color: var(--input-text-color);
}
.form-group button {
background-color: var(--primary);
color: white;
border: none;
padding: 0.75em 1.5em;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.form-group button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.status-message {
padding: 1em;
border-radius: 4px;
margin-top: 1em;
}
.status-message.success {
background-color: var(--success-bg);
color: var(--success-text);
}
.status-message.error {
background-color: var(--error-bg);
color: var(--error-text);
}
.export-results {
margin-top: 2em;
}
.export-results h3 {
margin-bottom: 1em;
}
.export-results button {
background-color: var(--primary);
color: white;
border: none;
padding: 0.75em 1.5em;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
margin-bottom: 1em;
}
.events-preview {
max-height: 400px;
overflow-y: auto;
}
</style>