Browse Source

more bug-fixes

master
Silberengel 1 month ago
parent
commit
c4a7f16759
  1. 8
      src/app.css
  2. 13
      src/lib/components/content/EmbeddedEvent.svelte
  3. 655
      src/lib/components/content/FileExplorer.svelte
  4. 50
      src/lib/components/content/MarkdownRenderer.svelte
  5. 727
      src/lib/components/preferences/UserPreferences.svelte
  6. 15
      src/lib/components/write/FindEventForm.svelte
  7. 51
      src/lib/modules/discussions/DiscussionList.svelte
  8. 224
      src/lib/modules/feed/FeedPage.svelte
  9. 83
      src/lib/modules/feed/FeedPost.svelte
  10. 47
      src/lib/services/cache/event-cache.ts
  11. 33
      src/routes/find/+page.svelte
  12. 47
      src/routes/repos/+page.svelte
  13. 516
      src/routes/repos/[naddr]/+page.svelte
  14. 334
      src/routes/rss/+page.svelte
  15. 105
      src/routes/topics/+page.svelte
  16. 92
      src/routes/topics/[name]/+page.svelte

8
src/app.css

@ -6,21 +6,21 @@ @@ -6,21 +6,21 @@
@tailwind utilities;
:root {
--text-size: 16px;
--text-size: 12px;
--line-height: 1.6;
--content-width: 800px;
}
[data-text-size='small'] {
--text-size: 12px;
--text-size: 10px;
}
[data-text-size='medium'] {
--text-size: 14px;
--text-size: 12px;
}
[data-text-size='large'] {
--text-size: 16px;
--text-size: 14px;
}
[data-line-spacing='tight'] {

13
src/lib/components/content/EmbeddedEvent.svelte

@ -113,12 +113,25 @@ @@ -113,12 +113,25 @@
} catch (e) {
console.error('Failed to decode event ID:', e);
error = true;
loading = false;
loadingEvent = false;
return;
}
}
if (!hexId) {
error = true;
loading = false;
loadingEvent = false;
return;
}
// Validate hexId is exactly 64 characters (proper event ID length)
if (hexId.length !== 64 || !/^[0-9a-f]{64}$/i.test(hexId)) {
console.warn('Invalid hex event ID length or format:', hexId);
error = true;
loading = false;
loadingEvent = false;
return;
}

655
src/lib/components/content/FileExplorer.svelte

@ -0,0 +1,655 @@ @@ -0,0 +1,655 @@
<script lang="ts">
import type { GitFile, GitRepoInfo } from '../../services/content/git-repo-fetcher.js';
interface Props {
files: GitFile[];
repoInfo: GitRepoInfo;
}
let { files, repoInfo }: Props = $props();
// Track expanded folders
let expandedPaths = $state<Set<string>>(new Set());
// Track selected file and its content
let selectedFile = $state<GitFile | null>(null);
let fileContent = $state<string | null>(null);
let loadingContent = $state(false);
let contentError = $state<string | null>(null);
// Build tree structure
function buildTree(files: GitFile[]): any {
const tree: any = {};
for (const file of files) {
const parts = file.path.split('/').filter(p => p);
let current = tree;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i === parts.length - 1) {
// File
current[part] = file;
} else {
// Directory
if (!current[part] || current[part].path) {
current[part] = {};
}
current = current[part];
}
}
}
return tree;
}
const fileTree = $derived.by(() => buildTree(files));
function toggleFolder(path: string) {
if (expandedPaths.has(path)) {
expandedPaths.delete(path);
} else {
expandedPaths.add(path);
}
expandedPaths = new Set(expandedPaths); // Trigger reactivity
}
function isExpanded(path: string): boolean {
return expandedPaths.has(path);
}
async function fetchFileContent(file: GitFile) {
if (selectedFile?.path === file.path && fileContent) {
return; // Already loaded
}
selectedFile = file;
loadingContent = true;
contentError = null;
fileContent = null;
try {
// Parse the repo URL to determine platform
const url = repoInfo.url;
let apiUrl = '';
if (url.includes('github.com')) {
// GitHub API: GET /repos/{owner}/{repo}/contents/{path}
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match) {
const [, owner, repo] = match;
apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`;
}
} else if (url.includes('gitlab.com')) {
// GitLab API: GET /projects/{id}/repository/files/{path}/raw
const match = url.match(/gitlab\.com\/([^/]+)\/([^/]+)/);
if (match) {
const [, owner, repo] = match;
const projectPath = encodeURIComponent(`${owner}/${repo}`);
apiUrl = `https://gitlab.com/api/v4/projects/${projectPath}/repository/files/${encodeURIComponent(file.path)}/raw?ref=${repoInfo.defaultBranch}`;
}
} else {
// Try Gitea pattern
const match = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)/);
if (match) {
const [, baseUrl, owner, repo] = match;
apiUrl = `${baseUrl}/api/v1/repos/${owner}/${repo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`;
}
}
if (!apiUrl) {
throw new Error('Unable to determine API endpoint for this repository');
}
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
// GitHub returns base64 encoded content, GitLab/Gitea return raw text
if (data.content) {
// GitHub format
fileContent = atob(data.content.replace(/\s/g, ''));
} else {
// GitLab/Gitea format (raw text)
fileContent = typeof data === 'string' ? data : await response.text();
}
} catch (error) {
console.error('Error fetching file content:', error);
contentError = error instanceof Error ? error.message : 'Failed to load file content';
} finally {
loadingContent = false;
}
}
function getFileIcon(file: GitFile): string {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const icons: Record<string, string> = {
'js': '📜', 'ts': '📘', 'jsx': '⚛', 'tsx': '⚛',
'py': '🐍', 'java': '☕', 'cpp': '⚙', 'c': '⚙',
'html': '🌐', 'css': '🎨', 'scss': '🎨', 'sass': '🎨',
'json': '📋', 'yaml': '📋', 'yml': '📋', 'toml': '📋',
'md': '📝', 'txt': '📄', 'adoc': '📝',
'png': '🖼', 'jpg': '🖼', 'jpeg': '🖼', 'gif': '🖼', 'svg': '🖼',
'pdf': '📕', 'zip': '📦', 'tar': '📦', 'gz': '📦',
'sh': '💻', 'bash': '💻', 'zsh': '💻',
'rs': '🦀', 'go': '🐹', 'php': '🐘', 'rb': '💎'
};
return icons[ext] || '📄';
}
function formatFileSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
<div class="file-explorer">
<div class="file-tree-panel">
<div class="file-tree-header">
<h3 class="file-tree-title">Files</h3>
<button
onclick={() => {
// Expand all
const allPaths = new Set<string>();
function collectPaths(tree: any, path: string = '') {
for (const [name, value] of Object.entries(tree)) {
if (value && typeof value === 'object' && !('path' in value)) {
const newPath = path ? `${path}/${name}` : name;
allPaths.add(newPath);
collectPaths(value, newPath);
}
}
}
collectPaths(fileTree);
expandedPaths = allPaths;
}}
class="expand-all-btn"
>
Expand All
</button>
<button
onclick={() => {
expandedPaths = new Set();
}}
class="collapse-all-btn"
>
Collapse All
</button>
</div>
<div class="file-tree-content">
{#each Object.entries(fileTree) as [name, value]}
{@const isFile = value && typeof value === 'object' && 'path' in value}
{@const isDir = value && typeof value === 'object' && !('path' in value)}
{#if isDir}
{@const dirPath = name}
{@const isExpandedDir = isExpanded(dirPath)}
<div class="tree-item tree-folder">
<button
onclick={() => toggleFolder(dirPath)}
class="tree-folder-btn"
aria-expanded={isExpandedDir}
>
<span class="tree-icon">{isExpandedDir ? '📂' : '📁'}</span>
<span class="tree-name">{name}</span>
</button>
{#if isExpandedDir}
<div class="tree-children">
{#each Object.entries(value).sort(([a, valA], [b, valB]) => {
const aIsFile = valA && typeof valA === 'object' && 'path' in valA;
const bIsFile = valB && typeof valB === 'object' && 'path' in valB;
const aIsDir = valA && typeof valA === 'object' && !('path' in valA);
const bIsDir = valB && typeof valB === 'object' && !('path' in valB);
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.localeCompare(b);
}) as [subName, subValue]}
{#if subValue && typeof subValue === 'object' && 'path' in subValue}
{@const file = subValue as GitFile}
<div class="tree-item tree-file" class:selected={selectedFile?.path === file.path}>
<button
onclick={() => fetchFileContent(file)}
class="tree-file-btn"
>
<span class="tree-icon">{getFileIcon(file)}</span>
<span class="tree-name">{subName}</span>
{#if file.size}
<span class="tree-size">{formatFileSize(file.size)}</span>
{/if}
</button>
</div>
{:else if subValue && typeof subValue === 'object'}
{@const subDirPath = `${dirPath}/${subName}`}
{@const isExpandedSubDir = isExpanded(subDirPath)}
<div class="tree-item tree-folder tree-nested">
<button
onclick={() => toggleFolder(subDirPath)}
class="tree-folder-btn"
aria-expanded={isExpandedSubDir}
>
<span class="tree-icon">{isExpandedSubDir ? '📂' : '📁'}</span>
<span class="tree-name">{subName}</span>
</button>
{#if isExpandedSubDir}
<div class="tree-children">
<!-- Recursive rendering for nested folders -->
{#each Object.entries(subValue).sort(([a, valA], [b, valB]) => {
const aIsFile = valA && typeof valA === 'object' && 'path' in valA;
const bIsFile = valB && typeof valB === 'object' && 'path' in valB;
const aIsDir = valA && typeof valA === 'object' && !('path' in valA);
const bIsDir = valB && typeof valB === 'object' && !('path' in valB);
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.localeCompare(b);
}) as [nestedName, nestedValue]}
{#if nestedValue && typeof nestedValue === 'object' && 'path' in nestedValue}
{@const nestedFile = nestedValue as GitFile}
<div class="tree-item tree-file" class:selected={selectedFile?.path === nestedFile.path}>
<button
onclick={() => fetchFileContent(nestedFile)}
class="tree-file-btn"
>
<span class="tree-icon">{getFileIcon(nestedFile)}</span>
<span class="tree-name">{nestedName}</span>
{#if nestedFile.size}
<span class="tree-size">{formatFileSize(nestedFile.size)}</span>
{/if}
</button>
</div>
{:else if nestedValue && typeof nestedValue === 'object'}
{@const deeperDirPath = `${subDirPath}/${nestedName}`}
{@const isExpandedDeeper = isExpanded(deeperDirPath)}
<div class="tree-item tree-folder tree-nested">
<button
onclick={() => toggleFolder(deeperDirPath)}
class="tree-folder-btn"
aria-expanded={isExpandedDeeper}
>
<span class="tree-icon">{isExpandedDeeper ? '📂' : '📁'}</span>
<span class="tree-name">{nestedName}</span>
</button>
{#if isExpandedDeeper}
<div class="tree-children">
{#each Object.entries(nestedValue).sort(([a, valA], [b, valB]) => {
const aIsFile = valA && typeof valA === 'object' && 'path' in valA;
const bIsFile = valB && typeof valB === 'object' && 'path' in valB;
const aIsDir = valA && typeof valA === 'object' && !('path' in valA);
const bIsDir = valB && typeof valB === 'object' && !('path' in valB);
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.localeCompare(b);
}) as [deepName, deepValue]}
{#if deepValue && typeof deepValue === 'object' && 'path' in deepValue}
{@const deepFile = deepValue as GitFile}
<div class="tree-item tree-file" class:selected={selectedFile?.path === deepFile.path}>
<button
onclick={() => fetchFileContent(deepFile)}
class="tree-file-btn"
>
<span class="tree-icon">{getFileIcon(deepFile)}</span>
<span class="tree-name">{deepName}</span>
{#if deepFile.size}
<span class="tree-size">{formatFileSize(deepFile.size)}</span>
{/if}
</button>
</div>
{:else}
<div class="tree-item tree-folder tree-nested">
<span class="tree-icon">📁</span>
<span class="tree-name">{deepName}/</span>
<span class="tree-note">(deeper nesting not fully expanded)</span>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</div>
{:else if isFile}
{@const file = value as GitFile}
<div class="tree-item tree-file" class:selected={selectedFile?.path === file.path}>
<button
onclick={() => fetchFileContent(file)}
class="tree-file-btn"
>
<span class="tree-icon">{getFileIcon(file)}</span>
<span class="tree-name">{name}</span>
{#if file.size}
<span class="tree-size">{formatFileSize(file.size)}</span>
{/if}
</button>
</div>
{/if}
{/each}
</div>
</div>
<div class="file-content-panel">
{#if loadingContent}
<div class="file-content-loading">
<p>Loading file content...</p>
</div>
{:else if contentError}
<div class="file-content-error">
<p>Error: {contentError}</p>
</div>
{:else if selectedFile && fileContent !== null}
<div class="file-content-header">
<h3 class="file-content-title">{selectedFile.path}</h3>
<span class="file-content-size">{formatFileSize(selectedFile.size)}</span>
</div>
<div class="file-content-body">
<pre class="file-content-code"><code>{fileContent}</code></pre>
</div>
{:else}
<div class="file-content-empty">
<p>Select a file to view its contents</p>
</div>
{/if}
</div>
</div>
<style>
.file-explorer {
display: flex;
gap: 1rem;
height: 600px;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
background: var(--fog-bg, #ffffff);
}
:global(.dark) .file-explorer {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-bg, #111827);
}
.file-tree-panel {
flex: 0 0 300px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--fog-border, #e5e7eb);
overflow: hidden;
}
:global(.dark) .file-tree-panel {
border-right-color: var(--fog-dark-border, #374151);
}
.file-tree-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .file-tree-header {
border-bottom-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.file-tree-title {
flex: 1;
font-size: 1rem;
font-weight: 600;
margin: 0;
color: var(--fog-text, #1f2937);
}
:global(.dark) .file-tree-title {
color: var(--fog-dark-text, #f9fafb);
}
.expand-all-btn,
.collapse-all-btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-bg, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: background-color 0.2s;
}
:global(.dark) .expand-all-btn,
:global(.dark) .collapse-all-btn {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-bg, #111827);
color: var(--fog-dark-text, #f9fafb);
}
.expand-all-btn:hover,
.collapse-all-btn:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .expand-all-btn:hover,
:global(.dark) .collapse-all-btn:hover {
background: var(--fog-dark-highlight, #475569);
}
.file-tree-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.tree-item {
display: flex;
align-items: center;
font-size: 0.875rem;
}
.tree-folder,
.tree-file {
margin: 0.125rem 0;
}
.tree-nested {
margin-left: 1rem;
}
.tree-folder-btn,
.tree-file-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0.5rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
border-radius: 0.25rem;
transition: background-color 0.2s;
color: var(--fog-text, #1f2937);
}
:global(.dark) .tree-folder-btn,
:global(.dark) .tree-file-btn {
color: var(--fog-dark-text, #f9fafb);
}
.tree-folder-btn:hover,
.tree-file-btn:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .tree-folder-btn:hover,
:global(.dark) .tree-file-btn:hover {
background: var(--fog-dark-highlight, #374151);
}
.tree-file.selected .tree-file-btn {
background: var(--fog-accent, #64748b);
color: var(--fog-bg, #ffffff);
}
:global(.dark) .tree-file.selected .tree-file-btn {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-bg, #111827);
}
.tree-icon {
font-size: 1rem;
flex-shrink: 0;
}
.tree-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-size {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
margin-left: 0.5rem;
}
:global(.dark) .tree-size {
color: var(--fog-dark-text-light, #9ca3af);
}
.tree-children {
margin-left: 1rem;
border-left: 1px solid var(--fog-border, #e5e7eb);
padding-left: 0.5rem;
}
:global(.dark) .tree-children {
border-left-color: var(--fog-dark-border, #374151);
}
.tree-note {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
font-style: italic;
}
:global(.dark) .tree-note {
color: var(--fog-dark-text-light, #9ca3af);
}
.file-content-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.file-content-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .file-content-header {
border-bottom-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-highlight, #374151);
}
.file-content-title {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: var(--fog-text, #1f2937);
font-family: monospace;
}
:global(.dark) .file-content-title {
color: var(--fog-dark-text, #f9fafb);
}
.file-content-size {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .file-content-size {
color: var(--fog-dark-text-light, #9ca3af);
}
.file-content-body {
flex: 1;
overflow: auto;
padding: 1rem;
}
.file-content-code {
margin: 0;
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
line-height: 1.5;
color: var(--fog-text, #1f2937);
white-space: pre-wrap;
word-wrap: break-word;
}
:global(.dark) .file-content-code {
color: var(--fog-dark-text, #f9fafb);
}
.file-content-loading,
.file-content-error,
.file-content-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .file-content-loading,
:global(.dark) .file-content-error,
:global(.dark) .file-content-empty {
color: var(--fog-dark-text-light, #9ca3af);
}
.file-content-error {
color: var(--fog-error, #dc2626);
}
:global(.dark) .file-content-error {
color: var(--fog-dark-error, #ef4444);
}
@media (max-width: 768px) {
.file-explorer {
flex-direction: column;
height: auto;
max-height: 800px;
}
.file-tree-panel {
flex: 0 0 300px;
border-right: none;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .file-tree-panel {
border-bottom-color: var(--fog-dark-border, #374151);
}
}
</style>

50
src/lib/components/content/MarkdownRenderer.svelte

@ -14,6 +14,7 @@ @@ -14,6 +14,7 @@
// Lazy load EmbeddedEvent component (heavy component) - will be loaded on demand
let EmbeddedEventComponent: any = null;
let embeddedEventLoading = $state(false);
let mountingEmbeddedEvents = $state(false); // Guard for mounting, separate from loading component
interface Props {
content: string;
@ -391,7 +392,8 @@ @@ -391,7 +392,8 @@
for (let i = eventLinks.length - 1; i >= 0; i--) {
const link = eventLinks[i];
const eventId = getEventIdFromNIP21(link.parsed);
if (eventId) {
// Validate event ID before creating placeholder to prevent invalid fetches
if (eventId && isValidNostrId(eventId)) {
// Escape event ID to prevent XSS
const escapedEventId = escapeHtml(eventId);
// Create a div element for embedded event cards (block-level)
@ -755,13 +757,31 @@ @@ -755,13 +757,31 @@
// Mount EmbeddedEvent components after rendering (lazy loaded)
async function mountEmbeddedEvents() {
if (!containerRef) return;
if (!containerRef || mountingEmbeddedEvents) return;
// Find all event placeholders and mount EmbeddedEvent components
const placeholders = containerRef.querySelectorAll('[data-nostr-event]:not([data-mounted])');
if (placeholders.length > 0) {
console.debug(`Mounting ${placeholders.length} EmbeddedEvent components`);
mountingEmbeddedEvents = true;
try {
// Validate event IDs before mounting to prevent invalid fetches
const validPlaceholders: Element[] = [];
placeholders.forEach((placeholder) => {
const eventId = placeholder.getAttribute('data-event-id');
// Use strict validation to prevent invalid fetches
if (eventId && isValidNostrId(eventId)) {
validPlaceholders.push(placeholder);
} else if (eventId) {
// Invalid event ID - mark as mounted to prevent retries
placeholder.setAttribute('data-mounted', 'true');
placeholder.textContent = ''; // Don't show invalid IDs
console.debug('Skipping invalid event ID in MarkdownRenderer:', eventId);
}
});
if (validPlaceholders.length > 0) {
console.debug(`Mounting ${validPlaceholders.length} EmbeddedEvent components`);
// Load component only when we have placeholders to mount
const Component = await loadEmbeddedEventComponent();
@ -770,7 +790,7 @@ @@ -770,7 +790,7 @@
return;
}
placeholders.forEach((placeholder) => {
validPlaceholders.forEach((placeholder) => {
const eventId = placeholder.getAttribute('data-event-id');
if (eventId) {
placeholder.setAttribute('data-mounted', 'true');
@ -794,6 +814,10 @@ @@ -794,6 +814,10 @@
}
});
}
} finally {
mountingEmbeddedEvents = false;
}
}
}
@ -814,12 +838,22 @@ @@ -814,12 +838,22 @@
});
// Also use MutationObserver to catch any placeholders added later
// Debounce to prevent excessive re-mounts
let mutationDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (!containerRef) return;
const observer = new MutationObserver(() => {
// Debounce mutations to prevent excessive re-mounts
if (mutationDebounceTimeout) {
clearTimeout(mutationDebounceTimeout);
}
mutationDebounceTimeout = setTimeout(() => {
mountProfileBadges();
mountEmbeddedEvents();
mutationDebounceTimeout = null;
}, 300); // 300ms debounce
});
observer.observe(containerRef, {
@ -827,7 +861,13 @@ @@ -827,7 +861,13 @@
subtree: true
});
return () => observer.disconnect();
return () => {
observer.disconnect();
if (mutationDebounceTimeout) {
clearTimeout(mutationDebounceTimeout);
mutationDebounceTimeout = null;
}
};
});
</script>

727
src/lib/components/preferences/UserPreferences.svelte

@ -1,7 +1,11 @@ @@ -1,7 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { shouldIncludeClientTag, setIncludeClientTag } from '../../services/client-tag-preference.js';
interface Props {
open?: boolean;
}
let { open = $bindable(false) }: Props = $props();
type TextSize = 'small' | 'medium' | 'large';
type LineSpacing = 'tight' | 'normal' | 'loose';
@ -11,49 +15,40 @@ @@ -11,49 +15,40 @@
let lineSpacing = $state<LineSpacing>('normal');
let contentWidth = $state<ContentWidth>('medium');
let isDark = $state(false);
let expiringEvents = $state(false);
let mediaUploadServer = $state('https://nostr.build');
let includeClientTag = $state(true);
let showPreferences = $state(false);
onMount(() => {
loadPreferences();
applyPreferences();
loadTheme();
});
// Load preferences from localStorage
const storedTextSize = localStorage.getItem('textSize') as TextSize | null;
const storedLineSpacing = localStorage.getItem('lineSpacing') as LineSpacing | null;
const storedContentWidth = localStorage.getItem('contentWidth') as ContentWidth | null;
const storedTheme = localStorage.getItem('theme');
function loadPreferences() {
if (typeof window === 'undefined') return;
textSize = storedTextSize || 'medium';
lineSpacing = storedLineSpacing || 'normal';
contentWidth = storedContentWidth || 'medium';
const savedTextSize = localStorage.getItem('aitherboard_textSize') as TextSize | null;
const savedLineSpacing = localStorage.getItem('aitherboard_lineSpacing') as LineSpacing | null;
const savedContentWidth = localStorage.getItem('aitherboard_contentWidth') as ContentWidth | null;
const savedExpiringEvents = localStorage.getItem('aitherboard_expiringEvents');
const savedMediaUploadServer = localStorage.getItem('aitherboard_mediaUploadServer');
// Check theme preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
if (savedTextSize) textSize = savedTextSize;
if (savedLineSpacing) lineSpacing = savedLineSpacing;
if (savedContentWidth) contentWidth = savedContentWidth;
if (savedExpiringEvents === 'true') expiringEvents = true;
if (savedMediaUploadServer) mediaUploadServer = savedMediaUploadServer;
includeClientTag = shouldIncludeClientTag();
}
// Apply preferences
applyPreferences();
});
function loadTheme() {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = stored === 'dark' || (!stored && prefersDark);
updateTheme();
}
function applyPreferences() {
// Apply text size
document.documentElement.setAttribute('data-text-size', textSize);
localStorage.setItem('textSize', textSize);
function toggleTheme() {
isDark = !isDark;
updateTheme();
}
// Apply line spacing
document.documentElement.setAttribute('data-line-spacing', lineSpacing);
localStorage.setItem('lineSpacing', lineSpacing);
function updateTheme() {
if (typeof document === 'undefined') return;
// Apply content width
document.documentElement.setAttribute('data-content-width', contentWidth);
localStorage.setItem('contentWidth', contentWidth);
// Apply theme
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
@ -63,361 +58,258 @@ @@ -63,361 +58,258 @@
}
}
function savePreferences() {
if (typeof window === 'undefined') return;
function handleTextSizeChange(size: TextSize) {
textSize = size;
applyPreferences();
}
function handleLineSpacingChange(spacing: LineSpacing) {
lineSpacing = spacing;
applyPreferences();
}
localStorage.setItem('aitherboard_textSize', textSize);
localStorage.setItem('aitherboard_lineSpacing', lineSpacing);
localStorage.setItem('aitherboard_contentWidth', contentWidth);
localStorage.setItem('aitherboard_expiringEvents', expiringEvents ? 'true' : 'false');
localStorage.setItem('aitherboard_mediaUploadServer', mediaUploadServer);
setIncludeClientTag(includeClientTag);
function handleContentWidthChange(width: ContentWidth) {
contentWidth = width;
applyPreferences();
}
function handleThemeToggle() {
isDark = !isDark;
applyPreferences();
}
function applyPreferences() {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// Text size
const textSizes = {
small: '14px',
medium: '16px',
large: '18px'
};
root.style.setProperty('--text-size', textSizes[textSize]);
root.setAttribute('data-text-size', textSize);
// Line spacing
const lineSpacings = {
tight: '1.4',
normal: '1.6',
loose: '1.8'
};
root.style.setProperty('--line-height', lineSpacings[lineSpacing]);
root.setAttribute('data-line-spacing', lineSpacing);
// Content width
const contentWidths = {
narrow: '600px',
medium: '800px',
wide: '1200px'
};
root.style.setProperty('--content-width', contentWidths[contentWidth]);
root.setAttribute('data-content-width', contentWidth);
}
function handleMediaUploadServerChange(e: Event) {
const target = e.target as HTMLInputElement;
mediaUploadServer = target.value;
savePreferences();
}
function handleManageCache() {
showPreferences = false;
goto('/cache');
}
$effect(() => {
savePreferences();
});
function close() {
open = false;
}
$effect(() => {
if (showPreferences) {
// Prevent body scroll when modal is open
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
function handleOverlayClick(e: MouseEvent) {
// Only close if clicking the overlay itself, not the modal content
if (e.target === e.currentTarget) {
close();
}
}
const handleEscape = (e: KeyboardEvent) => {
function handleOverlayKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
showPreferences = false;
close();
}
};
document.addEventListener('keydown', handleEscape);
return () => {
document.body.style.overflow = originalOverflow;
document.removeEventListener('keydown', handleEscape);
};
}
});
</script>
<!-- Settings button to open modal -->
<button
onclick={() => (showPreferences = !showPreferences)}
class="preferences-button"
title="User Preferences"
aria-label="User Preferences"
onclick={() => open = true}
class="px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors"
aria-label="Open preferences"
title="Preferences"
>
<span class="emoji"></span>
<span class="emoji emoji-grayscale"></span>
</button>
{#if showPreferences}
<!-- Backdrop -->
<!-- Modal -->
{#if open}
<div
class="preferences-backdrop"
onclick={() => (showPreferences = false)}
role="presentation"
></div>
<!-- Panel -->
<div class="preferences-panel" role="dialog" aria-modal="true" aria-labelledby="preferences-title">
<div class="preferences-header">
class="modal-overlay"
onclick={handleOverlayClick}
onkeydown={handleOverlayKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="preferences-title"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2 id="preferences-title">Preferences</h2>
<button onclick={() => (showPreferences = false)} class="close-button" aria-label="Close preferences">×</button>
<button onclick={close} class="close-button" aria-label="Close preferences">×</button>
</div>
<div class="preferences-body">
<div class="modal-body">
<!-- Theme Toggle -->
<fieldset class="preference-group">
<legend class="preference-label">Theme</legend>
<div class="preference-options" role="group" aria-label="Theme">
<div class="preference-section">
<div class="preference-label">
<span>Theme</span>
</div>
<div class="preference-controls">
<button
onclick={toggleTheme}
class="preference-option theme-toggle"
aria-pressed={isDark}
onclick={handleThemeToggle}
class="toggle-button"
class:active={isDark}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
<span class="emoji emoji-grayscale">{#if isDark}{:else}🌙{/if}</span>
<span>{isDark ? 'Light' : 'Dark'} Mode</span>
<span class="emoji">{isDark ? '☀' : '🌙'}</span>
<span>{isDark ? 'Light' : 'Dark'}</span>
</button>
</div>
</fieldset>
</div>
<!-- Text Size -->
<fieldset class="preference-group">
<legend class="preference-label">Text Size</legend>
<div class="preference-options" role="group" aria-label="Text Size">
<div class="preference-section">
<div class="preference-label">
<span>Text Size</span>
</div>
<div class="preference-controls">
<button
onclick={() => (textSize = 'small')}
class="preference-option {textSize === 'small' ? 'active' : ''}"
aria-pressed={textSize === 'small'}
onclick={() => handleTextSizeChange('small')}
class="option-button"
class:active={textSize === 'small'}
aria-label="Small text size"
>
Small
</button>
<button
onclick={() => (textSize = 'medium')}
class="preference-option {textSize === 'medium' ? 'active' : ''}"
aria-pressed={textSize === 'medium'}
onclick={() => handleTextSizeChange('medium')}
class="option-button"
class:active={textSize === 'medium'}
aria-label="Medium text size"
>
Medium
</button>
<button
onclick={() => (textSize = 'large')}
class="preference-option {textSize === 'large' ? 'active' : ''}"
aria-pressed={textSize === 'large'}
onclick={() => handleTextSizeChange('large')}
class="option-button"
class:active={textSize === 'large'}
aria-label="Large text size"
>
Large
</button>
</div>
</fieldset>
</div>
<!-- Line Spacing -->
<fieldset class="preference-group">
<legend class="preference-label">Line Spacing</legend>
<div class="preference-options" role="group" aria-label="Line Spacing">
<div class="preference-section">
<div class="preference-label">
<span>Line Spacing</span>
</div>
<div class="preference-controls">
<button
onclick={() => (lineSpacing = 'tight')}
class="preference-option {lineSpacing === 'tight' ? 'active' : ''}"
aria-pressed={lineSpacing === 'tight'}
onclick={() => handleLineSpacingChange('tight')}
class="option-button"
class:active={lineSpacing === 'tight'}
aria-label="Tight line spacing"
>
Tight
</button>
<button
onclick={() => (lineSpacing = 'normal')}
class="preference-option {lineSpacing === 'normal' ? 'active' : ''}"
aria-pressed={lineSpacing === 'normal'}
onclick={() => handleLineSpacingChange('normal')}
class="option-button"
class:active={lineSpacing === 'normal'}
aria-label="Normal line spacing"
>
Normal
</button>
<button
onclick={() => (lineSpacing = 'loose')}
class="preference-option {lineSpacing === 'loose' ? 'active' : ''}"
aria-pressed={lineSpacing === 'loose'}
onclick={() => handleLineSpacingChange('loose')}
class="option-button"
class:active={lineSpacing === 'loose'}
aria-label="Loose line spacing"
>
Loose
</button>
</div>
</fieldset>
<!-- Expiring Events -->
<fieldset class="preference-group">
<legend class="preference-label">Event Expiration</legend>
<label class="checkbox-label">
<input
type="checkbox"
bind:checked={expiringEvents}
class="checkbox-input"
/>
<span>Create expiring events (6 months: kinds 7, 1, 30315)</span>
</label>
<p class="preference-help">Adds a 6-month expiration timestamp to created events of the specified kinds.</p>
</fieldset>
<!-- Include Client Tag -->
<fieldset class="preference-group">
<legend class="preference-label">Client Tag</legend>
<label class="checkbox-label">
<input
type="checkbox"
bind:checked={includeClientTag}
class="checkbox-input"
/>
<span>Include client tag</span>
</label>
<p class="preference-help">Include NIP-89 client tag in published events to identify the client used.</p>
</fieldset>
<!-- Media Upload Server -->
<fieldset class="preference-group">
<legend class="preference-label">Media Upload Server</legend>
<input
type="text"
bind:value={mediaUploadServer}
oninput={handleMediaUploadServerChange}
placeholder="https://nostr.build"
class="text-input"
/>
<p class="preference-help">Preferred server for uploading media files (NIP-96).</p>
</fieldset>
<!-- Manage Cache Button -->
<div class="preference-group">
</div>
<!-- Content Width -->
<div class="preference-section">
<div class="preference-label">
<span>Content Width</span>
</div>
<div class="preference-controls">
<button
onclick={() => handleContentWidthChange('narrow')}
class="option-button"
class:active={contentWidth === 'narrow'}
aria-label="Narrow content width"
>
Narrow
</button>
<button
onclick={() => handleContentWidthChange('medium')}
class="option-button"
class:active={contentWidth === 'medium'}
aria-label="Medium content width"
>
Medium
</button>
<button
onclick={handleManageCache}
class="manage-cache-button"
onclick={() => handleContentWidthChange('wide')}
class="option-button"
class:active={contentWidth === 'wide'}
aria-label="Wide content width"
>
Manage Cache
Wide
</button>
</div>
</div>
</div>
<!-- About Section -->
<div class="preferences-footer">
<div class="about-section">
<h3 class="about-title">About</h3>
<p class="about-text">
aitherboard is a decentralized discussion board built on Nostr.
Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="about-link">Silberengel</a>.
</p>
<div class="modal-footer">
<button onclick={close}>Close</button>
</div>
</div>
</div>
{/if}
<style>
.preferences-button {
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
border: 1px solid var(--fog-border, #e5e7eb);
background: var(--fog-post, #ffffff);
color: var(--fog-text, #475569);
cursor: pointer;
transition: all 0.2s;
}
:global(.dark) .preferences-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #cbd5e1);
}
.preferences-button:hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .preferences-button:hover {
background: var(--fog-dark-highlight, #374151);
.emoji-grayscale {
filter: grayscale(100%);
}
.emoji {
font-size: 1rem;
line-height: 1;
opacity: 0.7;
filter: grayscale(100%);
button:hover .emoji-grayscale {
filter: grayscale(80%);
}
.preferences-backdrop {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
background: rgba(0, 0, 0, 0.5) !important;
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 99999 !important;
animation: fadeIn 0.2s ease-out;
pointer-events: auto !important;
isolation: isolate;
}
.preferences-panel {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 320px;
height: 100vh;
background: var(--fog-post, #ffffff);
border-right: 1px solid var(--fog-border, #e5e7eb);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
z-index: 100000 !important;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
overflow: hidden;
isolation: isolate;
align-items: center;
justify-content: center;
z-index: 1000;
}
:global(.dark) .preferences-panel {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #374151);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.3);
.modal-content {
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
:global(.dark) .modal-content {
background: #1e293b;
border-color: #475569;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.preferences-header {
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
border-bottom: 1px solid #cbd5e1;
}
:global(.dark) .preferences-header {
border-bottom-color: var(--fog-dark-border, #374151);
:global(.dark) .modal-header {
border-bottom-color: #475569;
}
.preferences-header h2 {
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #475569);
color: #1e293b;
}
:global(.dark) .preferences-header h2 {
color: var(--fog-dark-text, #cbd5e1);
:global(.dark) .modal-header h2 {
color: #f1f5f9;
}
.close-button {
@ -431,218 +323,153 @@ @@ -431,218 +323,153 @@
display: flex;
align-items: center;
justify-content: center;
color: var(--fog-text, #475569);
border-radius: 0.25rem;
transition: background 0.2s;
color: #64748b;
transition: color 0.2s;
}
:global(.dark) .close-button {
color: var(--fog-dark-text, #cbd5e1);
.close-button:hover {
color: #1e293b;
}
.close-button:hover {
background: var(--fog-highlight, #f3f4f6);
:global(.dark) .close-button {
color: #94a3b8;
}
:global(.dark) .close-button:hover {
background: var(--fog-dark-highlight, #374151);
color: #f1f5f9;
}
.preferences-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
.modal-body {
padding: 1.5rem;
}
.preference-group {
.preference-section {
margin-bottom: 1.5rem;
border: none;
padding: 0;
}
.preference-section:last-child {
margin-bottom: 0;
}
.preference-label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--fog-text, #475569);
padding: 0;
font-size: 0.875rem;
font-weight: 500;
color: #1e293b;
}
:global(.dark) .preference-label {
color: var(--fog-dark-text, #cbd5e1);
}
.preference-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.preference-option {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #475569);
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
color: #f1f5f9;
}
.preference-option.theme-toggle {
.preference-controls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
:global(.dark) .preference-option {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #cbd5e1);
}
.preference-option:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .preference-option:hover {
background: var(--fog-dark-highlight, #374151);
}
.preference-option.active {
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .preference-option.active {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #f1f5f9);
border-color: var(--fog-dark-accent, #94a3b8);
}
.checkbox-label {
.toggle-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #1e293b;
cursor: pointer;
color: var(--fog-text, #475569);
transition: all 0.2s;
font-size: 0.875rem;
}
:global(.dark) .checkbox-label {
color: var(--fog-dark-text, #cbd5e1);
}
.checkbox-input {
width: 1rem;
height: 1rem;
cursor: pointer;
.toggle-button:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
.preference-help {
margin: 0.5rem 0 0 0;
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
font-style: italic;
.toggle-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
:global(.dark) .preference-help {
color: var(--fog-dark-text-light, #9ca3af);
:global(.dark) .toggle-button {
background: #334155;
border-color: #475569;
color: #f1f5f9;
}
.text-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #475569);
font-size: 0.875rem;
:global(.dark) .toggle-button:hover {
background: #475569;
border-color: #64748b;
}
:global(.dark) .text-input {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #cbd5e1);
:global(.dark) .toggle-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.manage-cache-button {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
.option-button {
padding: 0.5rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #1e293b;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
:global(.dark) .manage-cache-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-accent, #94a3b8);
.option-button:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
.manage-cache-button:hover {
opacity: 0.9;
.option-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.preferences-footer {
padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
:global(.dark) .option-button {
background: #334155;
border-color: #475569;
color: #f1f5f9;
}
:global(.dark) .preferences-footer {
border-top-color: var(--fog-dark-border, #374151);
:global(.dark) .option-button:hover {
background: #475569;
border-color: #64748b;
}
.about-section {
font-size: 0.75rem;
:global(.dark) .option-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.about-title {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--fog-text, #475569);
}
:global(.dark) .about-title {
color: var(--fog-dark-text, #cbd5e1);
}
.about-text {
margin: 0;
color: var(--fog-text-light, #6b7280);
line-height: 1.5;
}
:global(.dark) .about-text {
color: var(--fog-dark-text-light, #9ca3af);
}
.about-link {
color: var(--fog-accent, #64748b);
text-decoration: underline;
.modal-footer {
padding: 1rem;
border-top: 1px solid #cbd5e1;
text-align: right;
}
:global(.dark) .about-link {
color: var(--fog-dark-accent, #94a3b8);
:global(.dark) .modal-footer {
border-top-color: #475569;
}
.about-link:hover {
opacity: 0.8;
.modal-footer button {
padding: 0.5rem 1rem;
background: #94a3b8;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
@media (max-width: 768px) {
.preferences-panel {
width: 100%;
max-width: 320px;
}
.modal-footer button:hover {
background: #64748b;
}
</style>

15
src/lib/components/write/FindEventForm.svelte

@ -5,6 +5,13 @@ @@ -5,6 +5,13 @@
import { getEvent } from '../../services/cache/event-cache.js';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
interface Props {
initialEventId?: string | null;
}
let { initialEventId }: Props = $props();
let eventIdInput = $state('');
let searching = $state(false);
@ -12,6 +19,14 @@ @@ -12,6 +19,14 @@
let error = $state<string | null>(null);
let showEdit = $state(false);
// Auto-load event if initialEventId is provided
$effect(() => {
if (initialEventId && !foundEvent && !searching) {
eventIdInput = initialEventId;
findEvent();
}
});
async function findEvent() {
if (!eventIdInput.trim()) return;

51
src/lib/modules/discussions/DiscussionList.svelte

@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { KIND } from '../../types/kind-lookup.js';
import { getRecentCachedEvents } from '../../services/cache/event-cache.js';
// Data maps - threads and stats for sorting only (DiscussionCard loads its own stats for display)
let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread
@ -47,6 +48,8 @@ @@ -47,6 +48,8 @@
prevSortBy = sortBy;
prevShowOlder = showOlder;
prevSelectedTopic = selectedTopic;
// Load cached threads first, then refresh
loadCachedThreads();
loadAllData();
});
@ -82,9 +85,49 @@ @@ -82,9 +85,49 @@
}
});
async function loadCachedThreads() {
if (!isMounted || isLoading) return;
try {
const config = nostrClient.getConfig();
const since = showOlder
? undefined
: Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
// Load cached threads (within 15 minutes)
const cachedThreads = await getRecentCachedEvents([KIND.DISCUSSION_THREAD], 15 * 60 * 1000, 50);
if (cachedThreads.length > 0 && isMounted) {
// Filter by time if needed
let filtered = cachedThreads;
if (since) {
filtered = cachedThreads.filter(e => e.created_at >= since);
}
// Build threads map from cached results
const newThreadsMap = new Map<string, NostrEvent>();
for (const event of filtered) {
newThreadsMap.set(event.id, event);
}
if (newThreadsMap.size > 0) {
threadsMap = newThreadsMap;
loading = false; // Show cached data immediately
}
}
} catch (error) {
console.error('Error loading cached threads:', error);
}
}
async function loadAllData() {
if (!isMounted || isLoading) return; // Don't load if unmounted or already loading
// Only show loading spinner if we don't have cached threads
const hasCachedThreads = threadsMap.size > 0;
if (!hasCachedThreads) {
loading = true;
}
isLoading = true;
voteCountsReady = false; // Reset vote counts ready state
try {
@ -136,11 +179,15 @@ @@ -136,11 +179,15 @@
if (!isMounted) return; // Don't process if unmounted
// Build threads map from results
const newThreadsMap = new Map<string, NostrEvent>();
// Merge with existing cached threads
const newThreadsMap = new Map(threadsMap);
for (const event of relayThreads) {
// Update or add new threads
const existing = newThreadsMap.get(event.id);
if (!existing || event.created_at > existing.created_at) {
newThreadsMap.set(event.id, event);
}
}
threadsMap = newThreadsMap;
loading = false; // Show data immediately

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

@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js';
import { getRecentFeedEvents } from '../../services/cache/event-cache.js';
interface Props {
singleRelay?: string; // If provided, use only this relay and disable cache
@ -32,6 +33,12 @@ @@ -32,6 +33,12 @@
let observer: IntersectionObserver | null = null;
let subscriptionId: string | null = $state(null);
let isMounted = $state(true);
let loadingParents = $state(false); // Guard to prevent concurrent parent/quoted event loads
let loadingFeed = $state(false); // Guard to prevent concurrent feed loads
let pendingSubscriptionEvents = $state<NostrEvent[]>([]); // Batch subscription events
let subscriptionBatchTimeout: ReturnType<typeof setTimeout> | null = null;
let loadMoreTimeout: ReturnType<typeof setTimeout> | null = null; // Debounce loadMore calls
let lastLoadMoreTime = $state<number>(0); // Track last loadMore call time
function openDrawer(event: NostrEvent) {
drawerEvent = event;
@ -48,6 +55,12 @@ @@ -48,6 +55,12 @@
(async () => {
await nostrClient.initialize();
if (!isMounted) return;
// Load cached feed events immediately (15 minute cache)
await loadCachedFeed();
if (!isMounted) return;
// Then fetch fresh data in the background
await loadFeed();
if (!isMounted) return;
setupSubscription();
@ -64,6 +77,15 @@ @@ -64,6 +77,15 @@
observer.disconnect();
observer = null;
}
if (subscriptionBatchTimeout) {
clearTimeout(subscriptionBatchTimeout);
subscriptionBatchTimeout = null;
}
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
loadMoreTimeout = null;
}
pendingSubscriptionEvents = [];
};
});
@ -82,6 +104,59 @@ @@ -82,6 +104,59 @@
};
});
function processSubscriptionBatch() {
if (!isMounted || pendingSubscriptionEvents.length === 0 || loadingFeed) {
pendingSubscriptionEvents = [];
subscriptionBatchTimeout = null;
return;
}
// Get current events snapshot to avoid race conditions
const currentEvents = events;
const currentEventIds = new Set(currentEvents.map(e => e.id));
// Filter out discussion threads and deduplicate within pending events
const seenInPending = new Set<string>();
const newEvents: NostrEvent[] = [];
for (const event of pendingSubscriptionEvents) {
// Skip discussion threads
if (event.kind === KIND.DISCUSSION_THREAD) continue;
// Skip if already in current events or already seen in this batch
if (!currentEventIds.has(event.id) && !seenInPending.has(event.id)) {
newEvents.push(event);
seenInPending.add(event.id);
}
}
if (newEvents.length === 0) {
pendingSubscriptionEvents = [];
subscriptionBatchTimeout = null;
return;
}
// Create a completely new events array with all unique events
// Use a Map to ensure uniqueness by event ID (last one wins if duplicates somehow exist)
const eventsMap = new Map<string, NostrEvent>();
// Add all current events first
for (const event of currentEvents) {
eventsMap.set(event.id, event);
}
// Add new events (will overwrite if somehow duplicate, but shouldn't happen)
for (const event of newEvents) {
eventsMap.set(event.id, event);
}
// Convert to array and sort
events = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at);
pendingSubscriptionEvents = [];
subscriptionBatchTimeout = null;
}
function setupSubscription() {
if (subscriptionId || singleRelay) return;
@ -93,11 +168,20 @@ @@ -93,11 +168,20 @@
filters,
relays,
(event: NostrEvent) => {
if (event.kind === KIND.DISCUSSION_THREAD) return;
const existingIds = new Set(events.map(e => e.id));
if (!existingIds.has(event.id)) {
events = [event, ...events].sort((a, b) => b.created_at - a.created_at);
if (!isMounted || event.kind === KIND.DISCUSSION_THREAD || loadingFeed) return;
// Add to pending batch
pendingSubscriptionEvents.push(event);
// Clear existing timeout
if (subscriptionBatchTimeout) {
clearTimeout(subscriptionBatchTimeout);
}
// Process batch after a short delay (debounce rapid updates)
subscriptionBatchTimeout = setTimeout(() => {
processSubscriptionBatch();
}, 100); // 100ms debounce
},
() => {}
);
@ -108,16 +192,77 @@ @@ -108,16 +192,77 @@
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
// Debounce rapid scroll events - clear any pending loadMore call
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
}
// Wait 300ms before actually loading more to batch rapid scroll events
loadMoreTimeout = setTimeout(() => {
if (hasMore && !loadingMore && isMounted) {
loadMore();
}
}, { threshold: 0.1 });
loadMoreTimeout = null;
}, 300);
}
}, { threshold: 0.1, rootMargin: '100px' }); // Add rootMargin to trigger slightly earlier
observer.observe(sentinelElement);
}
async function loadCachedFeed() {
if (!isMounted || singleRelay) return;
try {
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD);
// Load events cached within the last 15 minutes
const cachedEvents = await getRecentFeedEvents(feedKinds, 15 * 60 * 1000, 50);
if (cachedEvents.length > 0 && isMounted) {
// Filter to only showInFeed kinds and exclude kind 11
const filteredEvents = cachedEvents.filter((e: NostrEvent) =>
e.kind !== KIND.DISCUSSION_THREAD &&
getKindInfo(e.kind).showInFeed === true
);
// Deduplicate
const uniqueMap = new Map<string, NostrEvent>();
for (const event of filteredEvents) {
if (!uniqueMap.has(event.id)) {
uniqueMap.set(event.id, event);
}
}
const sortedEvents = Array.from(uniqueMap.values()).sort((a, b) => b.created_at - a.created_at);
if (sortedEvents.length > 0) {
events = sortedEvents;
oldestTimestamp = Math.min(...events.map(e => e.created_at));
// Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches
if (!loadingParents) {
loadParentAndQuotedEvents(events).catch(err => {
console.error('Error loading parent/quoted events from cache:', err);
});
}
// Don't set loading to false here - let loadFeed() handle that
}
}
} catch (error) {
console.error('Error loading cached feed:', error);
// Don't set loading to false - let loadFeed() handle that
}
}
async function loadFeed() {
if (!isMounted) return;
if (!isMounted || loadingFeed) return; // Prevent concurrent loads
loadingFeed = true;
// Only show loading spinner if we don't have cached events
const hasCachedEvents = events.length > 0;
if (!hasCachedEvents) {
loading = true;
}
relayError = null;
try {
@ -163,8 +308,16 @@ @@ -163,8 +308,16 @@
getKindInfo(e.kind).showInFeed === true
);
// Deduplicate
// Deduplicate and merge with existing events
const existingIds = new Set(events.map(e => e.id));
const uniqueMap = new Map<string, NostrEvent>();
// Add existing events first
for (const event of events) {
uniqueMap.set(event.id, event);
}
// Add new events
for (const event of filteredEvents) {
if (!uniqueMap.has(event.id)) {
uniqueMap.set(event.id, event);
@ -175,7 +328,13 @@ @@ -175,7 +328,13 @@
if (events.length > 0) {
oldestTimestamp = Math.min(...events.map(e => e.created_at));
loadParentAndQuotedEvents(events);
// Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches
if (!loadingParents) {
loadParentAndQuotedEvents(events).catch(err => {
console.error('Error loading parent/quoted events:', err);
});
}
}
hasMore = fetchedEvents.length >= 20;
@ -183,12 +342,23 @@ @@ -183,12 +342,23 @@
console.error('Error loading feed:', error);
} finally {
loading = false;
loadingFeed = false;
}
}
async function loadMore() {
if (!isMounted || loadingMore || !hasMore) return;
// Double-check guards to prevent concurrent calls
if (!isMounted || loadingMore || !hasMore || loadingFeed) {
return;
}
// Prevent rapid successive calls - minimum 1 second between loads
const now = Date.now();
if (now - lastLoadMoreTime < 1000) {
return;
}
lastLoadMoreTime = now;
loadingMore = true;
try {
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
@ -225,14 +395,39 @@ @@ -225,14 +395,39 @@
getKindInfo(e.kind).showInFeed === true
);
// Use Map-based deduplication to ensure no duplicates
const existingIds = new Set(events.map(e => e.id));
const newEvents = filteredEvents.filter(e => !existingIds.has(e.id));
const eventsMap = new Map<string, NostrEvent>();
// Add all existing events first
for (const event of events) {
eventsMap.set(event.id, event);
}
// Track which events are actually new
const newEvents: NostrEvent[] = [];
// Add new events (will only add if not already present)
for (const event of filteredEvents) {
if (!eventsMap.has(event.id)) {
eventsMap.set(event.id, event);
newEvents.push(event);
}
}
// Only update if we have new events
if (newEvents.length > 0) {
events = [...events, ...newEvents].sort((a, b) => b.created_at - a.created_at);
// Convert to array and sort
events = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at);
const allTimestamps = events.map(e => e.created_at);
oldestTimestamp = Math.min(...allTimestamps);
loadParentAndQuotedEvents(newEvents);
// Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches
if (!loadingParents) {
loadParentAndQuotedEvents(newEvents).catch(err => {
console.error('Error loading parent/quoted events for new events:', err);
});
}
}
hasMore = fetchedEvents.length >= 20;
@ -244,8 +439,9 @@ @@ -244,8 +439,9 @@
}
async function loadParentAndQuotedEvents(postsToLoad: NostrEvent[]) {
if (!isMounted || postsToLoad.length === 0) return;
if (!isMounted || postsToLoad.length === 0 || loadingParents) return;
loadingParents = true;
try {
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
const parentEventIds = new Set<string>();
@ -316,6 +512,8 @@ @@ -316,6 +512,8 @@
}
} catch (error) {
console.error('[FeedPage] Error loading parent/quoted events:', error);
} finally {
loadingParents = false;
}
}
</script>

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

@ -57,6 +57,11 @@ @@ -57,6 +57,11 @@
let bookmarked = $state(false);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Collapsed/expanded state for long content
let isExpanded = $state(false);
let needsCollapse = $state(false);
let cardContentRef = $state<HTMLElement | null>(null);
$effect(() => {
if (isLoggedIn) {
isBookmarked(post.id).then(b => {
@ -238,6 +243,51 @@ @@ -238,6 +243,51 @@
}
}
function toggleExpand(e: MouseEvent) {
e.stopPropagation(); // Prevent opening drawer when clicking show more/less
isExpanded = !isExpanded;
}
// Check if content needs collapsing after render
$effect(() => {
if (!cardContentRef) return;
// Use requestAnimationFrame to ensure DOM is ready
const checkHeight = () => {
if (!cardContentRef) return;
// Temporarily remove max-height to measure full height
const wasExpanded = cardContentRef.classList.contains('expanded');
cardContentRef.classList.remove('expanded');
const fullHeight = cardContentRef.scrollHeight;
const maxHeight = 500; // Match CSS max-height
// Restore expanded state if it was expanded
if (wasExpanded) {
cardContentRef.classList.add('expanded');
}
needsCollapse = fullHeight > maxHeight;
};
// Check immediately and after a short delay to account for async content loading
checkHeight();
const timeoutId = setTimeout(checkHeight, 500);
// Also check when content changes
const observer = new MutationObserver(checkHeight);
observer.observe(cardContentRef, {
childList: true,
subtree: true,
attributes: false
});
return () => {
clearTimeout(timeoutId);
observer.disconnect();
};
});
</script>
<article
@ -250,7 +300,11 @@ @@ -250,7 +300,11 @@
class:cursor-pointer={!!onOpenEvent}
{...(onOpenEvent ? { role: "button", tabindex: 0 } : {})}
>
<div class="card-content">
<div
class="card-content"
class:expanded={isExpanded}
bind:this={cardContentRef}
>
{#if isReply()}
<ReplyContext
parentEvent={parentEvent || undefined}
@ -328,6 +382,16 @@ @@ -328,6 +382,16 @@
</div>
</div>
{#if needsCollapse}
<button
class="show-more-button text-fog-accent dark:text-fog-dark-accent hover:underline"
onclick={toggleExpand}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpand(e as any); } }}
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
{/if}
<!-- Post actions: emoji button and menu -->
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
<FeedReactionButtons event={post} />
@ -426,6 +490,23 @@ @@ -426,6 +490,23 @@
background: transparent;
border: none;
cursor: pointer;
font-size: 0.875rem;
margin-top: 0.5rem;
color: var(--fog-accent, #64748b);
transition: color 0.2s;
}
.show-more-button:hover {
color: var(--fog-text, #1f2937);
text-decoration: underline;
}
:global(.dark) .show-more-button {
color: var(--fog-dark-accent, #94a3b8);
}
:global(.dark) .show-more-button:hover {
color: var(--fog-dark-text, #f9fafb);
}
.kind-badge {

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

@ -152,6 +152,53 @@ export async function deleteEvents(ids: string[]): Promise<void> { @@ -152,6 +152,53 @@ export async function deleteEvents(ids: string[]): Promise<void> {
await tx.done;
}
/**
* Get recent events from cache by kind(s) (within cache TTL)
* Returns events that were cached recently and match the specified kinds
*/
export async function getRecentCachedEvents(kinds: number[], maxAge: number = 15 * 60 * 1000, limit: number = 50): Promise<CachedEvent[]> {
try {
const db = await getDB();
const now = Date.now();
const cutoffTime = now - maxAge;
const results: CachedEvent[] = [];
const seen = new Set<string>();
// Get events for each kind
for (const kind of kinds) {
const tx = db.transaction('events', 'readonly');
const index = tx.store.index('kind');
const events = await index.getAll(kind);
await tx.done;
// Filter by cache age and deduplicate
for (const event of events) {
if (event.cached_at >= cutoffTime && !seen.has(event.id)) {
seen.add(event.id);
results.push(event);
}
}
}
// Sort by created_at (newest first) and limit
const sorted = results.sort((a, b) => b.created_at - a.created_at);
return sorted.slice(0, limit);
} catch (error) {
console.debug('Error getting recent cached events:', error);
return [];
}
}
/**
* Get recent feed events from cache (within cache TTL)
* Returns events that were cached recently and match feed kinds
* @deprecated Use getRecentCachedEvents instead
*/
export async function getRecentFeedEvents(kinds: number[], maxAge: number = 15 * 60 * 1000, limit: number = 50): Promise<CachedEvent[]> {
return getRecentCachedEvents(kinds, maxAge, limit);
}
/**
* Clear old events (older than specified timestamp)
*/

33
src/routes/find/+page.svelte

@ -3,10 +3,20 @@ @@ -3,10 +3,20 @@
import FindEventForm from '../../lib/components/write/FindEventForm.svelte';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let userInput = $state('');
let searching = $state(false);
let error = $state<string | null>(null);
let initialEventId = $state<string | null>(null);
// Check for event ID in URL query parameters
$effect(() => {
const eventId = $page.url.searchParams.get('event');
if (eventId) {
initialEventId = eventId;
}
});
/**
* Decode pubkey from various formats
@ -111,7 +121,7 @@ @@ -111,7 +121,7 @@
<div class="find-sections">
<!-- Find Event Section -->
<section class="find-section">
<FindEventForm />
<FindEventForm initialEventId={initialEventId} />
</section>
<!-- Find User Section -->
@ -223,9 +233,30 @@ @@ -223,9 +233,30 @@
}
.find-button {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: monospace;
}
:global(.dark) .find-button {
background: var(--fog-dark-accent, #94a3b8);
}
.find-button:hover:not(:disabled) {
opacity: 0.9;
}
.find-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;

47
src/routes/repos/+page.svelte

@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
import type { NostrEvent } from '../../lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import { getRecentCachedEvents } from '../../lib/services/cache/event-cache.js';
import { KIND } from '../../lib/types/kind-lookup.js';
let repos = $state<NostrEvent[]>([]);
@ -15,11 +15,45 @@ @@ -15,11 +15,45 @@
onMount(async () => {
await nostrClient.initialize();
await loadCachedRepos();
await loadRepos();
});
async function loadCachedRepos() {
try {
// Load cached repos (within 15 minutes)
const cachedRepos = await getRecentCachedEvents([KIND.REPO_ANNOUNCEMENT], 15 * 60 * 1000, 100);
if (cachedRepos.length > 0) {
// For parameterized replaceable events, get the newest version of each (by pubkey + d tag)
const reposByKey = new Map<string, NostrEvent>();
for (const event of cachedRepos) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${event.pubkey}:${dTag}`;
const existing = reposByKey.get(key);
if (!existing || event.created_at > existing.created_at) {
reposByKey.set(key, event);
}
}
const sortedRepos = Array.from(reposByKey.values()).sort((a, b) => b.created_at - a.created_at);
if (sortedRepos.length > 0) {
repos = sortedRepos;
loading = false; // Show cached data immediately
}
}
} catch (error) {
console.error('Error loading cached repos:', error);
}
}
async function loadRepos() {
// Only show loading spinner if we don't have cached repos
const hasCachedRepos = repos.length > 0;
if (!hasCachedRepos) {
loading = true;
}
try {
const relays = relayManager.getProfileReadRelays();
@ -32,8 +66,17 @@ @@ -32,8 +66,17 @@
{ useCache: true, cacheResults: true }
);
// For parameterized replaceable events, get the newest version of each (by pubkey + d tag)
// Merge with existing cached repos
const reposByKey = new Map<string, NostrEvent>();
// Add existing cached repos first
for (const repo of repos) {
const dTag = repo.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${repo.pubkey}:${dTag}`;
reposByKey.set(key, repo);
}
// Add/update with new repos
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${event.pubkey}:${dTag}`;

516
src/routes/repos/[naddr]/+page.svelte

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { fetchGitRepo, extractGitUrls, type GitRepoInfo, type GitFile } from '../../../lib/services/content/git-repo-fetcher.js';
import FileExplorer from '../../../lib/components/content/FileExplorer.svelte';
import { marked } from 'marked';
import Asciidoctor from 'asciidoctor';
import { KIND } from '../../../lib/types/kind-lookup.js';
@ -16,12 +17,15 @@ @@ -16,12 +17,15 @@
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte';
import { signAndPublish } from '../../../lib/services/nostr/auth-handler.js';
import { sessionManager } from '../../../lib/services/auth/session-manager.js';
import { cacheEvent } from '../../../lib/services/cache/event-cache.js';
import { cacheEvent, getEventsByKind } from '../../../lib/services/cache/event-cache.js';
import EventMenu from '../../../lib/components/EventMenu.svelte';
import { fetchProfiles } from '../../../lib/services/user-data.js';
let naddr = $derived($page.params.naddr);
let repoEvent = $state<NostrEvent | null>(null);
let gitRepo = $state<GitRepoInfo | null>(null);
let loading = $state(true);
let loadingGitRepo = $state(false);
let activeTab = $state<'metadata' | 'about' | 'repository' | 'issues' | 'documentation'>('metadata');
let issues = $state<NostrEvent[]>([]);
let issueComments = $state<Map<string, NostrEvent[]>>(new Map());
@ -29,28 +33,132 @@ @@ -29,28 +33,132 @@
let documentationEvents = $state<Map<string, NostrEvent>>(new Map());
let changingStatus = $state<Map<string, boolean>>(new Map()); // Track which issues are having status changed
let statusFilter = $state<string | null>(null); // Filter issues by status: null = all, 'open', 'resolved', 'closed', 'draft'
let issuesPage = $state(1); // Current page for pagination
const ISSUES_PER_PAGE = 20; // Number of issues to show per page
const asciidoctor = Asciidoctor();
let loadingRepo = $state(false); // Guard to prevent concurrent loads
onMount(async () => {
await nostrClient.initialize();
await loadRepo();
// Don't call loadRepo here - let $effect handle it
});
$effect(() => {
if (naddr) {
if (naddr && !loadingRepo) {
loadCachedRepo();
loadRepo();
}
});
// Load git repo when repository tab is clicked
$effect(() => {
if (activeTab === 'repository' && repoEvent && !gitRepo && !loadingGitRepo) {
loadGitRepo();
}
});
async function loadGitRepo() {
if (!repoEvent || loadingGitRepo || gitRepo) return;
loadingGitRepo = true;
try {
const gitUrls = extractGitUrls(repoEvent);
console.log('Git URLs found:', gitUrls);
if (gitUrls.length > 0) {
// Try each URL until one works
for (const url of gitUrls) {
try {
console.log('Attempting to fetch git repo from:', url);
const repo = await fetchGitRepo(url);
if (repo) {
gitRepo = repo;
console.log('Git repo loaded:', repo.name);
break; // Success, stop trying other URLs
}
} catch (error) {
console.warn('Failed to fetch git repo from', url, error);
// Continue to next URL
}
}
} else {
console.log('No git URLs found in repo event');
}
} catch (error) {
console.error('Error loading git repo:', error);
} finally {
loadingGitRepo = false;
}
}
async function loadCachedRepo() {
if (!naddr) return;
try {
// Decode naddr
let decoded;
try {
decoded = nip19.decode(naddr);
} catch (decodeError) {
return; // Can't decode, skip cache check
}
if (decoded.type !== 'naddr') {
return;
}
const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] };
const kind = naddrData.kind;
const pubkey = naddrData.pubkey;
const dTag = naddrData.identifier || '';
// Check cache for repo events of this kind
const cachedEvents = await getEventsByKind(kind, 1000);
// Find the matching repo event (by pubkey and d-tag)
const matchingEvent = cachedEvents.find(event => {
if (event.pubkey !== pubkey) return false;
const eventDTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
return eventDTag === dTag;
});
if (matchingEvent) {
repoEvent = matchingEvent;
loading = false; // Show cached data immediately
// Load issues and documentation in background (but not git repo - wait for tab click)
Promise.all([
loadIssues(),
loadDocumentation()
]).catch(err => {
console.error('Error loading repo data from cache:', err);
});
}
} catch (error) {
console.error('Error loading cached repo:', error);
}
}
async function loadRepo() {
if (!naddr || loadingRepo) {
if (!naddr) {
console.warn('No naddr parameter provided');
}
if (!repoEvent) {
loading = false;
}
return;
}
// Only show loading spinner if we don't have cached data
const hasCachedData = repoEvent !== null;
if (!hasCachedData) {
loading = true;
}
loadingRepo = true;
try {
// Decode naddr
let decoded;
@ -58,13 +166,17 @@ @@ -58,13 +166,17 @@
decoded = nip19.decode(naddr);
} catch (decodeError) {
console.error('Failed to decode naddr:', decodeError);
if (!hasCachedData) {
loading = false;
}
return;
}
if (decoded.type !== 'naddr') {
console.error('Invalid naddr type:', decoded.type);
if (!hasCachedData) {
loading = false;
}
return;
}
@ -83,6 +195,7 @@ @@ -83,6 +195,7 @@
console.log('Using relays:', relays);
// Step 1: Fetch the repo event by ID (using kind, author, and d-tag)
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
@ -95,40 +208,30 @@ @@ -95,40 +208,30 @@
repoEvent = events[0];
console.log('Repo event loaded:', repoEvent.id);
// Extract git URLs and fetch repo data
const gitUrls = extractGitUrls(repoEvent);
console.log('Git URLs found:', gitUrls);
if (gitUrls.length > 0) {
// Try each URL until one works
for (const url of gitUrls) {
try {
console.log('Attempting to fetch git repo from:', url);
const repo = await fetchGitRepo(url);
if (repo) {
gitRepo = repo;
console.log('Git repo loaded:', repo.name);
break;
}
} catch (error) {
console.warn('Failed to fetch git repo from', url, error);
// Continue to next URL
}
}
} else {
console.log('No git URLs found in repo event');
}
// Don't fetch git repo here - wait until user clicks on repository tab
// This prevents rate limiting from GitHub/GitLab/Gitea
// Load issues (events that reference this repo)
await loadIssues();
// Load documentation events
await loadDocumentation();
// Step 2: Batch load all related data in parallel (only if not already loaded from cache)
if (issues.length === 0 && documentationEvents.size === 0) {
await Promise.all([
loadIssues(), // Batch fetch issues, statuses, comments, and profiles
loadDocumentation() // Load documentation events
]);
}
} else {
console.warn('No repo event found for naddr:', naddr);
if (!hasCachedData) {
loading = false;
}
}
} catch (error) {
console.error('Error loading repo:', error);
if (!hasCachedData) {
loading = false;
}
} finally {
loading = false;
loadingRepo = false;
}
}
@ -139,38 +242,35 @@ @@ -139,38 +242,35 @@
const gitUrls = extractGitUrls(repoEvent);
const relays = relayManager.getProfileReadRelays();
// Fetch kind 1621 issues that reference this repo
const issueEvents: NostrEvent[] = [];
// Batch fetch all issues that reference this repo
const filters: any[] = [];
// Search for issues that reference the repo event ID
const referencedIssues = await nostrClient.fetchEvents(
[{ '#e': [repoEvent.id], kinds: [KIND.ISSUE], limit: 50 }],
relays,
{ useCache: true, cacheResults: true }
);
issueEvents.push(...referencedIssues);
filters.push({ '#e': [repoEvent.id], kinds: [KIND.ISSUE], limit: 100 });
// Search for issues that reference the repo using 'a' tag (NIP-34 format: kind:pubkey:d-tag)
const dTag = repoEvent.tags.find(t => Array.isArray(t) && t[0] === 'd')?.[1] || '';
if (dTag) {
const aTagValue = `${repoEvent.kind}:${repoEvent.pubkey}:${dTag}`;
const aTagIssues = await nostrClient.fetchEvents(
[{ '#a': [aTagValue], kinds: [KIND.ISSUE], limit: 50 }],
relays,
{ useCache: true, cacheResults: true }
);
issueEvents.push(...aTagIssues);
filters.push({ '#a': [aTagValue], kinds: [KIND.ISSUE], limit: 100 });
}
// Also search for issues with git URLs in tags
for (const url of gitUrls) {
const urlIssues = await nostrClient.fetchEvents(
[{ '#r': [url], kinds: [KIND.ISSUE], limit: 20 }],
relays,
{ useCache: true, cacheResults: true }
// Also search for issues with git URLs in tags (batch all URLs in one filter)
if (gitUrls.length > 0) {
filters.push({ '#r': gitUrls, kinds: [KIND.ISSUE], limit: 100 });
}
// Batch fetch all issues in parallel
const issueEventsArrays = await Promise.all(
filters.map(filter =>
nostrClient.fetchEvents([filter], relays, { useCache: true, cacheResults: true })
)
);
issueEvents.push(...urlIssues);
// Flatten and deduplicate
const issueEvents: NostrEvent[] = [];
for (const events of issueEventsArrays) {
issueEvents.push(...events);
}
// Deduplicate and sort
@ -179,9 +279,12 @@ @@ -179,9 +279,12 @@
console.log(`Loaded ${issues.length} issues for repo ${repoEvent.id}`);
// Load statuses and comments for each issue
await loadIssueStatuses();
await loadIssueComments();
// Batch load statuses, comments, and profiles
await Promise.all([
loadIssueStatuses(),
loadIssueComments(),
loadAllProfiles()
]);
} catch (error) {
console.error('Error loading issues:', error);
}
@ -383,10 +486,29 @@ @@ -383,10 +486,29 @@
// Filter issues by status
let filteredIssues = $derived.by(() => {
if (!statusFilter) {
return issues;
let filtered = issues;
if (statusFilter) {
filtered = issues.filter(issue => getCurrentStatus(issue.id) === statusFilter);
}
return filtered;
});
// Paginated issues
let paginatedIssues = $derived.by(() => {
const start = (issuesPage - 1) * ISSUES_PER_PAGE;
const end = start + ISSUES_PER_PAGE;
return filteredIssues.slice(start, end);
});
let totalPages = $derived.by(() => {
return Math.ceil(filteredIssues.length / ISSUES_PER_PAGE);
});
// Reset to page 1 when filter changes
$effect(() => {
if (statusFilter !== null) {
issuesPage = 1;
}
return issues.filter(issue => getCurrentStatus(issue.id) === statusFilter);
});
async function loadIssueComments() {
@ -396,8 +518,9 @@ @@ -396,8 +518,9 @@
const issueIds = issues.map(i => i.id);
const relays = relayManager.getCommentReadRelays();
// Batch fetch all comments for all issues
const comments = await nostrClient.fetchEvents(
[{ '#e': issueIds, kinds: [KIND.COMMENT], limit: 200 }],
[{ '#e': issueIds, kinds: [KIND.COMMENT], limit: 500 }],
relays,
{ useCache: true, cacheResults: true }
);
@ -416,11 +539,57 @@ @@ -416,11 +539,57 @@
}
issueComments = commentsMap;
console.log(`Loaded ${comments.length} comments for ${issueIds.length} issues`);
} catch (error) {
console.error('Error loading issue comments:', error);
}
}
async function loadAllProfiles() {
if (issues.length === 0) return;
try {
// Collect all unique pubkeys from:
// 1. Issue authors
// 2. Comment authors
// 3. Status authors
// 4. Repository owner and maintainers
const pubkeys = new Set<string>();
// Add repo owner and maintainers
if (repoEvent) {
pubkeys.add(repoEvent.pubkey);
const maintainers = getMaintainers();
maintainers.forEach(m => pubkeys.add(m));
}
// Add issue authors
issues.forEach(issue => pubkeys.add(issue.pubkey));
// Add comment authors
for (const comments of issueComments.values()) {
comments.forEach(comment => pubkeys.add(comment.pubkey));
}
// Add status authors
for (const status of issueStatuses.values()) {
pubkeys.add(status.pubkey);
}
const uniquePubkeys = Array.from(pubkeys);
console.log(`Batch fetching ${uniquePubkeys.length} profiles`);
// Batch fetch all profiles at once
const relays = relayManager.getProfileReadRelays();
await fetchProfiles(uniquePubkeys, relays);
console.log(`Pre-fetched ${uniquePubkeys.length} profiles`);
} catch (error) {
console.error('Error loading profiles:', error);
// Don't throw - profile loading is best effort
}
}
async function loadDocumentation() {
if (!repoEvent) return;
@ -725,9 +894,14 @@ @@ -725,9 +894,14 @@
</div>
{:else}
<div class="repo-header mb-6">
<div class="repo-title-row">
<h1 class="text-3xl font-bold text-fog-text dark:text-fog-dark-text font-mono mb-2">
{getRepoName()}
</h1>
{#if repoEvent}
<EventMenu event={repoEvent} showContentActions={true} />
{/if}
</div>
{#if getRepoDescription()}
<p class="text-fog-text-light dark:text-fog-dark-text-light mb-4">
{getRepoDescription()}
@ -877,7 +1051,11 @@ @@ -877,7 +1051,11 @@
</div>
{:else if activeTab === 'repository'}
<div class="repository-tab">
{#if gitRepo}
{#if loadingGitRepo}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading repository data...</p>
</div>
{:else if gitRepo}
<!-- Branch and Commit Info -->
<div class="repo-info-section mb-6">
<h2 class="text-xl font-bold text-fog-text dark:text-fog-dark-text mb-4">
@ -919,9 +1097,7 @@ @@ -919,9 +1097,7 @@
File Structure
</h2>
{#if gitRepo.files.length > 0}
<div class="file-tree">
<pre class="file-tree-content">{renderFileTree(getFileTree(gitRepo.files))}</pre>
</div>
<FileExplorer files={gitRepo.files} repoInfo={gitRepo} />
{:else}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No files found.</p>
@ -963,7 +1139,7 @@ @@ -963,7 +1139,7 @@
</div>
<div class="issues-list">
{#if filteredIssues.length > 0}
{#each filteredIssues as issue}
{#each paginatedIssues as issue}
{@const currentStatus = getCurrentStatus(issue.id)}
{@const isChanging = changingStatus.get(issue.id) || false}
<div class="issue-item">
@ -1013,6 +1189,31 @@ @@ -1013,6 +1189,31 @@
{/if}
</div>
{/each}
<!-- Pagination controls -->
{#if totalPages > 1}
<div class="pagination">
<button
onclick={() => issuesPage = Math.max(1, issuesPage - 1)}
disabled={issuesPage === 1}
class="pagination-button"
aria-label="Previous page"
>
Previous
</button>
<span class="pagination-info">
Page {issuesPage} of {totalPages} ({filteredIssues.length} {filteredIssues.length === 1 ? 'issue' : 'issues'})
</span>
<button
onclick={() => issuesPage = Math.min(totalPages, issuesPage + 1)}
disabled={issuesPage === totalPages}
class="pagination-button"
aria-label="Next page"
>
Next
</button>
</div>
{/if}
{:else}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No issues found with status "{statusFilter}".</p>
@ -1032,10 +1233,10 @@ @@ -1032,10 +1233,10 @@
{#each Array.from(documentationEvents.entries()) as [docNaddr, docEvent]}
<div class="documentation-item">
<div class="doc-header">
<h3 class="doc-title">Documentation: {docNaddr.slice(0, 20)}...</h3>
<div class="doc-meta">
<span class="doc-kind">Kind {docEvent.kind}</span>
<a href="/event/{docEvent.id}" class="doc-event-link">View Event</a>
<EventMenu event={docEvent} showContentActions={true} />
</div>
</div>
<div class="doc-content">
@ -1084,18 +1285,101 @@ @@ -1084,18 +1285,101 @@
border-bottom-color: var(--fog-dark-border, #374151);
}
.repo-title-row {
display: flex;
align-items: flex-start;
gap: 1rem;
justify-content: space-between;
flex-wrap: wrap;
}
.repo-title-row h1 {
flex: 1;
min-width: 0;
}
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-wrap: wrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
:global(.dark) .tabs {
border-bottom-color: var(--fog-dark-border, #374151);
}
.tab-button {
padding: 0.75rem 1.25rem;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
min-height: 2.75rem;
display: flex;
align-items: center;
justify-content: center;
}
:global(.dark) .tab-button {
color: var(--fog-dark-text-light, #9ca3af);
}
.tab-button:hover {
color: var(--fog-text, #1f2937);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .tab-button:hover {
color: var(--fog-dark-text, #f9fafb);
background: var(--fog-dark-highlight, #475569);
}
.tab-button.active {
color: var(--fog-text, #1f2937);
border-bottom-color: var(--fog-accent, #64748b);
background: var(--fog-highlight, #f3f4f6);
font-weight: 600;
}
:global(.dark) .tab-button.active {
color: var(--fog-dark-text, #f9fafb);
border-bottom-color: var(--fog-dark-accent, #94a3b8);
background: var(--fog-dark-highlight, #475569);
}
.tab-button:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
:global(.dark) .tab-button:focus {
outline-color: var(--fog-dark-accent, #94a3b8);
}
@media (max-width: 640px) {
.tabs {
gap: 0.25rem;
padding-bottom: 0.5rem;
}
.tab-button {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
min-height: 2.5rem;
}
}
.tab-content {
margin-top: 2rem;
@ -1278,30 +1562,6 @@ @@ -1278,30 +1562,6 @@
color: var(--fog-dark-text-light, #9ca3af);
}
.file-tree {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
overflow-x: auto;
}
:global(.dark) .file-tree {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.file-tree-content {
margin: 0;
font-family: monospace;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
white-space: pre;
}
:global(.dark) .file-tree-content {
color: var(--fog-dark-text, #f9fafb);
}
.issues-filter {
display: flex;
@ -1740,7 +2000,7 @@ @@ -1740,7 +2000,7 @@
.doc-header {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
@ -1751,17 +2011,6 @@ @@ -1751,17 +2011,6 @@
border-bottom-color: var(--fog-dark-border, #374151);
}
.doc-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
margin: 0;
}
:global(.dark) .doc-title {
color: var(--fog-dark-text, #f9fafb);
}
.doc-meta {
display: flex;
gap: 1rem;
@ -1769,6 +2018,18 @@ @@ -1769,6 +2018,18 @@
font-size: 0.875rem;
}
@media (max-width: 640px) {
.doc-header {
justify-content: flex-start;
}
.doc-meta {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
.doc-kind {
color: var(--fog-text-light, #6b7280);
padding: 0.25rem 0.5rem;
@ -1797,4 +2058,59 @@ @@ -1797,4 +2058,59 @@
.doc-content {
margin-top: 1rem;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .pagination {
border-top-color: var(--fog-dark-border, #374151);
}
.pagination-button {
padding: 0.5rem 1rem;
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;
cursor: pointer;
transition: all 0.2s;
}
.pagination-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .pagination-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .pagination-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.pagination-info {
font-size: 0.875rem;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .pagination-info {
color: var(--fog-dark-text-light, #9ca3af);
}
</style>

334
src/routes/rss/+page.svelte

@ -7,12 +7,26 @@ @@ -7,12 +7,26 @@
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import MarkdownRenderer from '../../lib/components/content/MarkdownRenderer.svelte';
const RSS_FEED_KIND = 10895;
interface RSSItem {
title: string;
link: string;
description: string;
pubDate: Date;
feedUrl: string;
feedTitle?: string;
}
let currentPubkey = $state<string | null>(null);
let rssEvent = $state<NostrEvent | null>(null);
let loading = $state(true);
let loadingFeeds = $state(false);
let rssItems = $state<RSSItem[]>([]);
let feedErrors = $state<Map<string, string>>(new Map());
let subscribedFeeds = $derived.by(() => {
if (!rssEvent) return [];
return rssEvent.tags
@ -32,6 +46,12 @@ @@ -32,6 +46,12 @@
await checkRssEvent();
});
$effect(() => {
if (subscribedFeeds.length > 0 && rssEvent) {
loadRssFeeds();
}
});
async function checkRssEvent() {
if (!currentPubkey) return;
@ -54,6 +74,149 @@ @@ -54,6 +74,149 @@
}
}
async function loadRssFeeds() {
if (subscribedFeeds.length === 0 || loadingFeeds) return;
loadingFeeds = true;
feedErrors.clear();
const allItems: RSSItem[] = [];
try {
// Fetch all feeds in parallel
const feedPromises = subscribedFeeds.map(async (feedUrl) => {
try {
const items = await fetchRssFeed(feedUrl);
allItems.push(...items);
} catch (error) {
console.error(`Error fetching RSS feed ${feedUrl}:`, error);
feedErrors.set(feedUrl, error instanceof Error ? error.message : 'Failed to fetch feed');
}
});
await Promise.all(feedPromises);
// Sort by date (newest first)
allItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
rssItems = allItems;
} catch (error) {
console.error('Error loading RSS feeds:', error);
} finally {
loadingFeeds = false;
}
}
async function fetchRssFeed(feedUrl: string): Promise<RSSItem[]> {
// Use a CORS proxy if needed, or fetch directly
// For now, try direct fetch first
let response: Response;
try {
response = await fetch(feedUrl, {
mode: 'cors',
headers: {
'Accept': 'application/rss+xml, application/xml, text/xml, */*'
}
});
} catch (error) {
// If CORS fails, try using a proxy
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`;
response = await fetch(proxyUrl);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
// Check for parsing errors
const parserError = xmlDoc.querySelector('parsererror');
if (parserError) {
throw new Error('Failed to parse RSS XML');
}
// Get feed title
const feedTitle = xmlDoc.querySelector('channel > title')?.textContent?.trim() ||
xmlDoc.querySelector('title')?.textContent?.trim();
// Parse items (handle both RSS 2.0 and Atom formats)
const items: RSSItem[] = [];
// RSS 2.0 format
const rssItems = xmlDoc.querySelectorAll('item');
rssItems.forEach((item) => {
const title = item.querySelector('title')?.textContent?.trim() || 'Untitled';
const link = item.querySelector('link')?.textContent?.trim() || '';
const description = item.querySelector('description')?.textContent?.trim() || '';
const pubDateStr = item.querySelector('pubDate')?.textContent?.trim() || '';
let pubDate = new Date();
if (pubDateStr) {
const parsed = new Date(pubDateStr);
if (!isNaN(parsed.getTime())) {
pubDate = parsed;
}
}
items.push({
title,
link,
description,
pubDate,
feedUrl,
feedTitle
});
});
// Atom format (if no RSS items found)
if (items.length === 0) {
const atomEntries = xmlDoc.querySelectorAll('entry');
atomEntries.forEach((entry) => {
const title = entry.querySelector('title')?.textContent?.trim() || 'Untitled';
const linkEl = entry.querySelector('link');
const link = linkEl?.getAttribute('href') || linkEl?.textContent?.trim() || '';
const description = entry.querySelector('summary')?.textContent?.trim() ||
entry.querySelector('content')?.textContent?.trim() || '';
const pubDateStr = entry.querySelector('published')?.textContent?.trim() ||
entry.querySelector('updated')?.textContent?.trim() || '';
let pubDate = new Date();
if (pubDateStr) {
const parsed = new Date(pubDateStr);
if (!isNaN(parsed.getTime())) {
pubDate = parsed;
}
}
items.push({
title,
link,
description,
pubDate,
feedUrl,
feedTitle
});
});
}
return items;
}
function getRelativeTime(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const minutes = Math.floor(diff / (1000 * 60));
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
function handleCreateRss() {
goto(`/write?kind=${RSS_FEED_KIND}`);
}
@ -83,6 +246,7 @@ @@ -83,6 +246,7 @@
</button>
</div>
{:else}
<div class="rss-content">
<div class="rss-info">
<h2 class="text-xl font-semibold mb-4 text-fog-text dark:text-fog-dark-text">Subscribed RSS Feeds</h2>
{#if subscribedFeeds.length === 0}
@ -101,6 +265,9 @@ @@ -101,6 +265,9 @@
>
{feedUrl}
</a>
{#if feedErrors.has(feedUrl)}
<span class="feed-error">Error: {feedErrors.get(feedUrl)}</span>
{/if}
</li>
{/each}
</ul>
@ -109,14 +276,65 @@ @@ -109,14 +276,65 @@
<p class="mb-2 text-sm text-fog-text dark:text-fog-dark-text">
To add or remove feeds, edit your kind {RSS_FEED_KIND} event.
</p>
{#if rssEvent}
<a
href="/write?kind={RSS_FEED_KIND}"
href="/find?event={rssEvent.id}"
class="edit-rss-button"
>
Edit RSS Feed Event
</a>
{:else}
<a
href="/write?kind={RSS_FEED_KIND}"
class="edit-rss-button"
>
Create RSS Feed Event
</a>
{/if}
</div>
</div>
{#if subscribedFeeds.length > 0}
<div class="rss-items-section">
<h2 class="text-xl font-semibold mb-4 text-fog-text dark:text-fog-dark-text">RSS Feed Items</h2>
{#if loadingFeeds}
<p class="text-fog-text dark:text-fog-dark-text">Loading RSS feeds...</p>
{:else if rssItems.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No RSS items found.</p>
{:else}
<div class="rss-items">
{#each rssItems as item (item.link + item.pubDate.getTime())}
<article class="rss-item">
<div class="rss-item-header">
<div class="rss-item-meta">
{#if item.feedTitle}
<span class="rss-feed-badge">{item.feedTitle}</span>
{/if}
<span class="rss-item-time">{getRelativeTime(item.pubDate)}</span>
</div>
</div>
<h3 class="rss-item-title">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
class="rss-item-link"
>
{item.title}
</a>
</h3>
{#if item.description}
<div class="rss-item-description">
<MarkdownRenderer content={item.description} />
</div>
{/if}
</article>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</main>
@ -187,4 +405,118 @@ @@ -187,4 +405,118 @@
text-decoration: underline;
}
.feed-error {
display: block;
margin-top: 0.5rem;
color: var(--fog-danger, #dc2626);
font-size: 0.75rem;
}
:global(.dark) .feed-error {
color: var(--fog-dark-danger, #ef4444);
}
.rss-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.rss-items-section {
padding: 2rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .rss-items-section {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.rss-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.rss-item {
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .rss-item {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.rss-item-header {
margin-bottom: 0.5rem;
}
.rss-item-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.rss-feed-badge {
padding: 0.25rem 0.5rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
:global(.dark) .rss-feed-badge {
background: var(--fog-dark-accent, #94a3b8);
}
.rss-item-time {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .rss-item-time {
color: var(--fog-dark-text-light, #9ca3af);
}
.rss-item-title {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
}
.rss-item-link {
color: var(--fog-text, #475569);
text-decoration: none;
}
:global(.dark) .rss-item-link {
color: var(--fog-dark-text, #cbd5e1);
}
.rss-item-link:hover {
color: var(--fog-accent, #64748b);
text-decoration: underline;
}
:global(.dark) .rss-item-link:hover {
color: var(--fog-dark-accent, #94a3b8);
}
.rss-item-description {
color: var(--fog-text, #475569);
font-size: 0.875rem;
line-height: 1.6;
}
:global(.dark) .rss-item-description {
color: var(--fog-dark-text, #cbd5e1);
}
</style>

105
src/routes/topics/+page.svelte

@ -14,9 +14,18 @@ @@ -14,9 +14,18 @@
isInterest: boolean;
}
let topics = $state<TopicInfo[]>([]);
const ITEMS_PER_PAGE = 50; // Number of topics to render at once
const ITEM_HEIGHT = 60; // Approximate height of each topic item in pixels
let allTopics = $state<TopicInfo[]>([]);
let visibleTopics = $state<TopicInfo[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let interestList = $state<string[]>([]);
let sentinelElement = $state<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
let renderedCount = $state(ITEMS_PER_PAGE);
function extractHashtags(event: NostrEvent): string[] {
const hashtags = new Set<string>();
@ -88,15 +97,61 @@ @@ -88,15 +97,61 @@
return b.count - a.count;
});
topics = topicList;
allTopics = topicList;
updateVisibleTopics();
hasMore = allTopics.length > renderedCount;
} catch (error) {
console.error('Error loading topics:', error);
topics = [];
allTopics = [];
visibleTopics = [];
} finally {
loading = false;
}
}
function updateVisibleTopics() {
visibleTopics = allTopics.slice(0, renderedCount);
}
function loadMore() {
if (loadingMore || !hasMore) return;
loadingMore = true;
// Simulate a small delay for smooth scrolling
setTimeout(() => {
renderedCount = Math.min(renderedCount + ITEMS_PER_PAGE, allTopics.length);
updateVisibleTopics();
hasMore = renderedCount < allTopics.length;
loadingMore = false;
}, 100);
}
function setupObserver() {
if (!sentinelElement || observer) return;
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
loadMore();
}
}, { threshold: 0.1, rootMargin: '100px' });
observer.observe(sentinelElement);
}
// Setup observer when sentinel element is available
$effect(() => {
if (sentinelElement && !loading && allTopics.length > 0) {
setupObserver();
}
return () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
});
function handleTopicClick(topic: string) {
goto(`/topics/${topic}`);
}
@ -115,11 +170,12 @@ @@ -115,11 +170,12 @@
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading topics...</p>
{:else if topics.length === 0}
{:else if allTopics.length === 0}
<p class="text-fog-text dark:text-fog-dark-text">No topics found.</p>
{:else}
<div class="topics-container">
<div class="topics-list">
{#each topics as topic (topic.name)}
{#each visibleTopics as topic (topic.name)}
<div
class="topic-item"
class:interest={topic.isInterest}
@ -138,6 +194,28 @@ @@ -138,6 +194,28 @@
</div>
{/each}
</div>
{#if hasMore}
<div
class="topics-sentinel"
bind:this={sentinelElement}
>
{#if loadingMore}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more topics...</p>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Scroll for more</p>
{/if}
</div>
{/if}
{#if !hasMore && allTopics.length > 0}
<div class="topics-end">
<p class="text-fog-text-light dark:text-fog-dark-text-light">
Showing all {allTopics.length} topics
</p>
</div>
{/if}
</div>
{/if}
</div>
</main>
@ -150,12 +228,29 @@ @@ -150,12 +228,29 @@
padding: 0 1rem;
}
.topics-container {
display: flex;
flex-direction: column;
}
.topics-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.topics-sentinel {
padding: 2rem;
text-align: center;
min-height: 100px;
}
.topics-end {
padding: 1rem;
text-align: center;
margin-top: 1rem;
}
.topic-item {
display: flex;
justify-content: space-between;

92
src/routes/topics/[name]/+page.svelte

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { KIND } from '../../../lib/types/kind-lookup.js';
import { getRecentCachedEvents } from '../../../lib/services/cache/event-cache.js';
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
@ -14,54 +15,109 @@ @@ -14,54 +15,109 @@
onMount(async () => {
await nostrClient.initialize();
await loadCachedTopicEvents();
await loadTopicEvents();
});
$effect(() => {
if ($page.params.name) {
loadCachedTopicEvents();
loadTopicEvents();
}
});
async function loadCachedTopicEvents() {
if (!topicName) return;
try {
// Load cached events for this topic (within 15 minutes)
const cachedEvents = await getRecentCachedEvents([KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], 15 * 60 * 1000, 100);
if (cachedEvents.length > 0) {
// Filter events that match the topic
const hashtagPattern = new RegExp(`#${topicName}\\b`, 'i');
const matchingEvents: NostrEvent[] = [];
for (const event of cachedEvents) {
// Check if event has matching t-tag or hashtag in content
const hasTTag = event.tags.some(t => t[0] === 't' && t[1] === topicName);
const hasHashtag = hashtagPattern.test(event.content);
if (hasTTag || hasHashtag) {
matchingEvents.push(event);
}
}
if (matchingEvents.length > 0) {
// Deduplicate
const eventMap = new Map<string, NostrEvent>();
for (const event of matchingEvents) {
eventMap.set(event.id, event);
}
events = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
loading = false; // Show cached data immediately
}
}
} catch (error) {
console.error('Error loading cached topic events:', error);
}
}
async function loadTopicEvents() {
if (!topicName) return;
// Only show loading spinner if we don't have cached events
const hasCachedEvents = events.length > 0;
if (!hasCachedEvents) {
loading = true;
}
try {
const relays = relayManager.getFeedReadRelays();
// Fetch events with matching hashtag in content or t-tag
// We'll search for events that contain the hashtag in content or have a matching t-tag
const allEvents: NostrEvent[] = [];
// Fetch events with matching t-tag (most efficient - uses relay filtering)
const tTagEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], '#t': [topicName], limit: 500 }],
relays,
{ useCache: true, cacheResults: true }
);
// Also search for hashtags in content (less efficient but catches events without t-tags)
// We'll fetch a larger set and filter, but prioritize t-tag events
const hashtagPattern = new RegExp(`#${topicName}\\b`, 'i');
const allEvents: NostrEvent[] = [...tTagEvents];
// Search for hashtag in content (kind 1 posts)
// For content-based hashtag search, we need to fetch more events
// But we'll limit this to avoid fetching too much
const contentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 100 }],
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 500 }],
relays,
{ useCache: true, cacheResults: true }
);
// Filter events that contain the hashtag
const hashtagPattern = new RegExp(`#${topicName}\\b`, 'i');
// Filter events that contain the hashtag in content but don't already have a t-tag
const tTagEventIds = new Set(tTagEvents.map(e => e.id));
for (const event of contentEvents) {
if (hashtagPattern.test(event.content)) {
// Only add if it has the hashtag in content and isn't already in tTagEvents
if (!tTagEventIds.has(event.id) && hashtagPattern.test(event.content)) {
// Also check if it doesn't have a t-tag (to avoid duplicates)
const hasTTag = event.tags.some(t => t[0] === 't' && t[1] === topicName);
if (!hasTTag) {
allEvents.push(event);
}
}
}
// Search for events with matching t-tag
const tTagEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], '#t': [topicName], limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// Merge and deduplicate
// Merge with existing cached events and deduplicate
const eventMap = new Map<string, NostrEvent>();
for (const event of allEvents) {
// Add existing cached events first
for (const event of events) {
eventMap.set(event.id, event);
}
for (const event of tTagEvents) {
// Add new events
for (const event of allEvents) {
eventMap.set(event.id, event);
}

Loading…
Cancel
Save