Browse Source

more features

master
Silberengel 1 month ago
parent
commit
f81de9b29b
  1. 4
      public/healthz.json
  2. 363
      src/lib/components/modals/SearchModal.svelte
  3. 87
      src/lib/modules/feed/Kind1FeedPage.svelte
  4. 3
      src/lib/modules/zaps/ZapButton.svelte
  5. 23
      src/lib/services/cache/event-cache.ts
  6. 126
      src/lib/services/keyboard-shortcuts.ts
  7. 20
      src/routes/+layout.svelte

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-02T14:17:29.413Z",
"buildTime": "2026-02-02T14:49:18.071Z",
"gitCommit": "unknown",
"timestamp": 1770041849413
"timestamp": 1770043758071
}

363
src/lib/components/modals/SearchModal.svelte

@ -0,0 +1,363 @@ @@ -0,0 +1,363 @@
<script lang="ts">
import { searchEvents } from '../../services/cache/search-index.js';
import { getEvent } from '../../services/cache/event-cache.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
let query = $state('');
let results = $state<NostrEvent[]>([]);
let loading = $state(false);
let searchInput: HTMLInputElement | null = $state(null);
$effect(() => {
if (open && searchInput) {
// Focus input when modal opens
setTimeout(() => searchInput?.focus(), 100);
}
});
async function handleSearch() {
if (!query.trim()) {
results = [];
return;
}
loading = true;
try {
// Search in index
const eventIds = await searchEvents(query.trim(), 20);
// Fetch events
const events: NostrEvent[] = [];
for (const id of eventIds) {
try {
const cached = await getEvent(id);
if (cached) {
events.push(cached.event);
} else {
// Try to fetch from relays
const relays = relayManager.getThreadReadRelays();
const event = await nostrClient.getEventById(id, relays);
if (event) {
events.push(event);
}
}
} catch {
// Skip if event not found
}
}
results = events.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error searching:', error);
results = [];
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
function handleResultClick(event: NostrEvent) {
// Navigate to thread if kind 11, or show event
if (event.kind === 11) {
goto(`/thread/${event.id}`);
} else if (event.kind === 1) {
// Could navigate to feed and highlight, or show in modal
goto(`/feed`);
}
onClose();
}
function getEventPreview(event: NostrEvent): string {
const content = event.content || '';
const preview = content.slice(0, 150);
return preview + (content.length > 150 ? '...' : '');
}
function getEventType(event: NostrEvent): string {
switch (event.kind) {
case 1:
return 'Post';
case 11:
return 'Thread';
case 1111:
return 'Comment';
default:
return 'Event';
}
}
</script>
{#if open}
<div
class="search-modal-overlay"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="dialog"
aria-modal="true"
aria-labelledby="search-title"
>
<div class="search-modal">
<div class="search-header">
<h2 id="search-title" class="text-xl font-bold mb-4">Search</h2>
<button
onclick={onClose}
class="close-button"
aria-label="Close search"
>
×
</button>
</div>
<div class="search-input-container">
<input
bind:this={searchInput}
type="text"
bind:value={query}
onkeydown={handleKeydown}
placeholder="Search posts, threads, comments..."
class="search-input"
aria-label="Search query"
/>
<button
onclick={handleSearch}
class="search-button"
disabled={loading || !query.trim()}
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
{#if loading}
<div class="search-results">
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light py-4">
Searching...
</p>
</div>
{:else if results.length > 0}
<div class="search-results">
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light mb-2">
Found {results.length} {results.length === 1 ? 'result' : 'results'}
</p>
<div class="results-list">
{#each results as event (event.id)}
<button
onclick={() => handleResultClick(event)}
class="result-item"
>
<div class="result-header">
<span class="result-type">{getEventType(event)}</span>
<span class="result-time">
{new Date(event.created_at * 1000).toLocaleDateString()}
</span>
</div>
<div class="result-content">
{getEventPreview(event)}
</div>
</button>
{/each}
</div>
</div>
{:else if query.trim() && !loading}
<div class="search-results">
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light py-4">
No results found
</p>
</div>
{/if}
</div>
</div>
{/if}
<style>
.search-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 2rem;
z-index: 1000;
overflow-y: auto;
}
.search-modal {
background: var(--fog-post, #ffffff);
border-radius: 0.5rem;
padding: 1.5rem;
width: 100%;
max-width: 600px;
margin-top: 5vh;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .search-modal {
background: var(--fog-dark-post, #1f2937);
}
.search-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.close-button {
background: none;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text, #1f2937);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
:global(.dark) .close-button {
color: var(--fog-dark-text, #f9fafb);
}
.close-button:hover {
opacity: 0.7;
}
.search-input-container {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.search-input {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 1rem;
}
:global(.dark) .search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.search-input:focus {
outline: none;
border-color: var(--fog-accent, #3b82f6);
}
.search-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #3b82f6);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
}
.search-button:hover:not(:disabled) {
opacity: 0.9;
}
.search-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.search-results {
max-height: 60vh;
overflow-y: auto;
}
.results-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.result-item {
text-align: left;
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
cursor: pointer;
transition: background 0.2s;
}
:global(.dark) .result-item {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.result-item:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .result-item:hover {
background: var(--fog-dark-highlight, #374151);
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.result-type {
font-size: 0.75rem;
font-weight: 600;
color: var(--fog-accent, #3b82f6);
text-transform: uppercase;
}
.result-time {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .result-time {
color: var(--fog-dark-text-light, #9ca3af);
}
.result-content {
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
line-height: 1.5;
}
:global(.dark) .result-content {
color: var(--fog-dark-text, #f9fafb);
}
</style>

87
src/lib/modules/feed/Kind1FeedPage.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import CreateKind1Form from './CreateKind1Form.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
@ -14,6 +15,7 @@ @@ -14,6 +15,7 @@
let loadingMore = $state(false);
let newPostsCount = $state(0);
let lastPostId = $state<string | null>(null);
let selectedIndex = $state<number>(-1);
onMount(async () => {
await nostrClient.initialize();
@ -22,11 +24,75 @@ @@ -22,11 +24,75 @@
// Set up infinite scroll
window.addEventListener('scroll', handleScroll);
// Register keyboard shortcuts
const unregisterJ = keyboardShortcuts.register({
key: 'j',
handler: () => {
if (posts.length > 0 && !showNewPostForm) {
selectedIndex = Math.min(selectedIndex + 1, posts.length - 1);
scrollToPost(selectedIndex);
}
},
description: 'Next post'
});
const unregisterK = keyboardShortcuts.register({
key: 'k',
handler: () => {
if (posts.length > 0 && !showNewPostForm) {
selectedIndex = Math.max(selectedIndex - 1, 0);
scrollToPost(selectedIndex);
}
},
description: 'Previous post'
});
const unregisterR = keyboardShortcuts.register({
key: 'r',
handler: () => {
if (selectedIndex >= 0 && selectedIndex < posts.length && !showNewPostForm) {
handleReply(posts[selectedIndex]);
}
},
description: 'Reply to selected post'
});
const unregisterZ = keyboardShortcuts.register({
key: 'z',
handler: () => {
if (selectedIndex >= 0 && selectedIndex < posts.length && !showNewPostForm) {
// Trigger zap button click
const postElement = document.querySelector(`[data-post-id="${posts[selectedIndex].id}"]`);
const zapButton = postElement?.querySelector('[data-zap-button]') as HTMLElement;
zapButton?.click();
}
},
description: 'Zap selected post'
});
return () => {
window.removeEventListener('scroll', handleScroll);
unregisterJ();
unregisterK();
unregisterR();
unregisterZ();
};
});
function scrollToPost(index: number) {
if (index < 0 || index >= posts.length) return;
const postElement = document.querySelector(`[data-post-id="${posts[index].id}"]`);
if (postElement) {
postElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight briefly
postElement.classList.add('keyboard-selected');
setTimeout(() => {
postElement.classList.remove('keyboard-selected');
}, 1000);
}
}
async function loadFeed(reset = true) {
if (reset) {
loading = true;
@ -175,8 +241,10 @@ @@ -175,8 +241,10 @@
</div>
{/if}
<div class="posts-list">
{#each posts as post (post.id)}
<Kind1Post {post} onReply={handleReply} />
{#each posts as post, index (post.id)}
<div data-post-id={post.id} class="post-wrapper">
<Kind1Post {post} onReply={handleReply} />
</div>
{/each}
</div>
{#if loadingMore}
@ -200,4 +268,19 @@ @@ -200,4 +268,19 @@
justify-content: space-between;
align-items: center;
}
.post-wrapper {
transition: background 0.2s;
}
.post-wrapper.keyboard-selected {
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
padding: 0.25rem;
margin: -0.25rem;
}
:global(.dark) .post-wrapper.keyboard-selected {
background: var(--fog-dark-highlight, #374151);
}
</style>

3
src/lib/modules/zaps/ZapButton.svelte

@ -129,7 +129,8 @@ @@ -129,7 +129,8 @@
<button
onclick={handleZap}
class="zap-button"
title="Zap"
data-zap-button
title="Zap (z)"
aria-label="Zap"
>
⚡ Zap

23
src/lib/services/cache/event-cache.ts vendored

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
*/
import { getDB } from './indexeddb-store.js';
import { indexEvent } from './search-index.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface CachedEvent extends NostrEvent {
@ -19,6 +20,16 @@ export async function cacheEvent(event: NostrEvent): Promise<void> { @@ -19,6 +20,16 @@ export async function cacheEvent(event: NostrEvent): Promise<void> {
cached_at: Date.now()
};
await db.put('events', cached);
// Index for search (only for events with content)
if (event.content) {
try {
await indexEvent(event.id, event.content);
} catch (error) {
// Don't fail caching if indexing fails
console.error('Error indexing event for search:', error);
}
}
}
/**
@ -35,6 +46,18 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> { @@ -35,6 +46,18 @@ export async function cacheEvents(events: NostrEvent[]): Promise<void> {
await tx.store.put(cached);
}
await tx.done;
// Index events for search (in background)
for (const event of events) {
if (event.content) {
try {
await indexEvent(event.id, event.content);
} catch (error) {
// Don't fail caching if indexing fails
console.error('Error indexing event for search:', error);
}
}
}
}
/**

126
src/lib/services/keyboard-shortcuts.ts

@ -0,0 +1,126 @@ @@ -0,0 +1,126 @@
/**
* Global keyboard shortcuts handler
* Handles j/k navigation, r reply, z zap, / search, etc.
*/
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
handler: (e: KeyboardEvent) => void;
description?: string;
}
class KeyboardShortcutsManager {
private shortcuts: Map<string, KeyboardShortcut> = new Map();
private enabled = true;
/**
* Register a keyboard shortcut
*/
register(shortcut: KeyboardShortcut): () => void {
const key = this.getKeyString(shortcut);
this.shortcuts.set(key, shortcut);
// Return unregister function
return () => {
this.shortcuts.delete(key);
};
}
/**
* Unregister a keyboard shortcut
*/
unregister(key: string, modifiers?: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean }): void {
const keyString = this.getKeyString({ key, ...modifiers, handler: () => {} });
this.shortcuts.delete(keyString);
}
/**
* Enable/disable shortcuts
*/
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
/**
* Check if shortcuts are enabled
*/
isEnabled(): boolean {
return this.enabled;
}
/**
* Handle keyboard event
*/
handleKeydown(e: KeyboardEvent): void {
if (!this.enabled) return;
// Ignore if user is typing in an input, textarea, or contenteditable
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
// Allow / for search even in inputs
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
// Let it through
} else {
return;
}
}
const keyString = this.getKeyString({
key: e.key.toLowerCase(),
ctrl: e.ctrlKey,
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey,
handler: () => {}
});
const shortcut = this.shortcuts.get(keyString);
if (shortcut) {
e.preventDefault();
e.stopPropagation();
shortcut.handler(e);
}
}
/**
* Get key string for shortcut lookup
*/
private getKeyString(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
if (shortcut.ctrl || shortcut.meta) parts.push('ctrl');
if (shortcut.shift) parts.push('shift');
if (shortcut.alt) parts.push('alt');
parts.push(shortcut.key.toLowerCase());
return parts.join('+');
}
/**
* Initialize global keyboard handler
*/
initialize(): void {
if (typeof window === 'undefined') return;
const handler = (e: KeyboardEvent) => this.handleKeydown(e);
window.addEventListener('keydown', handler);
// Return cleanup function
return () => {
window.removeEventListener('keydown', handler);
};
}
}
export const keyboardShortcuts = new KeyboardShortcutsManager();
// Initialize on module load (browser only)
if (typeof window !== 'undefined') {
keyboardShortcuts.initialize();
}

20
src/routes/+layout.svelte

@ -1,8 +1,12 @@ @@ -1,8 +1,12 @@
<script lang="ts">
import '../app.css';
import { sessionManager } from '../lib/services/auth/session-manager.js';
import { keyboardShortcuts } from '../lib/services/keyboard-shortcuts.js';
import SearchModal from '../lib/components/modals/SearchModal.svelte';
import { onMount } from 'svelte';
let showSearch = $state(false);
// Restore session on app load
onMount(async () => {
try {
@ -10,7 +14,23 @@ @@ -10,7 +14,23 @@
} catch (error) {
console.error('Failed to restore session:', error);
}
// Register search shortcut
keyboardShortcuts.register({
key: '/',
handler: (e) => {
// Don't open if already in an input
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
showSearch = true;
},
description: 'Open search'
});
});
</script>
<slot />
<SearchModal open={showSearch} onClose={() => (showSearch = false)} />

Loading…
Cancel
Save