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>