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