PublicationReader.svelte raw
1 <script>
2 import { readerIndex, readerSections, readerCurrentSection, activeLibraryView } from './libraryStores.js';
3
4 export let isLoggedIn = false;
5 export let userPubkey = "";
6
7 $: index = $readerIndex;
8 $: sections = $readerSections;
9 $: currentIdx = $readerCurrentSection;
10 $: currentSection = sections[currentIdx] || null;
11 $: totalSections = sections.length;
12
13 // Extract metadata from index event
14 $: title = index ? (index.tags || []).find(t => t[0] === "title")?.[1] || "Untitled" : "";
15 $: format = index ? (index.tags || []).find(t => t[0] === "format")?.[1] || "markdown" : "markdown";
16
17 // TOC entries from index a-tags or section titles
18 $: tocEntries = sections.map((s, i) => ({
19 index: i,
20 title: (s.tags || []).find(t => t[0] === "title")?.[1] || `Section ${i + 1}`,
21 }));
22
23 // Simple markdown-to-HTML renderer
24 function renderContent(content, fmt) {
25 if (!content) return '<p class="empty-section">Empty section.</p>';
26
27 // HTML escape first
28 let text = content
29 .replace(/&/g, '&')
30 .replace(/</g, '<')
31 .replace(/>/g, '>');
32
33 if (fmt === "asciidoc") {
34 // Basic AsciiDoc rendering
35 text = text.replace(/^= (.+)$/gm, '<h1>$1</h1>');
36 text = text.replace(/^== (.+)$/gm, '<h2>$1</h2>');
37 text = text.replace(/^=== (.+)$/gm, '<h3>$1</h3>');
38 text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
39 text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
40 text = text.replace(/`(.+?)`/g, '<code>$1</code>');
41 text = text.replace(/\n\n/g, '</p><p>');
42 text = `<p>${text}</p>`;
43 } else {
44 // Markdown rendering
45 text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
46 text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
47 text = text.replace(/^# (.+)$/gm, '<h1>$1</h1>');
48 text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
49 text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
50 text = text.replace(/`(.+?)`/g, '<code>$1</code>');
51 text = text.replace(/^\- (.+)$/gm, '<li>$1</li>');
52 text = text.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
53 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
54 text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="content-image" />');
55 text = text.replace(/\n\n/g, '</p><p>');
56 text = `<p>${text}</p>`;
57 }
58
59 // Convert remaining newlines to <br>
60 text = text.replace(/\n/g, '<br>');
61
62 return text;
63 }
64
65 $: renderedContent = currentSection ? renderContent(currentSection.content, format) : '';
66 $: sectionTitle = currentSection
67 ? (currentSection.tags || []).find(t => t[0] === "title")?.[1] || `Section ${currentIdx + 1}`
68 : '';
69
70 function goToSection(idx) {
71 readerCurrentSection.set(idx);
72 }
73
74 function prevSection() {
75 if (currentIdx > 0) readerCurrentSection.set(currentIdx - 1);
76 }
77
78 function nextSection() {
79 if (currentIdx < totalSections - 1) readerCurrentSection.set(currentIdx + 1);
80 }
81
82 function backToLibrary() {
83 activeLibraryView.set("my-library");
84 }
85 </script>
86
87 <div class="reader">
88 <!-- TOC sidebar -->
89 <div class="reader-toc">
90 <button class="back-link" on:click={backToLibrary}>
91 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
92 Back
93 </button>
94 <div class="toc-title">{title}</div>
95 {#each tocEntries as entry (entry.index)}
96 <button
97 class="toc-item"
98 class:active={currentIdx === entry.index}
99 on:click={() => goToSection(entry.index)}
100 >
101 {entry.title}
102 </button>
103 {/each}
104 </div>
105
106 <!-- Content panel -->
107 <div class="reader-content">
108 <div class="content-header">
109 <h2>{sectionTitle}</h2>
110 {#if totalSections > 1}
111 <span class="section-indicator">{currentIdx + 1} / {totalSections}</span>
112 {/if}
113 </div>
114
115 <div class="content-body">
116 {#if sections.length === 0}
117 <div class="reader-empty">No content available for this publication.</div>
118 {:else}
119 <div class="rendered-content">{@html renderedContent}</div>
120 {/if}
121 </div>
122
123 {#if totalSections > 1}
124 <div class="section-nav">
125 <button class="nav-btn" on:click={prevSection} disabled={currentIdx === 0}>
126 Previous
127 </button>
128 <button class="nav-btn" on:click={nextSection} disabled={currentIdx >= totalSections - 1}>
129 Next
130 </button>
131 </div>
132 {/if}
133 </div>
134 </div>
135
136 <style>
137 .reader {
138 display: flex;
139 width: 100%;
140 height: 100%;
141 overflow: hidden;
142 }
143
144 .reader-toc {
145 width: 220px;
146 flex-shrink: 0;
147 border-right: 1px solid var(--border-color);
148 overflow-y: auto;
149 padding: 0.5em 0;
150 }
151
152 .back-link {
153 display: flex;
154 align-items: center;
155 gap: 0.3em;
156 padding: 0.5em 0.8em;
157 background: none;
158 border: none;
159 color: var(--primary);
160 font-size: 0.8rem;
161 cursor: pointer;
162 margin-bottom: 0.5em;
163 }
164
165 .back-link svg {
166 width: 0.9em;
167 height: 0.9em;
168 }
169
170 .back-link:hover {
171 text-decoration: underline;
172 }
173
174 .toc-title {
175 padding: 0.3em 0.8em 0.6em;
176 font-weight: 700;
177 font-size: 0.9rem;
178 color: var(--text-color);
179 border-bottom: 1px solid var(--border-color);
180 margin-bottom: 0.3em;
181 }
182
183 .toc-item {
184 display: block;
185 width: 100%;
186 padding: 0.4em 0.8em;
187 background: none;
188 border: none;
189 color: var(--text-color);
190 font-size: 0.8rem;
191 cursor: pointer;
192 text-align: left;
193 transition: background 0.1s;
194 border-left: 2px solid transparent;
195 }
196
197 .toc-item:hover {
198 background: var(--primary-bg);
199 }
200
201 .toc-item.active {
202 background: var(--primary-bg);
203 border-left-color: var(--primary);
204 color: var(--primary);
205 font-weight: 600;
206 }
207
208 .reader-content {
209 flex: 1;
210 overflow-y: auto;
211 display: flex;
212 flex-direction: column;
213 min-width: 0;
214 }
215
216 .content-header {
217 display: flex;
218 align-items: center;
219 justify-content: space-between;
220 padding: 0.75em 1.5em;
221 border-bottom: 1px solid var(--border-color);
222 position: sticky;
223 top: 0;
224 background: var(--bg-color);
225 z-index: 1;
226 }
227
228 .content-header h2 {
229 margin: 0;
230 font-size: 1rem;
231 color: var(--text-color);
232 }
233
234 .section-indicator {
235 font-size: 0.75rem;
236 color: var(--text-muted);
237 }
238
239 .content-body {
240 flex: 1;
241 padding: 1.5em;
242 max-width: 720px;
243 }
244
245 .rendered-content {
246 font-size: 0.9rem;
247 line-height: 1.7;
248 color: var(--text-color);
249 }
250
251 :global(.rendered-content h1) { font-size: 1.4rem; margin: 1em 0 0.5em; color: var(--text-color); }
252 :global(.rendered-content h2) { font-size: 1.2rem; margin: 1em 0 0.4em; color: var(--text-color); }
253 :global(.rendered-content h3) { font-size: 1rem; margin: 0.8em 0 0.3em; color: var(--text-color); }
254 :global(.rendered-content p) { margin: 0 0 0.8em; }
255 :global(.rendered-content code) {
256 background: var(--card-bg, #1a1a1a);
257 padding: 0.15em 0.35em;
258 border-radius: 3px;
259 font-size: 0.85em;
260 }
261 :global(.rendered-content a) { color: var(--primary); }
262 :global(.rendered-content ul) { margin: 0.5em 0; padding-left: 1.5em; }
263 :global(.rendered-content li) { margin: 0.2em 0; }
264 :global(.rendered-content .content-image) { max-width: 100%; border-radius: 6px; margin: 0.5em 0; }
265 :global(.rendered-content .empty-section) { color: var(--text-muted); font-style: italic; }
266
267 .section-nav {
268 display: flex;
269 justify-content: space-between;
270 padding: 1em 1.5em;
271 border-top: 1px solid var(--border-color);
272 }
273
274 .nav-btn {
275 background: var(--button-bg);
276 border: 1px solid var(--border-color);
277 border-radius: 6px;
278 padding: 0.4em 1em;
279 color: var(--text-color);
280 font-size: 0.8rem;
281 cursor: pointer;
282 }
283
284 .nav-btn:hover:not(:disabled) {
285 background: var(--button-hover-bg);
286 }
287
288 .nav-btn:disabled {
289 opacity: 0.4;
290 cursor: not-allowed;
291 }
292
293 .reader-empty {
294 padding: 3em 1.5em;
295 text-align: center;
296 color: var(--text-muted);
297 }
298
299 @media (max-width: 640px) {
300 .reader-toc {
301 display: none;
302 }
303
304 .content-body {
305 padding: 1em;
306 }
307 }
308 </style>
309