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.
 
 
 
 
 
 

807 lines
24 KiB

<script>
export let isLoggedIn = false;
export let userRole = "";
export let userPubkey = "";
export let filteredEvents = [];
export let expandedEvents = new Set();
export let isLoadingEvents = false;
export let showOnlyMyEvents = false;
export let showFilterBuilder = false;
import { createEventDispatcher, onMount } from "svelte";
import FilterBuilder from "./FilterBuilder.svelte";
import { fetchUserProfile } from "./nostr.js";
import { getKindName, truncatePubkey } from "./helpers.js";
const dispatch = createEventDispatcher();
// Profile cache to avoid fetching the same profile multiple times
let profileCache = new Map();
// Local state for JSON editor toggle
let showJsonEditor = false;
function handleScroll(event) {
dispatch("scroll", event);
}
function toggleEventExpansion(eventId) {
dispatch("toggleEventExpansion", eventId);
}
function deleteEvent(eventId) {
dispatch("deleteEvent", eventId);
}
function copyEventToClipboard(event, e) {
dispatch("copyEventToClipboard", { event, e });
}
function handleToggleChange() {
dispatch("toggleChange");
}
function loadAllEvents(refresh, authors) {
dispatch("loadAllEvents", { refresh, authors });
}
function toggleFilterBuilder() {
dispatch("toggleFilterBuilder");
}
function toggleJsonEditor() {
showJsonEditor = !showJsonEditor;
}
function handleFilterApply(event) {
dispatch("filterApply", event.detail);
}
function handleFilterClear() {
dispatch("filterClear");
}
// Fetch profile for a pubkey (with caching)
async function getProfile(pubkey) {
if (!pubkey) return null;
// Check cache first
if (profileCache.has(pubkey)) {
return profileCache.get(pubkey);
}
// Fetch profile
try {
const profile = await fetchUserProfile(pubkey);
profileCache.set(pubkey, profile);
return profile;
} catch (error) {
console.warn("Failed to fetch profile for", pubkey, error);
// Cache null to avoid repeated failed fetches
profileCache.set(pubkey, null);
return null;
}
}
// Load profiles for all unique pubkeys in filteredEvents
$: if (filteredEvents.length > 0) {
const uniquePubkeys = new Set(
filteredEvents
.map(e => e.pubkey)
.filter(p => p && !profileCache.has(p))
);
// Fetch profiles for new pubkeys (fire-and-forget, errors are handled in getProfile)
uniquePubkeys.forEach(pubkey => {
getProfile(pubkey).catch(err => {
// Error already logged in getProfile, just prevent unhandled rejection
console.debug("Profile fetch failed (already handled):", pubkey);
});
});
}
function formatTimestamp(timestamp) {
return new Date(timestamp * 1000).toLocaleString();
}
function truncateContent(content) {
if (!content) return "";
return content.length > 100 ? content.slice(0, 100) + "..." : content;
}
</script>
<div class="events-view-container">
{#if isLoggedIn && (userRole === "read" || userRole === "write" || userRole === "admin" || userRole === "owner")}
<div class="events-view-content" on:scroll={handleScroll}>
{#if filteredEvents.length > 0}
{#each filteredEvents as event}
{@const profile = profileCache.get(event.pubkey)}
<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">
{#if profile?.picture}
<img
src={profile.picture}
alt={profile.name || truncatePubkey(event.pubkey)}
class="avatar-image"
/>
{:else}
<div class="avatar-placeholder">👤</div>
{/if}
</div>
<div class="events-view-info">
<div class="events-view-author">
{#if profile}
<span class="author-name">{profile.name || truncatePubkey(event.pubkey)}</span>
{#if profile.nip05}
<span class="author-nip05" title={profile.nip05}>@{profile.nip05}</span>
{/if}
<span class="author-pubkey" title={event.pubkey}>{truncatePubkey(event.pubkey)}</span>
{:else}
<span class="author-pubkey">{truncatePubkey(event.pubkey)}</span>
{/if}
</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="spinner"></div>
<p>Loading events...</p>
</div>
{/if}
</div>
{:else}
<div class="permission-denied">
<p>
❌ Read, write, admin, or owner permission required to view all
events.
</p>
</div>
{/if}
{#if isLoggedIn && (userRole === "read" || userRole === "write" || userRole === "admin" || userRole === "owner")}
<div class="events-view-footer">
<!-- Filter Builder Slide-up Panel -->
<div class="filter-panel" class:open={showFilterBuilder}>
<FilterBuilder
{showJsonEditor}
on:apply={handleFilterApply}
on:clear={handleFilterClear}
on:toggleJson={toggleJsonEditor}
/>
</div>
<div class="events-view-header">
<div class="events-view-left">
<button
class="filter-btn"
class:active={showFilterBuilder}
on:click={toggleFilterBuilder}
title="Filter events"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
</button>
<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>
<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>
</div>
{/if}
</div>
<style>
.events-view-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.events-view-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
/* Custom scrollbar styling */
.events-view-content::-webkit-scrollbar {
width: 16px;
background: var(--bg-color);
}
.events-view-content::-webkit-scrollbar-track {
background: var(--bg-color);
}
.events-view-content::-webkit-scrollbar-thumb {
background: var(--text-color);
border-radius: 9999px;
border: 4px solid var(--bg-color);
}
.events-view-content::-webkit-scrollbar-thumb:hover {
background: var(--text-color);
filter: brightness(1.2);
}
.events-view-content::-webkit-scrollbar-button {
background: var(--text-color);
height: 8px;
border: 4px solid var(--bg-color);
border-radius: 9999px;
background-clip: padding-box;
}
.events-view-item {
border: 0;
margin: 0;
transition: all 0.2s ease;
}
.events-view-item:hover {
padding: 0;
}
.events-view-row {
display: flex;
align-items: center;
padding: 0.5em;
cursor: pointer;
gap: 1em;
}
.events-view-avatar {
flex-shrink: 0;
}
.avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
border: 0;
}
.avatar-image {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border-color);
}
.events-view-info {
flex-shrink: 0;
min-width: 120px;
}
.events-view-author {
font-weight: 600;
color: var(--text-color);
font-size: 0.9em;
display: flex;
flex-direction: column;
gap: 0.2em;
}
.author-name {
font-weight: 600;
color: var(--text-color);
}
.author-nip05 {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
font-style: italic;
}
.author-pubkey {
font-size: 0.75em;
color: var(--text-color);
opacity: 0.6;
font-family: monospace;
}
.events-view-kind {
display: flex;
align-items: center;
gap: 0.5em;
margin-top: 0.25em;
}
.kind-number {
background: var(--card-bg);
color: var(--text-color);
padding: 0.1em 0.4em;
border: 1px solid var(--border-color);
font-size: 0.7em;
font-weight: 600;
font-family: monospace;
}
.kind-number.delete-event {
background: var(--danger);
}
.kind-name {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.8;
}
.event-timestamp {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
margin-bottom: 0.5em;
}
.delete-event-info {
background: var(--danger-bg);
padding: 0.5em;
border-radius: 4px;
border: 1px solid var(--danger);
}
.delete-event-label {
font-weight: 600;
color: var(--danger);
display: block;
margin-bottom: 0.25em;
}
.delete-targets {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
}
.delete-target {
background: var(--danger);
color: #ffffff;
padding: 0.1em 0.3em;
border-radius: 0.2rem;
font-size: 0.7em;
font-family: monospace;
}
.event-content-single-line {
color: var(--text-color);
line-height: 1.4;
word-wrap: break-word;
}
.delete-btn {
background: var(--danger);
color: var(--text-color);
border: none;
padding: 0.5em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
flex-shrink: 0;
transition: background-color 0.2s;
}
.delete-btn:hover {
background: var(--danger);
filter: brightness(0.9);
}
.events-view-details {
padding: 0;
background: var(--bg-color);
}
.json-container {
position: relative;
}
.event-json {
background: var(--code-bg);
padding: 1em;
border: 0;
font-size: 0.8em;
line-height: 1.4;
overflow-x: auto;
margin: 0;
color: var(--code-text);
}
.copy-json-btn {
position: absolute;
top: 1em;
right: 1em;
background: var(--primary);
color: var(--text-color);
border: none;
padding: 1em;
cursor: pointer;
font-size: 0.8em;
opacity: 0.8;
transition: opacity 0.2s;
}
.copy-json-btn:hover {
opacity: 1;
}
.no-events {
text-align: center;
padding: 2em;
color: var(--text-color);
opacity: 0.7;
}
.loading-events {
text-align: center;
padding: 2em;
color: var(--text-color);
}
.spinner {
width: 20px;
height: 20px;
border: 0;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1em;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.permission-denied {
text-align: center;
padding: 2em;
background-color: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.events-view-footer {
position: relative;
flex-shrink: 0;
}
.events-view-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5em;
border: 0;
background: var(--header-bg);
}
.events-view-toggle {
display: flex;
align-items: center;
}
.toggle-container {
display: flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
}
.toggle-container input[type="checkbox"] {
display: none;
}
.toggle-slider {
width: 40px;
height: 20px;
background: var(--border-color);
border-radius: 10px;
position: relative;
transition: background 0.2s;
}
.toggle-slider::before {
content: "";
position: absolute;
width: 16px;
height: 16px;
background: var(--text-color);
border-radius: 50%;
top: 2px;
left: 2px;
transition: transform 0.2s;
}
.toggle-container input:checked + .toggle-slider {
background: var(--primary);
}
.toggle-container input:checked + .toggle-slider::before {
transform: translateX(20px);
}
.toggle-label {
font-size: 0.9em;
color: var(--text-color);
}
.events-view-buttons {
display: flex;
gap: 0.5em;
}
.refresh-btn,
.reload-btn {
background: var(--primary);
color: var(--text-color);
border: none;
padding: 0.4em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25em;
box-sizing: border-box;
line-height: 1;
}
.reload-btn {
width: 2.5em;
padding: 0.4em;
}
.refresh-btn:hover:not(:disabled),
.reload-btn:hover:not(:disabled) {
background: var(--accent-hover-color);
}
.refresh-btn:disabled,
.reload-btn:disabled {
background: var(--secondary);
cursor: not-allowed;
padding: 0.4em 1em;
}
.reload-btn:disabled {
padding: 0.4em;
}
.reload-btn .spinner {
width: 0.8em;
height: 0.8em;
border: 1.5px solid var(--text-color);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0;
box-sizing: border-box;
}
.events-view-left {
display: flex;
align-items: center;
gap: 0.75em;
}
.filter-btn {
background: var(--primary);
color: var(--text-color);
border: none;
padding: 0.4em;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
width: 2.2em;
height: 2.2em;
box-sizing: border-box;
}
.filter-btn:hover {
background: var(--accent-hover-color);
}
.filter-btn.active {
background: var(--accent-hover-color);
}
.filter-btn svg {
width: 1em;
height: 1em;
}
.filter-panel {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
z-index: 100;
/* Account for scrollbar width in events-view-content */
padding-right: 16px;
box-sizing: border-box;
/* Flex column-reverse makes content anchor to top and grow downward */
display: flex;
flex-direction: column-reverse;
}
.filter-panel.open {
max-height: 60vh;
overflow-y: auto;
}
/* Custom scrollbar for filter panel */
.filter-panel::-webkit-scrollbar {
width: 16px;
background: var(--bg-color);
}
.filter-panel::-webkit-scrollbar-track {
background: var(--bg-color);
}
.filter-panel::-webkit-scrollbar-thumb {
background: var(--text-color);
border-radius: 9999px;
border: 4px solid var(--bg-color);
}
.filter-panel::-webkit-scrollbar-thumb:hover {
background: var(--text-color);
filter: brightness(1.2);
}
.filter-panel::-webkit-scrollbar-button {
background: var(--text-color);
height: 8px;
border: 4px solid var(--bg-color);
border-radius: 9999px;
background-clip: padding-box;
}
</style>