Browse Source

Implement New Views and Refactor App Structure

- Added new components: Header, Sidebar, ExportView, ImportView, EventsView, ComposeView, RecoveryView, SprocketView, and SearchResultsView to enhance the application's functionality and user experience.
- Updated App.svelte to integrate the new views and improve the overall layout.
- Refactored existing components for better organization and maintainability.
- Adjusted CSS styles for improved visual consistency across the application.
- Incremented version number to v0.19.3 to reflect the latest changes and additions.
main
mleku 3 months ago
parent
commit
d4f4f2a186
No known key found for this signature in database
  1. 17
      app/web/dist/bundle.css
  2. 30
      app/web/dist/bundle.js
  3. 2
      app/web/dist/bundle.js.map
  4. 2943
      app/web/src/App.svelte
  5. 129
      app/web/src/ComposeView.svelte
  6. 581
      app/web/src/EventsView.svelte
  7. 117
      app/web/src/ExportView.svelte
  8. 230
      app/web/src/Header.svelte
  9. 157
      app/web/src/ImportView.svelte
  10. 2
      app/web/src/LoginModal.svelte
  11. 90
      app/web/src/ManagedACL.svelte
  12. 358
      app/web/src/RecoveryView.svelte
  13. 431
      app/web/src/SearchResultsView.svelte
  14. 121
      app/web/src/Sidebar.svelte
  15. 580
      app/web/src/SprocketView.svelte
  16. 2
      pkg/version/version

17
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

30
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

2
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

2943
app/web/src/App.svelte

File diff suppressed because it is too large Load Diff

129
app/web/src/ComposeView.svelte

@ -0,0 +1,129 @@
<script>
export let composeEventJson = "";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
function reformatJson() {
dispatch("reformatJson");
}
function signEvent() {
dispatch("signEvent");
}
function publishEvent() {
dispatch("publishEvent");
}
</script>
<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>
<style>
.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;
}
.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;
}
.compose-btn:hover {
background: var(--button-hover-bg);
}
.reformat-btn {
background: var(--info);
color: var(--text-color);
}
.reformat-btn:hover {
background: var(--info);
filter: brightness(0.9);
}
.sign-btn {
background: var(--warning);
color: var(--text-color);
}
.sign-btn:hover {
background: var(--warning);
filter: brightness(0.9);
}
.publish-btn {
background: var(--success);
color: var(--text-color);
}
.publish-btn:hover {
background: var(--success);
filter: brightness(0.9);
}
.compose-editor {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
}
.compose-textarea {
flex: 1;
width: 100%;
padding: 1em;
border-radius: 0.5em;
background: var(--input-bg);
color: var(--input-text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
resize: vertical;
outline: none;
}
.compose-textarea:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
</style>

581
app/web/src/EventsView.svelte

@ -0,0 +1,581 @@
<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;
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
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 truncatePubkey(pubkey) {
if (!pubkey) return "";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
function getKindName(kind) {
const kindNames = {
0: "Profile",
1: "Text Note",
2: "Recommend Relay",
3: "Contacts",
4: "Encrypted DM",
5: "Delete",
6: "Repost",
7: "Reaction",
8: "Badge Award",
16: "Generic Repost",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1984: "Reporting",
9734: "Zap Request",
9735: "Zap",
10000: "Mute List",
10001: "Pin List",
10002: "Relay List",
22242: "Client Auth",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Categorized People",
30001: "Categorized Bookmarks",
30008: "Profile Badges",
30009: "Badge Definition",
30017: "Create or update a stall",
30018: "Create or update a product",
30023: "Long-form Content",
30024: "Draft Long-form Content",
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",
34550: "Community Definition",
};
return kindNames[kind] || `Kind ${kind}`;
}
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 === "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="spinner"></div>
<p>Loading events...</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>
<style>
.events-view-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.events-view-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
.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;
}
.events-view-info {
flex-shrink: 0;
min-width: 120px;
}
.events-view-author {
font-weight: 600;
color: var(--text-color);
font-size: 0.9em;
font-family: monospace;
}
.events-view-kind {
display: flex;
align-items: center;
gap: 0.5em;
margin-top: 0.25em;
}
.kind-number {
background: var(--primary);
color: var(--text-color);
padding: 0.1em 0.4em;
border: 0;
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: var(--text-color);
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-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.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
display: flex;
align-items: center;
gap: 0.25em;
}
.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;
}
</style>

117
app/web/src/ExportView.svelte

@ -0,0 +1,117 @@
<script>
export let isLoggedIn = false;
export let currentEffectiveRole = "";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
function exportMyEvents() {
dispatch("exportMyEvents");
}
function exportAllEvents() {
dispatch("exportAllEvents");
}
function openLoginModal() {
dispatch("openLoginModal");
}
</script>
{#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}
<style>
.export-section {
border-radius: 8px;
padding: 1em;
margin: 1em;
width: 32em;
background-color: var(--card-bg);
}
.export-section h3 {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.2rem;
font-weight: 600;
}
.export-section p {
margin: 0 0 1.5rem 0;
color: var(--text-color);
opacity: 0.8;
line-height: 1.4;
}
.export-btn {
background-color: var(--primary);
color: var(--text-color);
border: none;
padding: 0.75em 1.5em;
border-radius: 0.5em;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
transition: background-color 0.2s;
}
.export-btn:hover {
background-color: var(--accent-hover-color);
}
.login-prompt {
text-align: center;
padding: 2em;
background-color: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
width: 32em;
}
.login-prompt p {
margin: 0 0 1.5rem 0;
color: var(--text-color);
font-size: 1.1rem;
}
.login-btn {
background-color: var(--primary);
color: var(--text-color);
border: none;
padding: 0.75em 1.5em;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
transition: background-color 0.2s;
}
.login-btn:hover {
background-color: var(--accent-hover-color);
}
</style>

230
app/web/src/Header.svelte

@ -0,0 +1,230 @@
<script>
export let isDarkTheme = false;
export let isSearchMode = false;
export let searchQuery = "";
export let isLoggedIn = false;
export let userRole = "";
export let currentEffectiveRole = "";
export let userProfile = null;
export let userPubkey = "";
// Event dispatchers
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
function handleSearchKeydown(event) {
dispatch("searchKeydown", event);
}
function toggleSearchMode() {
dispatch("toggleSearchMode");
}
function toggleTheme() {
dispatch("toggleTheme");
}
function openSettingsDrawer() {
dispatch("openSettingsDrawer");
}
function openLoginModal() {
dispatch("openLoginModal");
}
</script>
<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}
<div class="header-buttons">
<button class="search-btn" on:click={toggleSearchMode}> 🔍 </button>
<button class="theme-toggle-btn" on:click={toggleTheme}>
{isDarkTheme ? "☀" : "🌙"}
</button>
{#if isLoggedIn}
<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>
{:else}
<button class="login-btn" on:click={openLoginModal}
>Log in</button
>
{/if}
</div>
</div>
</header>
<style>
.main-header {
color: var(--text-color);
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--header-bg);
border: 0;
z-index: 1000;
display: flex;
align-items: space-between;
padding: 0.25em;
}
.header-content {
display: flex;
align-items: center;
width: 100%;
padding: 0;
margin: 0;
}
.logo {
height: 2em;
width: auto;
flex-shrink: 0;
}
.header-title {
flex: 1;
display: flex;
align-items: center;
}
.app-title {
font-size: 1.2em;
font-weight: 600;
color: var(--text-color);
display: flex;
align-items: center;
gap: 0.5em;
}
.permission-badge {
background: var(--primary);
color: var(--text-color);
padding: 0.2em 0.5em;
border-radius: 0.5em;
font-size: 0.7em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.search-input-container {
flex: 1;
max-width: 400px;
}
.search-input {
width: 100%;
padding: 0.5em 1em;
border: 1px solid var(--border-color);
border-radius: 1.5em;
background: var(--input-bg);
color: var(--input-text-color);
font-size: 0.9em;
}
.search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.header-buttons {
display: flex;
align-items: center;
height: 100%;
margin-left: auto;
}
.search-btn,
.theme-toggle-btn,
.login-btn,
.user-profile-btn {
background: transparent;
color: var(--button-text);
border: 0;
height: 100%;
cursor: pointer;
font-size: 1em;
transition: background-color 0.2s;
flex-shrink: 0;
padding: 0.5em;
margin: 0;
display: block;
align-items: center;
justify-content: center;
}
.search-btn:hover,
.theme-toggle-btn:hover,
.login-btn:hover,
.user-profile-btn:hover {
background: var(--card-bg);
}
.user-profile-btn {
gap: 0.5em;
justify-content: flex-start;
padding: 0 0.75em;
}
.user-avatar {
width: 1.5em;
height: 1.5em;
border-radius: 50%;
object-fit: cover;
}
.user-avatar-placeholder {
width: 1.5em;
height: 1.5em;
border-radius: 50%;
background: var(--bg-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
}
.user-name {
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

157
app/web/src/ImportView.svelte

@ -0,0 +1,157 @@
<script>
export let isLoggedIn = false;
export let currentEffectiveRole = "";
export let selectedFile = null;
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
function handleFileSelect(event) {
dispatch("fileSelect", event);
}
function importEvents() {
dispatch("importEvents");
}
function openLoginModal() {
dispatch("openLoginModal");
}
</script>
<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>
<style>
.import-section {
background: transparent;
padding: 1em;
border-radius: 8px;
margin-bottom: 1.5rem;
width: 32em;
}
.import-section h3 {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.2rem;
font-weight: 600;
}
.import-section p {
margin: 0 0 1.5rem 0;
color: var(--text-color);
opacity: 0.8;
line-height: 1.4;
}
.recovery-controls-card {
background-color: var(--card-bg);
padding: 1em;
border: 0;
display: flex;
flex-direction: column;
border-radius: 0.5em;
gap: 1em;
}
#import-file {
padding: 0.5em;
border: 0;
background: var(--input-bg);
color: var(--input-text-color);
}
.import-btn {
background-color: var(--primary);
color: var(--text-color);
border-radius: 0.5em;
padding: 0.75em 1.5em;
border: 0;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
transition: background-color 0.2s;
align-self: flex-start;
}
.import-btn:hover:not(:disabled) {
background-color: var(--accent-hover-color);
}
.import-btn:disabled {
background-color: var(--secondary);
cursor: not-allowed;
}
.permission-denied,
.login-prompt {
text-align: center;
padding: 2em;
background-color: var(--card-bg);
border: 0;
}
.recovery-header {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.2rem;
font-weight: 600;
}
.recovery-description {
margin: 0 0 1.5rem 0;
color: var(--text-color);
line-height: 1.4;
}
.login-btn {
background-color: var(--primary);
color: var(--text-color);
border: none;
padding: 0.75em 1.5em;
border: 0;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
transition: background-color 0.2s;
}
.login-btn:hover {
background-color: var(--accent-hover-color);
}
</style>

2
app/web/src/LoginModal.svelte

@ -353,7 +353,7 @@
.login-nsec-btn { .login-nsec-btn {
padding: 12px 24px; padding: 12px 24px;
background: var(--primary); background: var(--primary);
color: white; color: var(--text-color);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;

90
app/web/src/ManagedACL.svelte

@ -897,23 +897,28 @@
</div> </div>
<div class="config-form"> <div class="config-form">
<div class="form-group"> <div class="form-group">
<label>Relay Name</label> <label for="relay-name">Relay Name</label>
<input <input
id="relay-name"
type="text" type="text"
bind:value={relayConfig.relay_name} bind:value={relayConfig.relay_name}
placeholder="Enter relay name" placeholder="Enter relay name"
/> />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Relay Description</label> <label for="relay-description"
>Relay Description</label
>
<textarea <textarea
id="relay-description"
bind:value={relayConfig.relay_description} bind:value={relayConfig.relay_description}
placeholder="Enter relay description" placeholder="Enter relay description"
></textarea> ></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Relay Icon URL</label> <label for="relay-icon">Relay Icon URL</label>
<input <input
id="relay-icon"
type="url" type="url"
bind:value={relayConfig.relay_icon} bind:value={relayConfig.relay_icon}
placeholder="Enter icon URL" placeholder="Enter icon URL"
@ -958,10 +963,10 @@
.owner-only-notice { .owner-only-notice {
margin-top: 10px; margin-top: 10px;
padding: 8px 12px; padding: 8px 12px;
background-color: #fff3cd; background-color: var(--warning-bg);
border: 1px solid #ffeaa7; border: 1px solid var(--warning);
border-radius: 4px; border-radius: 4px;
color: #856404; color: var(--text-color);
font-size: 0.9em; font-size: 0.9em;
} }
@ -972,21 +977,21 @@
} }
.message.success { .message.success {
background-color: #d4edda; background-color: var(--success-bg);
color: #155724; color: var(--success-text);
border: 1px solid #c3e6cb; border: 1px solid var(--success);
} }
.message.error { .message.error {
background-color: #f8d7da; background-color: var(--error-bg);
color: #721c24; color: var(--error-text);
border: 1px solid #f5c6cb; border: 1px solid var(--danger);
} }
.message.info { .message.info {
background-color: #d1ecf1; background-color: var(--primary-bg);
color: #0c5460; color: var(--text-color);
border: 1px solid #bee5eb; border: 1px solid var(--info);
} }
.tabs { .tabs {
@ -1010,8 +1015,8 @@
} }
.tab.active { .tab.active {
border-bottom-color: #007bff; border-bottom-color: var(--accent-color);
color: #007bff; color: var(--accent-color);
} }
.tab-content { .tab-content {
@ -1046,15 +1051,15 @@
.add-form button { .add-form button {
padding: 8px 16px; padding: 8px 16px;
background-color: #007bff; background-color: var(--accent-color);
color: white; color: var(--text-color);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
} }
.add-form button:disabled { .add-form button:disabled {
background-color: #6c757d; background-color: var(--secondary);
cursor: not-allowed; cursor: not-allowed;
} }
@ -1096,8 +1101,8 @@
.remove-btn { .remove-btn {
padding: 4px 8px; padding: 4px 8px;
background-color: #dc3545; background-color: var(--danger);
color: white; color: var(--text-color);
border: none; border: none;
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
@ -1119,13 +1124,13 @@
} }
.actions button:first-child { .actions button:first-child {
background-color: #28a745; background-color: var(--success);
color: white; color: var(--text-color);
} }
.actions button:last-child { .actions button:last-child {
background-color: #dc3545; background-color: var(--danger);
color: white; color: var(--text-color);
} }
.config-form { .config-form {
@ -1168,8 +1173,8 @@
.refresh-btn { .refresh-btn {
padding: 8px 16px; padding: 8px 16px;
background-color: #28a745; background-color: var(--success);
color: white; color: var(--text-color);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
@ -1177,11 +1182,12 @@
} }
.refresh-btn:hover:not(:disabled) { .refresh-btn:hover:not(:disabled) {
background-color: #218838; background-color: var(--success);
filter: brightness(0.9);
} }
.refresh-btn:disabled { .refresh-btn:disabled {
background-color: #6c757d; background-color: var(--secondary);
cursor: not-allowed; cursor: not-allowed;
} }
@ -1195,8 +1201,8 @@
.update-all-btn { .update-all-btn {
padding: 12px 24px; padding: 12px 24px;
background-color: #28a745; background-color: var(--success);
color: white; color: var(--text-color);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@ -1206,13 +1212,14 @@
} }
.update-all-btn:hover:not(:disabled) { .update-all-btn:hover:not(:disabled) {
background-color: #218838; background-color: var(--success);
filter: brightness(0.9);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.update-all-btn:disabled { .update-all-btn:disabled {
background-color: #6c757d; background-color: var(--secondary);
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
box-shadow: none; box-shadow: none;
@ -1225,19 +1232,4 @@
opacity: 0.7; opacity: 0.7;
font-style: italic; font-style: italic;
} }
.form-group button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
align-self: flex-start;
}
.form-group button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
</style> </style>

358
app/web/src/RecoveryView.svelte

@ -0,0 +1,358 @@
<script>
export let recoverySelectedKind = null;
export let recoveryCustomKind = "";
export let isLoadingRecovery = false;
export let recoveryEvents = [];
export let recoveryHasMore = false;
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
const replaceableKinds = [
{ value: 0, label: "Profile (0)" },
{ value: 3, label: "Contacts (3)" },
{ value: 10000, label: "Mute List (10000)" },
{ value: 10001, label: "Pin List (10001)" },
{ value: 10002, label: "Relay List (10002)" },
{ value: 30000, label: "Categorized People (30000)" },
{ value: 30001, label: "Categorized Bookmarks (30001)" },
{ value: 30008, label: "Profile Badges (30008)" },
{ value: 30009, label: "Badge Definition (30009)" },
{ value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Create or update a product (30018)" },
{ value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30078, label: "Application-specific Data (30078)" },
{ value: 30311, label: "Live Event (30311)" },
{ value: 30315, label: "User Statuses (30315)" },
{ value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ 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: 34550, label: "Community Definition (34550)" },
];
function selectRecoveryKind() {
dispatch("selectRecoveryKind");
}
function handleCustomKindInput() {
dispatch("handleCustomKindInput");
}
function loadRecoveryEvents() {
dispatch("loadRecoveryEvents");
}
function repostEventToAll(event) {
dispatch("repostEventToAll", event);
}
function repostEvent(event) {
dispatch("repostEvent", event);
}
function copyEventToClipboard(event, e) {
dispatch("copyEventToClipboard", { event, e });
}
function isCurrentVersion(event) {
// This logic would need to be passed from parent or implemented here
// For now, just return false for old versions
return false;
}
</script>
<div class="recovery-tab">
<div>
<h3>Event Recovery</h3>
<p>Search and recover old versions of replaceable events</p>
</div>
<div class="recovery-controls-card">
<div class="recovery-controls">
<div class="kind-selector">
<label for="recovery-kind">Select Event Kind:</label>
<select
id="recovery-kind"
bind:value={recoverySelectedKind}
on:change={selectRecoveryKind}
>
<option value={null}>Choose a replaceable kind...</option>
{#each replaceableKinds as kind}
<option value={kind.value}>{kind.label}</option>
{/each}
</select>
</div>
<div class="custom-kind-input">
<label for="custom-kind">Or enter custom kind number:</label>
<input
id="custom-kind"
type="number"
bind:value={recoveryCustomKind}
on:input={handleCustomKindInput}
placeholder="e.g., 10001"
min="0"
/>
</div>
</div>
</div>
{#if (recoverySelectedKind !== null && recoverySelectedKind !== undefined && recoverySelectedKind >= 0) || (recoveryCustomKind !== "" && parseInt(recoveryCustomKind) >= 0)}
<div class="recovery-results">
{#if isLoadingRecovery}
<div class="loading">Loading events...</div>
{:else if recoveryEvents.length === 0}
<div class="no-events">No events found for this kind</div>
{:else}
<div class="events-list">
{#each recoveryEvents as event}
{@const isCurrent = isCurrentVersion(event)}
<div class="event-item" class:old-version={!isCurrent}>
<div class="event-header">
<div class="event-header-left">
<span class="event-kind">
{#if isCurrent}
Current Version{/if}</span
>
<span class="event-timestamp">
{new Date(
event.created_at * 1000,
).toLocaleString()}
</span>
</div>
<div class="event-header-actions">
{#if !isCurrent}
<button
class="repost-all-button"
on:click={() =>
repostEventToAll(event)}
>
🌐 Repost to All
</button>
<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>
<style>
.recovery-tab {
width: 100%;
max-width: 1200px;
margin: 0;
padding: 20px;
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
}
.recovery-tab h3 {
margin: 0 0 0.5rem 0;
color: var(--text-color);
font-size: 1.5rem;
font-weight: 600;
}
.recovery-tab p {
margin: 0 0 1.5rem 0;
color: var(--text-color);
opacity: 0.8;
line-height: 1.4;
}
.recovery-controls-card {
background-color: var(--card-bg);
border-radius: 0.5em;
padding: 1em;
margin-bottom: 1.5rem;
}
.recovery-controls {
display: flex;
flex-direction: column;
gap: 1em;
}
.kind-selector,
.custom-kind-input {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.kind-selector label,
.custom-kind-input label {
font-weight: 600;
color: var(--text-color);
}
.kind-selector select,
.custom-kind-input input {
padding: 0.5em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
font-size: 0.9em;
}
.recovery-results {
margin-top: 1.5rem;
}
.loading,
.no-events {
text-align: center;
padding: 2em;
color: var(--text-color);
opacity: 0.7;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1em;
}
.event-item {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1em;
}
.event-item.old-version {
border-color: var(--warning);
background: var(--warning-bg);
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
flex-wrap: wrap;
gap: 1em;
}
.event-header-left {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.event-kind {
font-weight: 600;
color: var(--primary);
}
.event-timestamp {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.7;
}
.event-header-actions {
display: flex;
gap: 0.5em;
flex-wrap: wrap;
}
.repost-all-button,
.repost-button,
.copy-json-btn {
background: var(--accent-color);
color: var(--accent-hover-color);
border: none;
padding: 0.5em;
border-radius: 0.5em;
cursor: pointer;
font-size: 0.8em;
transition: background-color 0.2s;
}
.repost-all-button:hover,
.repost-button:hover,
.copy-json-btn:hover {
background: var(--accent-hover-color);
}
.event-content {
margin-top: 1em;
}
.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);
}
.load-more {
width: 100%;
padding: 12px;
background: var(--primary);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
margin-top: 20px;
transition: background 0.2s ease;
}
.load-more:hover:not(:disabled) {
background: var(--accent-hover-color);
}
.load-more:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

431
app/web/src/SearchResultsView.svelte

@ -0,0 +1,431 @@
<script>
export let searchTab = null;
export let searchResults = new Map();
export let expandedEvents = new Set();
export let userRole = "";
export let userPubkey = "";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
function loadSearchResults(tabId, query, refresh) {
dispatch("loadSearchResults", { tabId, query, refresh });
}
function handleSearchScroll(event, tabId) {
dispatch("searchScroll", { event, tabId });
}
function toggleEventExpansion(eventId) {
dispatch("toggleEventExpansion", eventId);
}
function deleteEvent(eventId) {
dispatch("deleteEvent", eventId);
}
function copyEventToClipboard(event, e) {
dispatch("copyEventToClipboard", { event, e });
}
function truncatePubkey(pubkey) {
if (!pubkey) return "";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
function getKindName(kind) {
const kindNames = {
0: "Profile",
1: "Text Note",
2: "Recommend Relay",
3: "Contacts",
4: "Encrypted DM",
5: "Delete",
6: "Repost",
7: "Reaction",
8: "Badge Award",
16: "Generic Repost",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1984: "Reporting",
9734: "Zap Request",
9735: "Zap",
10000: "Mute List",
10001: "Pin List",
10002: "Relay List",
22242: "Client Auth",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Categorized People",
30001: "Categorized Bookmarks",
30008: "Profile Badges",
30009: "Badge Definition",
30017: "Create or update a stall",
30018: "Create or update a product",
30023: "Long-form Content",
30024: "Draft Long-form Content",
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",
34550: "Community Definition",
};
return kindNames[kind] || `Kind ${kind}`;
}
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>
{#if searchTab}
<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 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-results">
<p>No results found for "{searchTab.query}"</p>
</div>
{/if}
{#if searchResults.get(searchTab.id)?.isLoading}
<div class="loading-search">
<div class="spinner"></div>
<p>Searching...</p>
</div>
{/if}
</div>
</div>
{/if}
<style>
.search-results-view {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.search-results-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1em;
border-bottom: 1px solid var(--border-color);
background: var(--header-bg);
}
.search-results-header h2 {
margin: 0;
color: var(--text-color);
font-size: 1.2rem;
font-weight: 600;
}
.refresh-btn {
background: var(--primary);
color: var(--text-color);
border: none;
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
}
.refresh-btn:hover:not(:disabled) {
background: var(--accent-hover-color);
}
.refresh-btn:disabled {
background: var(--secondary);
cursor: not-allowed;
}
.search-results-content {
flex: 1;
overflow-y: auto;
padding: 1em;
}
.search-result-item {
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 0.5em;
background: var(--card-bg);
transition: all 0.2s ease;
}
.search-result-item:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.search-result-item.expanded {
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.search-result-row {
display: flex;
align-items: center;
padding: 1em;
cursor: pointer;
gap: 1em;
}
.search-result-avatar {
flex-shrink: 0;
}
.avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
border: 1px solid var(--border-color);
}
.search-result-info {
flex-shrink: 0;
min-width: 120px;
}
.search-result-author {
font-weight: 600;
color: var(--text-color);
font-size: 0.9em;
font-family: monospace;
}
.search-result-kind {
display: flex;
align-items: center;
gap: 0.5em;
margin-top: 0.25em;
}
.kind-number {
background: var(--primary);
color: var(--text-color);
padding: 0.1em 0.4em;
border-radius: 0.25rem;
font-size: 0.7em;
font-weight: 600;
font-family: monospace;
}
.kind-name {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.8;
}
.search-result-content {
flex: 1;
min-width: 0;
}
.event-timestamp {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
margin-bottom: 0.5em;
}
.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);
}
.search-result-details {
border-top: 1px solid var(--border-color);
padding: 1em;
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: 0.5em;
right: 0.5em;
background: var(--primary);
color: var(--text-color);
border: none;
padding: 0.25em 0.5em;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.8em;
opacity: 0.8;
transition: opacity 0.2s;
}
.copy-json-btn:hover {
opacity: 1;
}
.no-results {
text-align: center;
padding: 2em;
color: var(--text-color);
opacity: 0.7;
}
.loading-search {
text-align: center;
padding: 2em;
color: var(--text-color);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top: 2px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1em;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

121
app/web/src/Sidebar.svelte

@ -0,0 +1,121 @@
<script>
export let isDarkTheme = false;
export let tabs = [];
export let selectedTab = "";
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
function selectTab(tabId) {
dispatch("selectTab", tabId);
}
function closeSearchTab(tabId) {
dispatch("closeSearchTab", tabId);
}
</script>
<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>
<style>
.sidebar {
position: fixed;
left: 0;
top: 2.5em;
width: 200px;
bottom: 0;
background: var(--sidebar-bg);
overflow-y: auto;
z-index: 100;
}
.sidebar-content {
padding: 0;
background: var(--sidebar-bg);
}
.tabs {
display: flex;
flex-direction: column;
padding: 0;
}
.tab {
display: flex;
align-items: center;
padding: 0.75em;
background: transparent;
color: var(--text-color);
border: none;
cursor: pointer;
transition: background-color 0.2s;
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;
flex-shrink: 0;
}
.tab-close-icon:hover {
opacity: 0.7;
background-color: var(--warning);
color: var(--text-color);
}
</style>

580
app/web/src/SprocketView.svelte

@ -0,0 +1,580 @@
<script>
export let isLoggedIn = false;
export let userRole = "";
export let sprocketStatus = null;
export let isLoadingSprocket = false;
export let sprocketUploadFile = null;
export let sprocketScript = "";
export let sprocketMessage = "";
export let sprocketMessageType = "";
export let sprocketVersions = [];
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();
function restartSprocket() {
dispatch("restartSprocket");
}
function deleteSprocket() {
dispatch("deleteSprocket");
}
function handleSprocketFileSelect(event) {
dispatch("sprocketFileSelect", event);
}
function uploadSprocketScript() {
dispatch("uploadSprocketScript");
}
function saveSprocket() {
dispatch("saveSprocket");
}
function loadSprocket() {
dispatch("loadSprocket");
}
function loadVersions() {
dispatch("loadVersions");
}
function loadVersion(version) {
dispatch("loadVersion", version);
}
function deleteVersion(versionName) {
dispatch("deleteVersion", versionName);
}
function openLoginModal() {
dispatch("openLoginModal");
}
</script>
<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>
<style>
.sprocket-view {
width: 100%;
max-width: 1200px;
margin: 0;
padding: 20px;
background: var(--header-bg);
color: var(--text-color);
border-radius: 8px;
}
.sprocket-view h2 {
margin: 0 0 1.5rem 0;
color: var(--text-color);
font-size: 1.8rem;
font-weight: 600;
}
.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-header h3 {
margin: 0;
color: var(--text-color);
font-size: 1.2rem;
font-weight: 600;
}
.sprocket-controls {
display: flex;
gap: 0.5rem;
}
.sprocket-btn {
background: var(--primary);
color: var(--text-color);
border: none;
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
display: flex;
align-items: center;
gap: 0.25em;
}
.sprocket-btn:hover:not(:disabled) {
background: var(--accent-hover-color);
}
.sprocket-btn:disabled {
background: var(--secondary);
cursor: not-allowed;
}
.restart-btn {
background: var(--warning);
}
.restart-btn:hover:not(:disabled) {
background: var(--warning);
filter: brightness(0.9);
}
.delete-btn {
background: var(--danger);
}
.delete-btn:hover:not(:disabled) {
background: var(--danger);
filter: brightness(0.9);
}
.sprocket-upload-section {
margin-bottom: 1.5rem;
}
.sprocket-upload-section h4 {
margin: 0 0 0.5rem 0;
color: var(--text-color);
font-size: 1rem;
font-weight: 600;
}
.upload-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#sprocket-upload-file {
padding: 0.5em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
}
.upload-btn {
background: var(--success);
align-self: flex-start;
}
.upload-btn:hover:not(:disabled) {
background: var(--success);
filter: brightness(0.9);
}
.sprocket-status {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-color);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.status-label {
font-weight: 600;
color: var(--text-color);
}
.status-value {
color: var(--text-color);
}
.status-value.running {
color: var(--success);
}
.script-editor-container {
margin-bottom: 1.5rem;
}
.script-editor {
width: 100%;
height: 300px;
padding: 1em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--input-text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
resize: vertical;
}
.script-editor:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.script-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.save-btn {
background: var(--success);
}
.save-btn:hover:not(:disabled) {
background: var(--success);
filter: brightness(0.9);
}
.load-btn {
background: var(--info);
}
.load-btn:hover:not(:disabled) {
background: var(--info);
filter: brightness(0.9);
}
.sprocket-message {
padding: 1rem;
border-radius: 4px;
margin-top: 1rem;
background: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success);
}
.sprocket-message.error {
background: var(--danger-bg);
color: var(--danger-text);
border: 1px solid var(--danger);
}
.versions-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 4px;
}
.version-item.current {
border-color: var(--primary);
background: var(--primary-bg);
}
.version-info {
flex: 1;
}
.version-name {
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.version-date {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
display: flex;
align-items: center;
gap: 0.5rem;
}
.current-badge {
background: var(--primary);
color: var(--text-color);
padding: 0.1em 0.4em;
border-radius: 0.25rem;
font-size: 0.7em;
font-weight: 600;
}
.version-actions {
display: flex;
gap: 0.25rem;
}
.version-btn {
background: var(--primary);
color: var(--text-color);
border: none;
padding: 0.25em 0.5em;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.8em;
transition: background-color 0.2s;
}
.version-btn:hover:not(:disabled) {
background: var(--accent-hover-color);
}
.version-btn:disabled {
background: var(--secondary);
cursor: not-allowed;
}
.version-btn.delete-btn {
background: var(--danger);
}
.version-btn.delete-btn:hover:not(:disabled) {
background: var(--danger);
filter: brightness(0.9);
}
.refresh-btn {
background: var(--info);
}
.refresh-btn:hover:not(:disabled) {
background: var(--info);
filter: brightness(0.9);
}
.permission-denied,
.login-prompt {
text-align: center;
padding: 2em;
background-color: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.permission-denied p,
.login-prompt p {
margin: 0 0 1rem 0;
line-height: 1.4;
}
.permission-denied code {
background: var(--code-bg);
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.9em;
}
.login-btn {
background: var(--primary);
color: var(--text-color);
border: none;
padding: 0.75em 1.5em;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 0.9em;
transition: background-color 0.2s;
}
.login-btn:hover {
background: var(--accent-hover-color);
}
</style>

2
pkg/version/version

@ -1 +1 @@
v0.19.1 v0.19.3
Loading…
Cancel
Save