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, '&amp;')
  30              .replace(/</g, '&lt;')
  31              .replace(/>/g, '&gt;');
  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