MyLibraryView.svelte raw
1 <script>
2 import { onMount } from 'svelte';
3 import { userLibrary, selectedCategory, libraryLoading, openReader } from './libraryStores.js';
4 import { fetchEvents, fetchUserProfile } from './nostr.js';
5
6 export let isLoggedIn = false;
7 export let userPubkey = "";
8 export let userSigner = null;
9
10 let initialized = false;
11
12 $: categories = $userLibrary?.categories || [];
13 $: selectedCatDocs = getDocsForCategory($selectedCategory, $userLibrary);
14
15 function getDocsForCategory(catDtag, lib) {
16 if (!lib?.categories) return [];
17 if (!catDtag) {
18 // Show all documents across all categories
19 return lib.categories.flatMap(c => c.publications || []);
20 }
21 const cat = lib.categories.find(c => c.dtag === catDtag);
22 return cat?.publications || [];
23 }
24
25 onMount(() => {
26 if (isLoggedIn && userPubkey && !initialized) {
27 initialized = true;
28 loadLibrary();
29 }
30 });
31
32 async function loadLibrary() {
33 if ($libraryLoading || !userPubkey) return;
34 libraryLoading.set(true);
35
36 try {
37 // Fetch all kind 30040 (publication indices) by user
38 const indexEvents = await fetchEvents(
39 [{ kinds: [30040], authors: [userPubkey], limit: 100 }],
40 { timeout: 15000, useCache: false }
41 );
42
43 if (!indexEvents || indexEvents.length === 0) {
44 userLibrary.set({ categories: [{ dtag: "uncategorized", title: "Uncategorized", publications: [] }] });
45 return;
46 }
47
48 // Build category tree from index events
49 // Look for a root index (d=library-root) that references category indices
50 const rootIndex = indexEvents.find(e =>
51 (e.tags || []).find(t => t[0] === "d" && t[1] === "library-root")
52 );
53
54 const categories = [];
55 const categorizedDtags = new Set();
56
57 if (rootIndex) {
58 // Extract category references from root
59 const catRefs = (rootIndex.tags || []).filter(t => t[0] === "a");
60 for (const ref of catRefs) {
61 const [, coordStr] = ref;
62 // coordStr format: "30040:pubkey:dtag"
63 const parts = coordStr?.split(":");
64 if (!parts || parts.length < 3) continue;
65 const catDtag = parts.slice(2).join(":");
66
67 const catEvent = indexEvents.find(e =>
68 (e.tags || []).find(t => t[0] === "d" && t[1] === catDtag)
69 );
70
71 if (catEvent) {
72 const title = (catEvent.tags || []).find(t => t[0] === "title")?.[1] || catDtag;
73 const pubRefs = (catEvent.tags || []).filter(t => t[0] === "a");
74 const publications = [];
75
76 for (const pubRef of pubRefs) {
77 const pubParts = pubRef[1]?.split(":");
78 if (!pubParts || pubParts.length < 3) continue;
79 const pubDtag = pubParts.slice(2).join(":");
80 categorizedDtags.add(pubDtag);
81
82 const pubEvent = indexEvents.find(e =>
83 (e.tags || []).find(t => t[0] === "d" && t[1] === pubDtag)
84 );
85 if (pubEvent) {
86 publications.push({
87 dtag: pubDtag,
88 title: (pubEvent.tags || []).find(t => t[0] === "title")?.[1] || pubDtag,
89 event: pubEvent,
90 });
91 }
92 }
93
94 categories.push({ dtag: catDtag, title, publications });
95 categorizedDtags.add(catDtag);
96 }
97 }
98 }
99
100 // Add uncategorized publications
101 const uncategorized = [];
102 for (const ev of indexEvents) {
103 const dtag = (ev.tags || []).find(t => t[0] === "d")?.[1];
104 if (!dtag || dtag === "library-root" || categorizedDtags.has(dtag)) continue;
105 uncategorized.push({
106 dtag,
107 title: (ev.tags || []).find(t => t[0] === "title")?.[1] || dtag,
108 event: ev,
109 });
110 }
111
112 if (uncategorized.length > 0 || categories.length === 0) {
113 categories.push({ dtag: "uncategorized", title: "Uncategorized", publications: uncategorized });
114 }
115
116 userLibrary.set({ categories });
117 } catch (err) {
118 console.error("[Library] Error loading:", err);
119 } finally {
120 libraryLoading.set(false);
121 }
122 }
123
124 async function openPublication(pub) {
125 if (!pub.event) return;
126
127 try {
128 // Fetch sections (kind 30041) referenced by the index
129 const sectionRefs = (pub.event.tags || []).filter(t => t[0] === "a" && t[1]?.startsWith("30041:"));
130 const sections = [];
131
132 if (sectionRefs.length > 0) {
133 // Fetch all 30041 by this author
134 const sectionEvents = await fetchEvents(
135 [{ kinds: [30041], authors: [pub.event.pubkey], limit: 100 }],
136 { timeout: 10000, useCache: false }
137 );
138
139 // Match and order by reference order
140 for (const ref of sectionRefs) {
141 const parts = ref[1]?.split(":");
142 const secDtag = parts?.slice(2).join(":");
143 const secEvent = (sectionEvents || []).find(e =>
144 (e.tags || []).find(t => t[0] === "d" && t[1] === secDtag)
145 );
146 if (secEvent) sections.push(secEvent);
147 }
148 }
149
150 // Also try kind 30023 (long-form article) if no 30041 sections found
151 if (sections.length === 0) {
152 const dtag = (pub.event.tags || []).find(t => t[0] === "d")?.[1];
153 const articles = await fetchEvents(
154 [{ kinds: [30023], authors: [pub.event.pubkey], "#d": [dtag], limit: 1 }],
155 { timeout: 10000, useCache: false }
156 );
157 if (articles?.length > 0) sections.push(articles[0]);
158 }
159
160 openReader(pub.event, sections);
161 } catch (err) {
162 console.error("[Library] Error opening publication:", err);
163 }
164 }
165
166 function selectCategory(dtag) {
167 selectedCategory.set($selectedCategory === dtag ? null : dtag);
168 }
169 </script>
170
171 <div class="my-library">
172 {#if !isLoggedIn}
173 <div class="library-empty">Log in to access your library.</div>
174 {:else if $libraryLoading}
175 <div class="library-loading"><div class="spinner"></div></div>
176 {:else}
177 <!-- Category sidebar -->
178 <div class="category-sidebar">
179 <div class="category-header">Categories</div>
180 <button
181 class="category-item"
182 class:active={!$selectedCategory}
183 on:click={() => selectedCategory.set(null)}
184 >
185 All
186 </button>
187 {#each categories as cat (cat.dtag)}
188 <button
189 class="category-item"
190 class:active={$selectedCategory === cat.dtag}
191 on:click={() => selectCategory(cat.dtag)}
192 >
193 <span class="cat-name">{cat.title}</span>
194 <span class="cat-count">{cat.publications?.length || 0}</span>
195 </button>
196 {/each}
197 </div>
198
199 <!-- Document list -->
200 <div class="document-list">
201 <div class="docs-header">
202 <h3>{$selectedCategory ? categories.find(c => c.dtag === $selectedCategory)?.title || 'Documents' : 'All Documents'}</h3>
203 <span class="docs-count">{selectedCatDocs.length} document{selectedCatDocs.length !== 1 ? 's' : ''}</span>
204 </div>
205
206 {#if selectedCatDocs.length === 0}
207 <div class="library-empty">
208 <p>No publications yet.</p>
209 <p class="hint">Create your first publication from the "New" tab in Library.</p>
210 </div>
211 {:else}
212 {#each selectedCatDocs as doc (doc.dtag)}
213 <!-- svelte-ignore a11y-click-events-have-key-events -->
214 <!-- svelte-ignore a11y-no-static-element-interactions -->
215 <div class="doc-card" on:click={() => openPublication(doc)}>
216 <div class="doc-title">{doc.title}</div>
217 {#if doc.event}
218 <div class="doc-meta">
219 <span class="doc-kind">
220 {doc.event.kind === 30023 ? 'Article' : 'Publication'}
221 </span>
222 <span class="doc-date">
223 {new Date(doc.event.created_at * 1000).toLocaleDateString()}
224 </span>
225 </div>
226 {/if}
227 </div>
228 {/each}
229 {/if}
230 </div>
231 {/if}
232 </div>
233
234 <style>
235 .my-library {
236 display: flex;
237 width: 100%;
238 height: 100%;
239 overflow: hidden;
240 }
241
242 .category-sidebar {
243 width: 200px;
244 flex-shrink: 0;
245 border-right: 1px solid var(--border-color);
246 overflow-y: auto;
247 padding: 0.5em 0;
248 }
249
250 .category-header {
251 padding: 0.5em 0.8em;
252 font-size: 0.75rem;
253 font-weight: 600;
254 color: var(--text-muted);
255 text-transform: uppercase;
256 letter-spacing: 0.5px;
257 }
258
259 .category-item {
260 display: flex;
261 align-items: center;
262 justify-content: space-between;
263 width: 100%;
264 padding: 0.45em 0.8em;
265 background: none;
266 border: none;
267 color: var(--text-color);
268 font-size: 0.83rem;
269 cursor: pointer;
270 text-align: left;
271 transition: background 0.1s;
272 }
273
274 .category-item:hover {
275 background: var(--primary-bg);
276 }
277
278 .category-item.active {
279 background: var(--primary-bg);
280 color: var(--primary);
281 font-weight: 600;
282 }
283
284 .cat-count {
285 font-size: 0.7rem;
286 color: var(--text-muted);
287 }
288
289 .document-list {
290 flex: 1;
291 overflow-y: auto;
292 min-width: 0;
293 }
294
295 .docs-header {
296 display: flex;
297 align-items: center;
298 justify-content: space-between;
299 padding: 0.7em 1em;
300 border-bottom: 1px solid var(--border-color);
301 position: sticky;
302 top: 0;
303 background: var(--bg-color);
304 z-index: 1;
305 }
306
307 .docs-header h3 {
308 margin: 0;
309 font-size: 0.9rem;
310 color: var(--text-color);
311 }
312
313 .docs-count {
314 font-size: 0.75rem;
315 color: var(--text-muted);
316 }
317
318 .doc-card {
319 padding: 0.75em 1em;
320 border-bottom: 1px solid var(--border-color);
321 cursor: pointer;
322 transition: background 0.1s;
323 }
324
325 .doc-card:hover {
326 background: var(--primary-bg);
327 }
328
329 .doc-title {
330 font-size: 0.9rem;
331 font-weight: 600;
332 color: var(--text-color);
333 margin-bottom: 0.25em;
334 }
335
336 .doc-meta {
337 display: flex;
338 gap: 0.75em;
339 font-size: 0.75rem;
340 color: var(--text-muted);
341 }
342
343 .doc-kind {
344 background: var(--primary-bg);
345 padding: 0.1em 0.4em;
346 border-radius: 3px;
347 }
348
349 .library-empty {
350 text-align: center;
351 padding: 3em 1em;
352 color: var(--text-muted);
353 font-size: 0.85rem;
354 }
355
356 .library-empty p {
357 margin: 0 0 0.3em;
358 }
359
360 .hint {
361 font-size: 0.78rem;
362 }
363
364 .library-loading {
365 display: flex;
366 justify-content: center;
367 align-items: center;
368 width: 100%;
369 padding: 3em;
370 }
371
372 .spinner {
373 width: 24px;
374 height: 24px;
375 border: 2px solid var(--border-color);
376 border-top-color: var(--primary);
377 border-radius: 50%;
378 animation: spin 0.8s linear infinite;
379 }
380
381 @keyframes spin {
382 to { transform: rotate(360deg); }
383 }
384
385 @media (max-width: 640px) {
386 .category-sidebar {
387 display: none;
388 }
389 }
390 </style>
391