/ src / components / resources / ResourcesCard.svelte
ResourcesCard.svelte
  1  <script lang="ts">
  2    import ResourceItem from './ResourceItem.svelte';
  3    import AddResourceModal from './AddResourceModal.svelte';
  4    import { getLogseqClient } from '../../lib/logseq';
  5    import {
  6      createResource,
  7      isValidHttpUrl,
  8      extractTitleFromMarkdown,
  9      type WorkspaceResource,
 10    } from '../../lib/types';
 11    import { getAttachmentManager, MAX_ATTACHMENT_SIZE, formatFileSize } from '../../lib/attachments';
 12  
 13    interface Props {
 14      resources: WorkspaceResource[];
 15      workspaceId: string;
 16      graphName: string | null;
 17      logseqConnected: boolean;
 18      onAddResources: (resources: WorkspaceResource[]) => void;
 19      onRemoveResource: (resourceId: string) => void;
 20      onTabDrop?: (tabData: { tab: { url: string; title: string; pinned?: boolean }; tabIndex: number; sourceWorkspaceId: string }) => void;
 21      highlightResourceId?: string | null;
 22    }
 23  
 24    let { resources, workspaceId, graphName, logseqConnected, onAddResources, onRemoveResource, onTabDrop, highlightResourceId = null }: Props = $props();
 25  
 26    const attachmentManager = getAttachmentManager();
 27  
 28    let modalOpen = $state(false);
 29    let isDragOver = $state(false);
 30  
 31    const client = getLogseqClient();
 32  
 33    function handleDragOver(e: DragEvent) {
 34      e.preventDefault();
 35      // Use 'move' when dragging a saved tab, 'copy' otherwise
 36      const hasJson = e.dataTransfer?.types?.includes('application/json');
 37      e.dataTransfer!.dropEffect = hasJson ? 'move' : 'copy';
 38      isDragOver = true;
 39    }
 40  
 41    function handleDragLeave(e: DragEvent) {
 42      // Only set isDragOver to false if leaving the container entirely
 43      const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
 44      if (
 45        e.clientX < rect.left ||
 46        e.clientX >= rect.right ||
 47        e.clientY < rect.top ||
 48        e.clientY >= rect.bottom
 49      ) {
 50        isDragOver = false;
 51      }
 52    }
 53  
 54    async function handleDrop(e: DragEvent) {
 55      e.preventDefault();
 56      isDragOver = false;
 57  
 58      // Check for saved tab drag (from the saved tabs list)
 59      const jsonData = e.dataTransfer?.getData('application/json');
 60      if (jsonData) {
 61        try {
 62          const dragData = JSON.parse(jsonData);
 63          if (dragData.type === 'tab' && dragData.tab && onTabDrop) {
 64            onTabDrop({
 65              tab: dragData.tab,
 66              tabIndex: dragData.tabIndex,
 67              sourceWorkspaceId: dragData.sourceWorkspaceId,
 68            });
 69            return;
 70          }
 71        } catch {
 72          // Not valid JSON, fall through to other handlers
 73        }
 74      }
 75  
 76      // Check for files first
 77      if (e.dataTransfer?.files?.length) {
 78        for (const file of e.dataTransfer.files) {
 79          if (file.name.endsWith('.md') || file.name.endsWith('.markdown')) {
 80            // Markdown files get special handling - create Logseq page
 81            await handleMarkdownFileDrop(file);
 82          } else {
 83            // All other files become attachments
 84            await handleFileDrop(file);
 85          }
 86        }
 87        return;
 88      }
 89  
 90      // Check for URLs
 91      const urlData =
 92        e.dataTransfer?.getData('text/uri-list') ||
 93        e.dataTransfer?.getData('text/plain');
 94  
 95      if (urlData && isValidHttpUrl(urlData.trim())) {
 96        await handleUrlDrop(urlData.trim());
 97      }
 98    }
 99  
100    async function handleFileDrop(file: File) {
101      // Validate file size
102      if (file.size > MAX_ATTACHMENT_SIZE) {
103        alert(`File too large. Maximum size is 25MB. Your file is ${formatFileSize(file.size)}.`);
104        return;
105      }
106  
107      try {
108        const resource = await attachmentManager.addAttachment(file, workspaceId);
109        onAddResources([resource]);
110        console.log('[Resources] Added file attachment:', file.name);
111      } catch (error) {
112        console.error('[Resources] Failed to add file attachment:', error);
113        alert(error instanceof Error ? error.message : 'Failed to add file attachment.');
114      }
115    }
116  
117    async function handleMarkdownFileDrop(file: File) {
118      if (!logseqConnected || !graphName) {
119        alert('Logseq must be connected to upload markdown files.');
120        return;
121      }
122  
123      try {
124        const content = await file.text();
125        const title = extractTitleFromMarkdown(content) || file.name.replace(/\.md$|\.markdown$/i, '');
126  
127        // Create page in Logseq
128        await client.getOrCreatePage(title);
129  
130        // Add content to the page
131        await client.appendBlockToPage(title, content);
132  
133        // Create resource linking to the new page
134        const resource = createResource('logseq', title, title);
135        onAddResources([resource]);
136  
137        console.log('[Resources] Created Logseq page from markdown:', title);
138      } catch (error) {
139        console.error('[Resources] Failed to create Logseq page:', error);
140        alert('Failed to create Logseq page from markdown file.');
141      }
142    }
143  
144    async function handleUrlDrop(url: string) {
145      try {
146        // Try to get a title from the URL
147        let title: string;
148        try {
149          const urlObj = new URL(url);
150          title = urlObj.hostname + urlObj.pathname;
151          if (title.length > 50) {
152            title = urlObj.hostname;
153          }
154        } catch {
155          title = url;
156        }
157  
158        const resource = createResource('url', title, url);
159        onAddResources([resource]);
160  
161        console.log('[Resources] Added URL resource:', url);
162      } catch (error) {
163        console.error('[Resources] Failed to add URL:', error);
164      }
165    }
166  </script>
167  
168  <div class="panel resources-panel" class:drag-over={isDragOver}>
169    <div class="flex items-center justify-between mb-4">
170      <h3 class="text-lg text-phosphor">Resources ({resources.length})</h3>
171      <button
172        class="btn-secondary text-sm px-3 py-1"
173        onclick={() => (modalOpen = true)}
174      >
175        + Add
176      </button>
177    </div>
178  
179    <!-- Drop zone -->
180    <div
181      class="drop-zone"
182      class:active={isDragOver}
183      ondragover={handleDragOver}
184      ondragleave={handleDragLeave}
185      ondrop={handleDrop}
186      role="region"
187      aria-label="Drop files or URLs here to add as resources"
188    >
189      {#if resources.length > 0}
190        <div class="resources-list">
191          {#each resources as resource (resource.id)}
192            <ResourceItem
193              {resource}
194              {graphName}
195              {logseqConnected}
196              onRemove={onRemoveResource}
197              highlight={highlightResourceId === resource.id}
198            />
199          {/each}
200        </div>
201      {:else}
202        <div class="empty-state">
203          <p class="text-text-muted text-sm">No resources yet</p>
204          <p class="text-text-muted text-xs mt-1">
205            Drag URLs, files, or markdown here, or click "Add" to search
206          </p>
207        </div>
208      {/if}
209  
210      {#if isDragOver}
211        <div class="drop-overlay">
212          <div class="drop-message">
213            <svg class="w-8 h-8 text-phosphor mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
214              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
215            </svg>
216            <span class="text-phosphor font-medium">Drop to add resource</span>
217          </div>
218        </div>
219      {/if}
220    </div>
221  </div>
222  
223  <AddResourceModal
224    open={modalOpen}
225    {logseqConnected}
226    onClose={() => (modalOpen = false)}
227    onAdd={onAddResources}
228  />
229  
230  <style>
231    .resources-panel {
232      position: relative;
233    }
234  
235    .resources-panel.drag-over {
236      border-color: var(--color-phosphor);
237    }
238  
239    .drop-zone {
240      position: relative;
241    }
242  
243    .drop-zone.active {
244      opacity: 0.7;
245    }
246  
247    .resources-list {
248      display: flex;
249      flex-direction: column;
250      gap: 0.25rem;
251    }
252  
253    .empty-state {
254      display: flex;
255      flex-direction: column;
256      align-items: center;
257      justify-content: center;
258      padding: 2rem;
259      text-align: center;
260    }
261  
262    .drop-overlay {
263      position: absolute;
264      inset: 0;
265      display: flex;
266      align-items: center;
267      justify-content: center;
268      background-color: rgba(217, 137, 46, 0.1);
269      border: 2px dashed var(--color-phosphor);
270      border-radius: 0.375rem;
271    }
272  
273    .drop-message {
274      display: flex;
275      flex-direction: column;
276      align-items: center;
277      text-align: center;
278    }
279  
280    .btn-secondary {
281      background-color: var(--color-surface);
282      border: 1px solid rgba(217, 137, 46, 0.3);
283      border-radius: 0.375rem;
284      color: var(--color-text);
285      transition: all 0.15s;
286    }
287  
288    .btn-secondary:hover {
289      border-color: var(--color-phosphor);
290      color: var(--color-phosphor);
291    }
292  </style>