Browse Source

create more advanced cache and settings management

make create form more helpful
add write buttons to Threads and
master
Silberengel 1 month ago
parent
commit
451f811978
  1. 4
      public/healthz.json
  2. 51
      src/lib/components/layout/SearchBox.svelte
  3. 576
      src/lib/components/write/CreateEventForm.svelte
  4. 2
      src/lib/components/write/EditEventForm.svelte
  5. 5
      src/lib/modules/feed/FeedPage.svelte
  6. 36
      src/lib/modules/feed/FeedPost.svelte
  7. 42
      src/lib/modules/threads/ThreadCard.svelte
  8. 196
      src/lib/services/cache/cache-manager.ts
  9. 44
      src/routes/+page.svelte
  10. 876
      src/routes/cache/+page.svelte
  11. 60
      src/routes/feed/+page.svelte
  12. 14
      src/routes/write/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-04T09:37:24.895Z",
"buildTime": "2026-02-04T09:46:20.554Z",
"gitCommit": "unknown",
"timestamp": 1770197844895
"timestamp": 1770198380554
}

51
src/lib/components/layout/SearchBox.svelte

@ -13,22 +13,13 @@ @@ -13,22 +13,13 @@
let searchResults = $state<Array<{ event: NostrEvent; matchType: string }>>([]);
let showResults = $state(false);
let searchInput: HTMLInputElement | null = $state(null);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Decode bech32 identifiers
function decodeIdentifier(input: string): { type: 'event' | 'profile' | null; id: string | null; pubkey: string | null } {
const trimmed = input.trim();
// Check if it's a hex event ID (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return { type: 'event', id: trimmed.toLowerCase(), pubkey: null };
}
// Check if it's a hex pubkey (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return { type: 'profile', id: null, pubkey: trimmed.toLowerCase() };
}
// Check if it's a bech32 encoded format
// Check if it's a bech32 encoded format first (before hex)
if (/^(note|nevent|naddr|npub|nprofile)1[a-z0-9]+$/i.test(trimmed)) {
try {
const decoded = nip19.decode(trimmed);
@ -55,6 +46,14 @@ @@ -55,6 +46,14 @@
}
}
// Check if it's a hex identifier (64 hex characters)
// We can't distinguish between event IDs and pubkeys by format alone,
// so we'll try event ID first, then fall back to pubkey if event not found
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
// Default to event ID - if not found, we can try as pubkey in performSearch
return { type: 'event', id: trimmed.toLowerCase(), pubkey: trimmed.toLowerCase() };
}
return { type: null, id: null, pubkey: null };
}
@ -96,6 +95,10 @@ @@ -96,6 +95,10 @@
if (event) {
searchResults = [{ event, matchType: 'Event ID' }];
} else if (decoded.pubkey) {
// Event not found, but we have a pubkey - try as profile
handleProfileClick(decoded.pubkey);
return;
}
} else if (decoded.type === 'profile' && decoded.pubkey) {
// Search for profile - navigate directly to profile page
@ -145,9 +148,16 @@ @@ -145,9 +148,16 @@
const target = e.target as HTMLInputElement;
searchQuery = target.value;
// Debounce search
// Clear existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Debounce search - wait 300ms after user stops typing
if (searchQuery.trim()) {
searchTimeout = setTimeout(() => {
performSearch();
}, 300);
} else {
searchResults = [];
showResults = false;
@ -182,15 +192,26 @@ @@ -182,15 +192,26 @@
const target = e.target as HTMLElement;
if (!target.closest('.search-box-container')) {
showResults = false;
searchQuery = '';
}
};
document.addEventListener('click', handleClickOutside);
// Use capture phase to catch clicks before they bubble
document.addEventListener('click', handleClickOutside, true);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('click', handleClickOutside, true);
};
}
});
// Cleanup timeout on unmount
$effect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
});
</script>
<div class="search-box-container">
@ -215,7 +236,7 @@ @@ -215,7 +236,7 @@
{#each searchResults as { event, matchType }}
<button
onclick={() => {
if (matchType === 'Profile' || event.kind === KIND.METADATA) {
if (event.kind === KIND.METADATA) {
handleProfileClick(event.pubkey);
} else {
handleResultClick(event);

576
src/lib/components/write/CreateEventForm.svelte

@ -20,17 +20,323 @@ @@ -20,17 +20,323 @@
{ value: 30817, label: '30817 - AsciiDoc' },
{ value: 30041, label: '30041 - AsciiDoc' },
{ value: 30040, label: '30040 - Event Index (metadata-only)' },
{ value: 1068, label: '1068 - Poll' }
{ value: 1068, label: '1068 - Poll' },
{ value: -1, label: 'Unknown Kind' }
];
interface Props {
initialKind?: number | null;
}
let { initialKind = null }: Props = $props();
let selectedKind = $state<number>(1);
let customKindId = $state<string>('');
let content = $state('');
let tags = $state<string[][]>([]);
let publishing = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
// Sync selectedKind when initialKind prop changes
$effect(() => {
if (initialKind !== null && initialKind !== undefined) {
selectedKind = initialKind;
}
});
const isKind30040 = $derived(selectedKind === 30040);
const isUnknownKind = $derived(selectedKind === -1);
const effectiveKind = $derived(isUnknownKind ? (parseInt(customKindId) || 1) : selectedKind);
function getKindHelpText(kind: number): { description: string; suggestedTags: string[] } {
switch (kind) {
case 1:
return {
description: 'A simple plaintext note for social media. Use for short messages, replies, and general posts.',
suggestedTags: ['e (event references)', 'p (pubkey mentions)', 'q (quoted events)', 't (hashtags)']
};
case 11:
return {
description: 'A discussion thread. SHOULD include a title tag. Replies use kind 1111 comments (NIP-22).',
suggestedTags: ['title (required)', 't (topics/hashtags)']
};
case 9802:
return {
description: 'A highlight event to signal content you find valuable. Content is the highlighted text portion.',
suggestedTags: ['a (addressable event)', 'e (event reference)', 'r (URL reference)', 'p (author pubkeys)', 'context (surrounding text)', 'comment (for quote highlights)']
};
case 1222:
return {
description: 'A voice message (root). Content MUST be a URL to an audio file (audio/mp4 recommended). Duration SHOULD be ≤60 seconds.',
suggestedTags: ['imeta (with url, waveform, duration)', 't (hashtags)', 'g (geohash)']
};
case 20:
return {
description: 'A picture-first post. Content is a description. Images are referenced via imeta tags.',
suggestedTags: ['title', 'imeta (url, m, blurhash, dim, alt, x, fallback)', 'p (tagged users)', 'm (media type)', 'x (image hash)', 't (hashtags)', 'location', 'g (geohash)', 'L/l (language)', 'content-warning']
};
case 21:
case 22:
return {
description: kind === 21 ? 'A normal video post. Content is a summary/description.' : 'A short video post (stories/reels style). Content is a summary/description.',
suggestedTags: ['title (required)', 'imeta (url, dim, m, image, fallback, service, bitrate, duration)', 'published_at', 'text-track', 'content-warning', 'alt', 'segment', 't (hashtags)', 'p (participants)', 'r (web references)']
};
case 30023:
return {
description: 'A long-form article or blog post. Content is Markdown. Include a d tag for editability.',
suggestedTags: ['d (required for editability)', 'title', 'image', 'summary', 'published_at', 't (hashtags)']
};
case 30818:
return {
description: 'A wiki article (AsciiDoc). Content is AsciiDoc with wikilinks. Identified by lowercase, normalized d tag.',
suggestedTags: ['d (required, normalized)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
};
case 30817:
return {
description: 'An AsciiDoc article. Similar to 30818 but may have different conventions.',
suggestedTags: ['d (identifier)', 'title', 'summary', 'a (addressable event)', 'e (event reference)']
};
case 30041:
return {
description: 'Publication content section (AsciiDoc). Content is text/AsciiDoc with wikilinks. Part of a publication structure.',
suggestedTags: ['d (required)', 'title (required)', 'wikilink']
};
case 30040:
return {
description: 'Publication index (metadata-only). Content MUST be empty. Defines structure and metadata of a publication.',
suggestedTags: ['d (required)', 'title (required)', 'a (referenced events)', 'auto-update (yes|ask|no)', 'p (original author)', 'E (original event)', 'source', 'version', 'type', 'author', 'i (ISBN)', 't (hashtags)', 'published_on', 'published_by', 'image', 'summary']
};
case 1068:
return {
description: 'A poll event. Content is the poll label/question. Options and settings are in tags.',
suggestedTags: ['option (optionId, label)', 'relay (one or more)', 'polltype (singlechoice|multiplechoice)', 'endsAt (unix timestamp)']
};
default:
return {
description: `Custom kind ${kind}. Refer to the relevant NIP specification for tag requirements.`,
suggestedTags: []
};
}
}
const helpText = $derived(getKindHelpText(effectiveKind));
function getExampleJSON(kind: number): string {
const examplePubkey = '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
const exampleEventId = '67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446';
const exampleRelay = 'wss://relay.example.com';
const timestamp = Math.floor(Date.now() / 1000);
switch (kind) {
case 1:
return JSON.stringify({
kind: 1,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Hello nostr! This is a simple text note.',
tags: [
['e', exampleEventId, exampleRelay],
['p', examplePubkey],
['t', 'nostr']
],
id: '...',
sig: '...'
}, null, 2);
case 11:
return JSON.stringify({
kind: 11,
pubkey: examplePubkey,
created_at: timestamp,
content: 'This is a discussion thread about a topic.',
tags: [
['title', 'Discussion Thread Title'],
['t', 'topic1'],
['t', 'topic2']
],
id: '...',
sig: '...'
}, null, 2);
case 9802:
return JSON.stringify({
kind: 9802,
pubkey: examplePubkey,
created_at: timestamp,
content: 'This is the highlighted text portion.',
tags: [
['e', exampleEventId, exampleRelay],
['p', examplePubkey, '', 'author'],
['context', 'This is the highlighted text portion within the surrounding context text...']
],
id: '...',
sig: '...'
}, null, 2);
case 1222:
return JSON.stringify({
kind: 1222,
pubkey: examplePubkey,
created_at: timestamp,
content: 'https://example.com/audio/voice-message.m4a',
tags: [
['imeta', 'url https://example.com/audio/voice-message.m4a', 'waveform 0 7 35 8 100 100 49', 'duration 8'],
['t', 'voice']
],
id: '...',
sig: '...'
}, null, 2);
case 20:
return JSON.stringify({
kind: 20,
pubkey: examplePubkey,
created_at: timestamp,
content: 'A beautiful sunset photo',
tags: [
['title', 'Sunset Photo'],
['imeta', 'url https://nostr.build/i/image.jpg', 'm image/jpeg', 'dim 3024x4032', 'alt A scenic sunset'],
['t', 'photography'],
['location', 'San Francisco, CA']
],
id: '...',
sig: '...'
}, null, 2);
case 21:
return JSON.stringify({
kind: 21,
pubkey: examplePubkey,
created_at: timestamp,
content: 'A detailed video about Nostr protocol',
tags: [
['title', 'Introduction to Nostr'],
['imeta', 'url https://example.com/video.mp4', 'dim 1920x1080', 'm video/mp4', 'duration 300', 'bitrate 3000000'],
['published_at', timestamp.toString()],
['t', 'tutorial']
],
id: '...',
sig: '...'
}, null, 2);
case 22:
return JSON.stringify({
kind: 22,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Quick video update',
tags: [
['title', 'Quick Update'],
['imeta', 'url https://example.com/short.mp4', 'dim 1080x1920', 'm video/mp4', 'duration 15'],
['published_at', timestamp.toString()]
],
id: '...',
sig: '...'
}, null, 2);
case 30023:
return JSON.stringify({
kind: 30023,
pubkey: examplePubkey,
created_at: timestamp,
content: '# Long-form Article\n\nThis is a long-form article written in Markdown...',
tags: [
['d', 'article-slug'],
['title', 'My Long-form Article'],
['summary', 'A brief summary of the article'],
['published_at', timestamp.toString()],
['t', 'article'],
['t', 'longform']
],
id: '...',
sig: '...'
}, null, 2);
case 30818:
return JSON.stringify({
kind: 30818,
pubkey: examplePubkey,
created_at: timestamp,
content: '= Wiki Article\n\nThis is a wiki article written in AsciiDoc.',
tags: [
['d', 'wiki-article'],
['title', 'Wiki Article'],
['summary', 'A brief summary']
],
id: '...',
sig: '...'
}, null, 2);
case 30817:
return JSON.stringify({
kind: 30817,
pubkey: examplePubkey,
created_at: timestamp,
content: '= AsciiDoc Document\n\nContent in AsciiDoc format...',
tags: [
['d', 'asciidoc-doc'],
['title', 'AsciiDoc Document']
],
id: '...',
sig: '...'
}, null, 2);
case 30041:
return JSON.stringify({
kind: 30041,
pubkey: examplePubkey,
created_at: timestamp,
content: '= Chapter Title\n\nChapter content with [[wikilinks]]...',
tags: [
['d', 'publication-chapter-1'],
['title', 'Chapter 1: Introduction']
],
id: '...',
sig: '...'
}, null, 2);
case 30040:
return JSON.stringify({
kind: 30040,
pubkey: examplePubkey,
created_at: timestamp,
content: '',
tags: [
['d', 'publication-slug'],
['title', 'My Publication'],
['author', 'Author Name'],
['summary', 'Publication summary'],
['type', 'book'],
['a', '30041:' + examplePubkey + ':chapter-1', exampleRelay],
['a', '30041:' + examplePubkey + ':chapter-2', exampleRelay],
['auto-update', 'ask']
],
id: '...',
sig: '...'
}, null, 2);
case 1068:
return JSON.stringify({
kind: 1068,
pubkey: examplePubkey,
created_at: timestamp,
content: 'What is your favorite color?',
tags: [
['option', 'opt1', 'Red'],
['option', 'opt2', 'Blue'],
['option', 'opt3', 'Green'],
['relay', exampleRelay],
['polltype', 'singlechoice'],
['endsAt', (timestamp + 86400).toString()]
],
id: '...',
sig: '...'
}, null, 2);
default:
return JSON.stringify({
kind: kind,
pubkey: examplePubkey,
created_at: timestamp,
content: 'Custom event content',
tags: [
['example', 'tag', 'value']
],
id: '...',
sig: '...'
}, null, 2);
}
}
const exampleJSON = $derived(getExampleJSON(effectiveKind));
function addTag() {
tags = [...tags, ['', '']];
@ -60,11 +366,16 @@ @@ -60,11 +366,16 @@
return;
}
if (isUnknownKind && (!customKindId || isNaN(parseInt(customKindId)))) {
alert('Please enter a valid kind number');
return;
}
publishing = true;
try {
const eventTemplate = {
kind: selectedKind,
kind: effectiveKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]),
@ -114,7 +425,7 @@ @@ -114,7 +425,7 @@
if (!session) return;
const eventTemplate = {
kind: selectedKind,
kind: effectiveKind,
pubkey: session.pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: tags.filter(t => t[0] && t[1]),
@ -131,8 +442,33 @@ @@ -131,8 +442,33 @@
}
</script>
<div class="create-form">
<div class="create-form-container">
<div class="create-form">
<div class="form-header">
<h2 class="form-title">Create Event</h2>
<div class="help-text-panel">
<div class="help-header">
<p class="help-description">{helpText.description}</p>
<div class="example-button-wrapper">
<button class="example-button" type="button" title="Show example JSON">?</button>
<div class="example-tooltip">
<div class="example-tooltip-header">Example of {effectiveKind} event</div>
<pre class="example-json">{exampleJSON}</pre>
</div>
</div>
</div>
{#if helpText.suggestedTags.length > 0}
<div class="suggested-tags">
<strong>Suggested tags:</strong>
<ul>
{#each helpText.suggestedTags as tag}
<li>{tag}</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
<div class="form-group">
<label for="kind-select" class="form-label">Kind</label>
@ -141,11 +477,23 @@ @@ -141,11 +477,23 @@
<option value={kind.value}>{kind.label}</option>
{/each}
</select>
{#if isKind30040}
<p class="help-text">Note: Kind 30040 is metadata-only. Sections must be added manually using the edit function.</p>
{#if isUnknownKind}
<div class="custom-kind-input">
<label for="custom-kind-id" class="form-label">Kind ID</label>
<input
id="custom-kind-id"
type="number"
bind:value={customKindId}
placeholder="Enter kind number"
class="kind-id-input"
min="0"
max="65535"
/>
</div>
{/if}
</div>
{#if !isKind30040}
<div class="form-group">
<label for="content-textarea" class="form-label">Content</label>
<textarea
@ -154,9 +502,9 @@ @@ -154,9 +502,9 @@
class="content-input"
rows="10"
placeholder="Event content..."
disabled={isKind30040}
></textarea>
</div>
{/if}
<div class="form-group">
<fieldset>
@ -188,6 +536,8 @@ @@ -188,6 +536,8 @@
<button class="tag-remove" onclick={() => removeTag(index)}>×</button>
</div>
{/each}
</div>
<div class="add-tag-wrapper">
<button class="add-tag-button" onclick={addTag}>Add Tag</button>
</div>
</fieldset>
@ -202,6 +552,7 @@ @@ -202,6 +552,7 @@
{publishing ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>
</div>
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
@ -216,23 +567,197 @@ @@ -216,23 +567,197 @@
{/if}
<style>
.create-form-container {
display: flex;
gap: 2rem;
max-width: 1200px;
}
.create-form {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-header {
display: flex;
gap: 2rem;
align-items: flex-start;
}
.form-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
flex-shrink: 0;
}
:global(.dark) .form-title {
color: var(--fog-dark-text, #f9fafb);
}
.help-text-panel {
flex: 1;
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
font-size: 0.875rem;
}
:global(.dark) .help-text-panel {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.help-header {
display: flex;
align-items: flex-start;
gap: 0.5rem;
position: relative;
}
.help-description {
margin: 0 0 0.75rem 0;
color: var(--fog-text, #1f2937);
line-height: 1.5;
flex: 1;
}
:global(.dark) .help-description {
color: var(--fog-dark-text, #f9fafb);
}
.example-button-wrapper {
position: relative;
flex-shrink: 0;
}
.example-button {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s;
}
:global(.dark) .example-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.example-button:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .example-button:hover {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
.example-tooltip {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 1rem;
min-width: 400px;
max-width: 600px;
max-height: 500px;
overflow: auto;
z-index: 1000;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
:global(.dark) .example-tooltip {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.example-button-wrapper:hover .example-tooltip {
opacity: 1;
pointer-events: auto;
}
.example-tooltip-header {
font-weight: 600;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .example-tooltip-header {
color: var(--fog-dark-text, #f9fafb);
border-bottom-color: var(--fog-dark-border, #374151);
}
.example-json {
margin: 0;
font-size: 0.75rem;
font-family: 'Courier New', Courier, monospace;
color: var(--fog-text, #1f2937);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.4;
}
:global(.dark) .example-json {
color: var(--fog-dark-text, #f9fafb);
}
.suggested-tags {
margin-top: 0.75rem;
}
.suggested-tags strong {
display: block;
margin-bottom: 0.5rem;
color: var(--fog-text, #1f2937);
font-size: 0.8125rem;
}
:global(.dark) .suggested-tags strong {
color: var(--fog-dark-text, #f9fafb);
}
.suggested-tags ul {
margin: 0;
padding-left: 1.25rem;
color: var(--fog-text-light, #6b7280);
font-size: 0.8125rem;
}
:global(.dark) .suggested-tags ul {
color: var(--fog-dark-text-light, #9ca3af);
}
.suggested-tags li {
margin-bottom: 0.25rem;
}
.form-group {
display: flex;
flex-direction: column;
@ -264,15 +789,26 @@ @@ -264,15 +789,26 @@
color: var(--fog-dark-text, #f9fafb);
}
.help-text {
margin: 0;
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
font-style: italic;
.custom-kind-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
:global(.dark) .help-text {
color: var(--fog-dark-text-light, #9ca3af);
.kind-id-input {
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: 0.875rem;
}
:global(.dark) .kind-id-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.content-input {
@ -292,15 +828,11 @@ @@ -292,15 +828,11 @@
color: var(--fog-dark-text, #f9fafb);
}
.content-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tag-row {
@ -354,6 +886,10 @@ @@ -354,6 +886,10 @@
background: var(--fog-dark-border, #475569);
}
.add-tag-wrapper {
margin-top: 0.5rem;
}
.add-tag-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
@ -362,7 +898,6 @@ @@ -362,7 +898,6 @@
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
align-self: flex-start;
}
:global(.dark) .add-tag-button {
@ -381,6 +916,7 @@ @@ -381,6 +916,7 @@
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}

2
src/lib/components/write/EditEventForm.svelte

@ -131,7 +131,7 @@ @@ -131,7 +131,7 @@
</script>
<div class="edit-form">
<h2 class="form-title">Edit Event</h2>
<h2 class="form-title">Edit/Fork Event</h2>
<p class="form-description">Edit the event content and tags. ID, kind, pubkey, sig, and created_at are generated on publish.</p>
<div class="form-group">

5
src/lib/modules/feed/FeedPage.svelte

@ -415,6 +415,7 @@ @@ -415,6 +415,7 @@
const sorted = unique.sort((a, b) => b.created_at - a.created_at);
allPosts = sorted;
// Always set posts, even if empty
// Apply filter if one is selected
if (selectedListId) {
handleListFilterChange(selectedListId);
@ -422,10 +423,14 @@ @@ -422,10 +423,14 @@
posts = [...allPosts];
}
console.log(`[FeedPage] Loaded ${sorted.length} events, posts array has ${posts.length} items`);
if (sorted.length > 0) {
oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
// Batch load reactions for all posts
await loadReactionsForPosts(sorted);
} else {
console.log('[FeedPage] No events found. Relays:', relays);
}
hasMore = events.length >= 20;

36
src/lib/modules/feed/FeedPost.svelte

@ -356,19 +356,24 @@ @@ -356,19 +356,24 @@
>
{#if previewMode}
<!-- Preview mode: show only title and first 150 chars -->
<a href="/thread/{post.id}" class="card-link">
<div class="card-content">
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">
<a href="/thread/{post.id}">{getTitle()}</a>
{getTitle()}
</h3>
<div class="flex items-center gap-2">
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<div class="interactive-element">
<EventMenu event={post} showContentActions={true} />
</div>
</div>
</div>
<div class="mb-2 flex items-center gap-2 flex-wrap">
<div class="interactive-element">
<ProfileBadge pubkey={post.pubkey} />
</div>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
@ -399,9 +404,9 @@ @@ -399,9 +404,9 @@
</span>
{/if}
</div>
<a href="/thread/{post.id}" class="text-fog-accent dark:text-fog-dark-accent hover:underline">View thread →</a>
</div>
</div>
</a>
{:else}
<!-- Full mode: show complete content -->
<div class="card-content" class:expanded bind:this={contentElement}>
@ -613,6 +618,33 @@ @@ -613,6 +618,33 @@
background: var(--fog-dark-highlight, #374151);
}
.card-link {
display: block;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.card-link:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .card-link:hover {
background: var(--fog-dark-highlight, #475569);
}
.card-link {
pointer-events: auto;
}
.card-link > * {
pointer-events: none;
}
.card-link .interactive-element {
pointer-events: auto;
}
.post-header {
display: flex;
align-items: center;

42
src/lib/modules/threads/ThreadCard.svelte

@ -200,19 +200,24 @@ @@ -200,19 +200,24 @@
</script>
<article class="thread-card bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 mb-4 shadow-sm dark:shadow-lg">
<a href="/thread/{thread.id}" class="card-link">
<div class="card-content" class:expanded bind:this={contentElement}>
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">
<a href="/thread/{thread.id}">{getTitle()}</a>
{getTitle()}
</h3>
<div class="flex items-center gap-2">
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<div class="interactive-element">
<EventMenu event={thread} showContentActions={true} />
</div>
</div>
</div>
<div class="mb-2 flex items-center gap-2">
<div class="interactive-element">
<ProfileBadge pubkey={thread.pubkey} />
</div>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
@ -244,9 +249,9 @@ @@ -244,9 +249,9 @@
{/if}
{/if}
</div>
<a href="/thread/{thread.id}" class="ml-2 text-fog-accent dark:text-fog-dark-accent hover:underline">View thread →</a>
</div>
</div>
</a>
{#if needsExpansion}
<button
@ -269,6 +274,39 @@ @@ -269,6 +274,39 @@
position: relative;
}
.card-link {
display: block;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.card-link:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .card-link:hover {
background: var(--fog-dark-highlight, #475569);
}
.interactive-element {
position: relative;
z-index: 10;
pointer-events: auto;
}
.card-link {
pointer-events: auto;
}
.card-link > * {
pointer-events: none;
}
.card-link .interactive-element {
pointer-events: auto;
}
.thread-card a {
color: inherit;
text-decoration: none;

196
src/lib/services/cache/cache-manager.ts vendored

@ -0,0 +1,196 @@ @@ -0,0 +1,196 @@
/**
* Cache management utilities for browsing and managing cached events
*/
import { getDB } from './indexeddb-store.js';
import type { CachedEvent } from './event-cache.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface CacheStats {
totalEvents: number;
eventsByKind: Map<number, number>;
eventsByPubkey: Map<string, number>;
oldestEvent: number | null;
newestEvent: number | null;
totalSize: number; // Estimated size in bytes
}
/**
* Get statistics about the cache
*/
export async function getCacheStats(): Promise<CacheStats> {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const store = tx.store;
const stats: CacheStats = {
totalEvents: 0,
eventsByKind: new Map(),
eventsByPubkey: new Map(),
oldestEvent: null,
newestEvent: null,
totalSize: 0
};
let count = 0;
for await (const cursor of store.iterate()) {
const event = cursor.value as CachedEvent;
count++;
// Count by kind
const kindCount = stats.eventsByKind.get(event.kind) || 0;
stats.eventsByKind.set(event.kind, kindCount + 1);
// Count by pubkey
const pubkeyCount = stats.eventsByPubkey.get(event.pubkey) || 0;
stats.eventsByPubkey.set(event.pubkey, pubkeyCount + 1);
// Track oldest/newest
if (stats.oldestEvent === null || event.created_at < stats.oldestEvent) {
stats.oldestEvent = event.created_at;
}
if (stats.newestEvent === null || event.created_at > stats.newestEvent) {
stats.newestEvent = event.created_at;
}
// Estimate size (rough calculation)
const eventSize = JSON.stringify(event).length;
stats.totalSize += eventSize;
}
stats.totalEvents = count;
await tx.done;
return stats;
}
/**
* Get all cached events with pagination
*/
export async function getAllCachedEvents(
options: {
kind?: number;
pubkey?: string;
limit?: number;
offset?: number;
searchTerm?: string;
} = {}
): Promise<CachedEvent[]> {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
let events: CachedEvent[] = [];
if (options.kind !== undefined) {
// Use kind index
const index = tx.store.index('kind');
for await (const cursor of index.iterate(options.kind)) {
events.push(cursor.value as CachedEvent);
}
} else if (options.pubkey) {
// Use pubkey index
const index = tx.store.index('pubkey');
for await (const cursor of index.iterate(options.pubkey)) {
events.push(cursor.value as CachedEvent);
}
} else {
// Get all events
for await (const cursor of tx.store.iterate()) {
events.push(cursor.value as CachedEvent);
}
}
await tx.done;
// Apply search filter if provided
if (options.searchTerm) {
const searchLower = options.searchTerm.toLowerCase();
events = events.filter(event => {
const eventStr = JSON.stringify(event).toLowerCase();
return eventStr.includes(searchLower) ||
event.id.toLowerCase().includes(searchLower) ||
event.content.toLowerCase().includes(searchLower);
});
}
// Sort by created_at descending
events.sort((a, b) => b.created_at - a.created_at);
// Apply pagination
const offset = options.offset || 0;
const limit = options.limit || 100;
return events.slice(offset, offset + limit);
}
/**
* Estimate cache size in bytes
*/
export async function getCacheSize(): Promise<number> {
const stats = await getCacheStats();
return stats.totalSize;
}
/**
* Clear events by kind
*/
export async function clearCacheByKind(kind: number): Promise<number> {
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
const index = tx.store.index('kind');
let deleted = 0;
for await (const cursor of index.iterate(kind)) {
await cursor.delete();
deleted++;
}
await tx.done;
return deleted;
}
/**
* Clear events older than timestamp
*/
export async function clearCacheByDate(olderThan: number): Promise<number> {
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
const index = tx.store.index('created_at');
let deleted = 0;
for await (const cursor of index.iterate()) {
const event = cursor.value as CachedEvent;
if (event.created_at < olderThan) {
await cursor.delete();
deleted++;
}
}
await tx.done;
return deleted;
}
/**
* Clear all cache
*/
export async function clearAllCache(): Promise<number> {
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
let deleted = 0;
for await (const cursor of tx.store.iterate()) {
await cursor.delete();
deleted++;
}
await tx.done;
return deleted;
}
/**
* Delete a specific event by ID
*/
export async function deleteEventById(id: string): Promise<boolean> {
const db = await getDB();
await db.delete('events', id);
return true;
}

44
src/routes/+page.svelte

@ -18,6 +18,9 @@ @@ -18,6 +18,9 @@
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Threads</h1>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr. Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="text-fog-accent dark:text-fog-dark-accent hover:text-fog-text dark:hover:text-fog-dark-text underline transition-colors">Silberengel</a>.</p>
</div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<div class="search-section mb-6">
@ -38,4 +41,45 @@ @@ -38,4 +41,45 @@
justify-content: space-between;
align-items: flex-start;
}
.write-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
text-decoration: none;
transition: all 0.2s;
min-width: 2.5rem;
min-height: 2.5rem;
}
:global(.dark) .write-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.write-button:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .write-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.emoji {
font-size: 1.25rem;
line-height: 1;
}
.emoji-grayscale {
filter: grayscale(100%);
opacity: 0.7;
}
</style>

876
src/routes/cache/+page.svelte vendored

@ -0,0 +1,876 @@ @@ -0,0 +1,876 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js';
import type { CachedEvent } from '../../lib/services/cache/event-cache.js';
import { KIND } from '../../lib/types/kind-lookup.js';
let stats = $state<CacheStats | null>(null);
let events = $state<CachedEvent[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let offset = $state(0);
const PAGE_SIZE = 50;
// Filters
let selectedKind = $state<number | null>(null);
let selectedPubkey = $state<string>('');
let searchTerm = $state<string>('');
// UI state
let expandedEvents = $state<Set<string>>(new Set());
let deletingEventId = $state<string | null>(null);
onMount(async () => {
await loadStats();
await loadEvents();
});
async function loadStats() {
try {
stats = await getCacheStats();
} catch (error) {
console.error('Error loading cache stats:', error);
}
}
async function loadEvents(reset = false) {
if (reset) {
offset = 0;
events = [];
hasMore = true;
}
if (loadingMore || (!hasMore && !reset)) return;
loadingMore = true;
try {
const newEvents = await getAllCachedEvents({
kind: selectedKind || undefined,
pubkey: selectedPubkey || undefined,
searchTerm: searchTerm || undefined,
limit: PAGE_SIZE,
offset: reset ? 0 : offset
});
if (reset) {
events = newEvents;
} else {
events = [...events, ...newEvents];
}
hasMore = newEvents.length === PAGE_SIZE;
offset = events.length;
} catch (error) {
console.error('Error loading events:', error);
} finally {
loadingMore = false;
loading = false;
}
}
async function handleFilterChange() {
await loadEvents(true);
await loadStats();
}
async function handleDeleteEvent(id: string) {
if (!confirm('Are you sure you want to delete this event from cache?')) {
return;
}
deletingEventId = id;
try {
await deleteEventById(id);
events = events.filter(e => e.id !== id);
expandedEvents.delete(id);
await loadStats();
} catch (error) {
console.error('Error deleting event:', error);
alert('Failed to delete event');
} finally {
deletingEventId = null;
}
}
async function handleClearAll() {
if (!confirm('Are you sure you want to clear all cache? This cannot be undone.')) {
return;
}
try {
await clearAllCache();
events = [];
await loadStats();
alert('Cache cleared successfully');
} catch (error) {
console.error('Error clearing cache:', error);
alert('Failed to clear cache');
}
}
async function handleClearByKind(kind: number) {
const kindName = getKindName(kind);
if (!confirm(`Are you sure you want to clear all kind ${kind} (${kindName}) events from cache?`)) {
return;
}
try {
const deleted = await clearCacheByKind(kind);
events = events.filter(e => e.kind !== kind);
await loadStats();
alert(`Deleted ${deleted} events`);
} catch (error) {
console.error('Error clearing cache by kind:', error);
alert('Failed to clear cache');
}
}
async function handleClearByDate(days: number) {
if (!confirm(`Are you sure you want to clear all events older than ${days} days?`)) {
return;
}
try {
const olderThan = Math.floor(Date.now() / 1000) - (days * 86400);
const deleted = await clearCacheByDate(olderThan);
events = events.filter(e => e.created_at >= olderThan);
await loadStats();
alert(`Deleted ${deleted} events`);
} catch (error) {
console.error('Error clearing cache by date:', error);
alert('Failed to clear cache');
}
}
function toggleExpand(eventId: string) {
if (expandedEvents.has(eventId)) {
expandedEvents.delete(eventId);
} else {
expandedEvents.add(eventId);
}
expandedEvents = new Set(expandedEvents);
}
async function copyEventJson(event: CachedEvent) {
try {
const json = JSON.stringify(event, null, 2);
await navigator.clipboard.writeText(json);
// Could show a toast notification here
} catch (error) {
console.error('Error copying to clipboard:', error);
alert('Failed to copy to clipboard');
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString();
}
function getKindName(kind: number): string {
const kindNames: Record<number, string> = {
[KIND.METADATA]: 'Metadata',
[KIND.SHORT_TEXT_NOTE]: 'Short Text Note',
[KIND.REACTION]: 'Reaction',
[KIND.DISCUSSION_THREAD]: 'Discussion Thread',
[KIND.COMMENT]: 'Comment',
};
return kindNames[kind] || `Kind ${kind}`;
}
function getKindOptions(): number[] {
if (!stats) return [];
return Array.from(stats.eventsByKind.keys()).sort((a, b) => a - b);
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="cache-page">
<h1 class="page-title">Cache Manager</h1>
{#if loading && !stats}
<div class="loading-state">
<p>Loading cache statistics...</p>
</div>
{:else if stats}
<!-- Statistics -->
<div class="stats-section">
<h2 class="section-title">Statistics</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Events</div>
<div class="stat-value">{stats.totalEvents.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Cache Size</div>
<div class="stat-value">{formatBytes(stats.totalSize)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Oldest Event</div>
<div class="stat-value">{stats.oldestEvent ? formatDate(stats.oldestEvent) : 'N/A'}</div>
</div>
<div class="stat-card">
<div class="stat-label">Newest Event</div>
<div class="stat-value">{stats.newestEvent ? formatDate(stats.newestEvent) : 'N/A'}</div>
</div>
</div>
<!-- Events by Kind -->
{#if stats.eventsByKind.size > 0}
<div class="kind-stats">
<h3 class="subsection-title">Events by Kind</h3>
<div class="kind-list">
{#each Array.from(stats.eventsByKind.entries()).sort((a, b) => b[1] - a[1]) as [kind, count]}
<div class="kind-item">
<span class="kind-name">{getKindName(kind)} ({kind})</span>
<span class="kind-count">{count.toLocaleString()}</span>
<button
class="clear-kind-button"
onclick={() => handleClearByKind(kind)}
title="Clear all events of this kind"
>
Clear
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>
<!-- Filters -->
<div class="filters-section">
<h2 class="section-title">Filters</h2>
<div class="filters-grid">
<div class="filter-group">
<label for="kind-filter" class="filter-label">Kind</label>
<select
id="kind-filter"
bind:value={selectedKind}
onchange={handleFilterChange}
class="filter-select"
>
<option value={null}>All Kinds</option>
{#each getKindOptions() as kind}
<option value={kind}>{getKindName(kind)} ({kind})</option>
{/each}
</select>
</div>
<div class="filter-group">
<label for="pubkey-filter" class="filter-label">Pubkey</label>
<input
id="pubkey-filter"
type="text"
bind:value={selectedPubkey}
oninput={() => {
// Debounce search
const timeout = setTimeout(() => handleFilterChange(), 500);
return () => clearTimeout(timeout);
}}
placeholder="Filter by pubkey..."
class="filter-input"
/>
</div>
<div class="filter-group">
<label for="search-filter" class="filter-label">Search</label>
<input
id="search-filter"
type="text"
bind:value={searchTerm}
oninput={() => {
const timeout = setTimeout(() => handleFilterChange(), 500);
return () => clearTimeout(timeout);
}}
placeholder="Search event ID or content..."
class="filter-input"
/>
</div>
</div>
</div>
<!-- Bulk Actions -->
<div class="bulk-actions-section">
<h2 class="section-title">Bulk Actions</h2>
<div class="bulk-actions">
<button class="bulk-action-button" onclick={handleClearAll}>
Clear All Cache
</button>
<button class="bulk-action-button" onclick={() => handleClearByDate(30)}>
Clear Events Older Than 30 Days
</button>
<button class="bulk-action-button" onclick={() => handleClearByDate(7)}>
Clear Events Older Than 7 Days
</button>
</div>
</div>
<!-- Events List -->
<div class="events-section">
<h2 class="section-title">Cached Events ({events.length})</h2>
{#if loading && events.length === 0}
<div class="loading-state">
<p>Loading events...</p>
</div>
{:else if events.length === 0}
<div class="empty-state">
<p>No events found in cache.</p>
</div>
{:else}
<div class="events-list">
{#each events as event (event.id)}
<div class="event-card">
<button
class="copy-button-top"
onclick={() => copyEventJson(event)}
title="Copy event JSON"
>
📋
</button>
<div class="event-header">
<div class="event-info">
<div class="event-id">
<strong>ID:</strong>
<code class="event-id-code">{event.id.substring(0, 16)}...</code>
<a href="/event/{event.id}" class="event-link" target="_blank">View</a>
</div>
<div class="event-meta">
<span><strong>Kind:</strong> {getKindName(event.kind)} ({event.kind})</span>
<span><strong>Pubkey:</strong> <code>{event.pubkey.substring(0, 16)}...</code></span>
<span><strong>Created:</strong> {formatDate(event.created_at)}</span>
<span><strong>Cached:</strong> {formatDate(event.cached_at / 1000)}</span>
</div>
</div>
<div class="event-actions">
<button
class="delete-button"
onclick={() => handleDeleteEvent(event.id)}
disabled={deletingEventId === event.id}
title="Delete from cache"
>
{deletingEventId === event.id ? 'Deleting...' : '🗑 Delete'}
</button>
</div>
</div>
{#if expandedEvents.has(event.id)}
<div class="event-content">
<div class="event-json" contenteditable="true" spellcheck="false">
{JSON.stringify(event, null, 2)}
</div>
<button class="collapse-button" onclick={() => toggleExpand(event.id)}>
Collapse
</button>
</div>
{:else}
<div class="event-preview">
<p class="event-content-preview">{event.content.substring(0, 200)}{event.content.length > 200 ? '...' : ''}</p>
<button class="expand-button" onclick={() => toggleExpand(event.id)}>
Expand JSON
</button>
</div>
{/if}
</div>
{/each}
</div>
{#if hasMore}
<div class="load-more-section">
<button
class="load-more-button"
onclick={() => loadEvents()}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load More'}
</button>
</div>
{/if}
{/if}
</div>
{/if}
</div>
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.cache-page {
max-width: 100%;
}
.page-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 2rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .page-title {
color: var(--fog-dark-text, #f9fafb);
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .section-title {
color: var(--fog-dark-text, #f9fafb);
}
.subsection-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.75rem;
margin-top: 1.5rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .subsection-title {
color: var(--fog-dark-text, #f9fafb);
}
.stats-section,
.filters-section,
.bulk-actions-section,
.events-section {
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .stats-section,
:global(.dark) .filters-section,
:global(.dark) .bulk-actions-section,
:global(.dark) .events-section {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
}
:global(.dark) .stat-card {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
}
.stat-label {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
margin-bottom: 0.5rem;
}
:global(.dark) .stat-label {
color: var(--fog-dark-text-light, #9ca3af);
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .stat-value {
color: var(--fog-dark-text, #f9fafb);
}
.kind-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kind-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
}
:global(.dark) .kind-item {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
}
.kind-name {
flex: 1;
color: var(--fog-text, #1f2937);
}
:global(.dark) .kind-name {
color: var(--fog-dark-text, #f9fafb);
}
.kind-count {
margin-right: 1rem;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .kind-count {
color: var(--fog-dark-text-light, #9ca3af);
}
.clear-kind-button {
padding: 0.25rem 0.75rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
.clear-kind-button:hover {
opacity: 0.9;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .filter-label {
color: var(--fog-dark-text, #f9fafb);
}
.filter-select,
.filter-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
}
:global(.dark) .filter-select,
:global(.dark) .filter-input {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.bulk-actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.bulk-action-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.bulk-action-button:hover {
opacity: 0.9;
}
.events-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.event-card {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
padding: 1rem;
position: relative;
}
.copy-button-top {
position: absolute;
top: 0.75rem;
right: 0.75rem;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
z-index: 10;
}
:global(.dark) .copy-button-top {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.copy-button-top:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .event-card {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
}
.event-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.75rem;
}
.event-info {
flex: 1;
min-width: 0;
}
.event-id {
margin-bottom: 0.5rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .event-id {
color: var(--fog-dark-text, #f9fafb);
}
.event-id-code {
font-family: monospace;
font-size: 0.875rem;
background: var(--fog-highlight, #f3f4f6);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
margin: 0 0.5rem;
}
:global(.dark) .event-id-code {
background: var(--fog-dark-highlight, #475569);
}
.event-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
margin-left: 0.5rem;
font-size: 0.875rem;
}
.event-link:hover {
text-decoration: underline;
}
.event-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .event-meta {
color: var(--fog-dark-text-light, #9ca3af);
}
.event-meta code {
font-family: monospace;
font-size: 0.8125rem;
}
.event-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.delete-button {
padding: 0.5rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
:global(.dark) .copy-button,
:global(.dark) .delete-button {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.copy-button:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.delete-button:hover {
background: #ef4444;
color: white;
border-color: #ef4444;
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.event-content {
margin-top: 0.75rem;
}
.event-json {
font-family: monospace;
font-size: 0.8125rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
padding: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 500px;
overflow-y: auto;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
color: var(--fog-text, #1f2937);
}
:global(.dark) .event-json {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.event-preview {
margin-top: 0.75rem;
}
.event-content-preview {
margin: 0 0 0.5rem 0;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
}
:global(.dark) .event-content-preview {
color: var(--fog-dark-text-light, #9ca3af);
}
.expand-button,
.collapse-button {
padding: 0.5rem 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .expand-button,
:global(.dark) .collapse-button {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
.expand-button:hover,
.collapse-button:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.load-more-section {
text-align: center;
margin-top: 2rem;
}
.load-more-button {
padding: 0.75rem 2rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.load-more-button:hover:not(:disabled) {
opacity: 0.9;
}
.load-more-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-state,
.empty-state {
padding: 2rem;
text-align: center;
color: var(--fog-text, #1f2937);
}
:global(.dark) .loading-state,
:global(.dark) .empty-state {
color: var(--fog-dark-text, #f9fafb);
}
</style>

60
src/routes/feed/+page.svelte

@ -13,9 +13,14 @@ @@ -13,9 +13,14 @@
<Header />
<main class="container mx-auto px-4 py-8">
<div class="search-section mb-6">
<div class="feed-header mb-6">
<div class="search-section">
<SearchBox />
</div>
<a href="/write?kind=1" class="write-button" title="Write a new post">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<FeedPage />
</main>
@ -24,4 +29,57 @@ @@ -24,4 +29,57 @@
max-width: var(--content-width);
margin: 0 auto;
}
.feed-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.search-section {
flex: 1;
}
.write-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
text-decoration: none;
transition: all 0.2s;
min-width: 2.5rem;
min-height: 2.5rem;
flex-shrink: 0;
}
:global(.dark) .write-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.write-button:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .write-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.emoji {
font-size: 1.25rem;
line-height: 1;
}
.emoji-grayscale {
filter: grayscale(100%);
opacity: 0.7;
}
</style>

14
src/routes/write/+page.svelte

@ -5,12 +5,24 @@ @@ -5,12 +5,24 @@
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
let mode = $state<'select' | 'find' | 'create'>('select');
let initialKind = $state<number | null>(null);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
onMount(async () => {
await nostrClient.initialize();
// Check for kind parameter in URL
const kindParam = $page.url.searchParams.get('kind');
if (kindParam) {
const kind = parseInt(kindParam, 10);
if (!isNaN(kind)) {
initialKind = kind;
mode = 'create';
}
}
});
</script>
@ -48,7 +60,7 @@ @@ -48,7 +60,7 @@
{:else if mode === 'create'}
<div class="form-container">
<button class="back-button" onclick={() => mode = 'select'}> Back</button>
<CreateEventForm />
<CreateEventForm initialKind={initialKind} />
</div>
{/if}
</div>

Loading…
Cancel
Save