ImportBookmarksModal.svelte
1 <script lang="ts"> 2 import type { BookmarkFolder, DetectedStructure, ImportMode } from '../../lib/bookmarks/types'; 3 import type { ParentWorkspace } from '../../lib/types'; 4 import { Modal, Button } from '../common'; 5 6 interface FolderWithMeta extends BookmarkFolder { 7 structure: DetectedStructure; 8 recommendedMode: ImportMode; 9 } 10 11 interface Props { 12 open: boolean; 13 parentWorkspaces: ParentWorkspace[]; 14 onclose: () => void; 15 onImported: () => void; 16 } 17 18 let { open = $bindable(), parentWorkspaces, onclose, onImported }: Props = $props(); 19 20 // State 21 let loading = $state(false); 22 let error = $state<string | null>(null); 23 let folders = $state<FolderWithMeta[]>([]); 24 let selectedFolder = $state<FolderWithMeta | null>(null); 25 let importMode = $state<ImportMode>('auto'); 26 let selectedParentId = $state<string>(''); 27 let importing = $state(false); 28 29 // Load bookmark folders when modal opens 30 $effect(() => { 31 if (open) { 32 loadFolders(); 33 } else { 34 // Reset state when closed 35 error = null; 36 selectedFolder = null; 37 importMode = 'auto'; 38 selectedParentId = ''; 39 } 40 }); 41 42 async function loadFolders() { 43 loading = true; 44 error = null; 45 46 try { 47 const response = await browser.runtime.sendMessage({ type: 'GET_BOOKMARK_FOLDERS' }); 48 if (response.success) { 49 folders = response.folders; 50 } else { 51 error = response.error || 'Failed to load bookmark folders'; 52 } 53 } catch (e) { 54 error = e instanceof Error ? e.message : 'Failed to load bookmarks'; 55 } finally { 56 loading = false; 57 } 58 } 59 60 function selectFolder(folder: FolderWithMeta) { 61 selectedFolder = folder; 62 importMode = 'auto'; 63 selectedParentId = ''; 64 } 65 66 function getEffectiveMode(): 'standalone' | 'context' { 67 if (importMode === 'auto') { 68 return selectedFolder?.recommendedMode === 'context' ? 'context' : 'standalone'; 69 } 70 return importMode; 71 } 72 73 async function handleImport() { 74 if (!selectedFolder) return; 75 76 importing = true; 77 error = null; 78 79 try { 80 // Clone selectedFolder to avoid Firefox "Proxy object could not be cloned" error 81 const folderData = JSON.parse(JSON.stringify(selectedFolder)); 82 const response = await browser.runtime.sendMessage({ 83 type: 'IMPORT_FROM_BOOKMARKS', 84 payload: { 85 folderId: selectedFolder.id, 86 folderData, 87 mode: importMode, 88 parentId: selectedParentId || undefined 89 } 90 }); 91 92 if (response.success) { 93 onImported(); 94 onclose(); 95 } else { 96 error = response.error || 'Failed to import bookmarks'; 97 } 98 } catch (e) { 99 error = e instanceof Error ? e.message : 'Failed to import bookmarks'; 100 } finally { 101 importing = false; 102 } 103 } 104 105 function formatBookmarkCount(count: number): string { 106 return `${count} bookmark${count !== 1 ? 's' : ''}`; 107 } 108 </script> 109 110 <Modal bind:open title="Import from Bookmarks" size="lg"> 111 <div class="space-y-4"> 112 {#if loading} 113 <div class="flex items-center justify-center py-8"> 114 <div class="flex items-center gap-3 text-text-muted"> 115 <svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"> 116 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> 117 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> 118 </svg> 119 <span>Loading bookmark folders...</span> 120 </div> 121 </div> 122 {:else if error} 123 <div class="bg-status-error/10 border border-status-error/30 rounded-lg p-4 text-sm text-status-error"> 124 {error} 125 </div> 126 {:else if folders.length === 0} 127 <div class="text-center py-8 text-text-muted"> 128 <svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 129 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /> 130 </svg> 131 <p class="mb-2">No Mnemonic bookmarks found</p> 132 <p class="text-xs">Export a workspace to bookmarks first, then import it here.</p> 133 </div> 134 {:else} 135 <!-- Folder list --> 136 <div class="space-y-2"> 137 <p class="text-sm text-text-muted">Select a folder from your Mnemonic bookmarks:</p> 138 <div class="max-h-48 overflow-y-auto border border-phosphor-dark/20 rounded-lg divide-y divide-phosphor-dark/10"> 139 {#each folders as folder (folder.id)} 140 <button 141 class="w-full p-3 text-left hover:bg-phosphor/10 transition-colors flex items-center gap-3 {selectedFolder?.id === folder.id ? 'bg-phosphor/20' : ''}" 142 onclick={() => selectFolder(folder)} 143 > 144 <svg class="w-5 h-5 text-phosphor flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"> 145 <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /> 146 </svg> 147 <div class="flex-1 min-w-0"> 148 <div class="font-medium truncate">{folder.title}</div> 149 <div class="text-xs text-text-muted flex items-center gap-2"> 150 <span>{formatBookmarkCount(folder.structure.bookmarkCount)}</span> 151 {#if folder.structure.subfolderCount > 0} 152 <span class="text-phosphor-dark">|</span> 153 <span>{folder.structure.subfolderCount} subfolder{folder.structure.subfolderCount !== 1 ? 's' : ''}</span> 154 {/if} 155 </div> 156 </div> 157 <span class="px-2 py-0.5 text-xs rounded-full {folder.structure.type === 'nested' ? 'bg-phosphor/20 text-phosphor' : 'bg-surface-raised text-text-muted'}"> 158 {folder.structure.type === 'nested' ? 'Context' : 'Flat'} 159 </span> 160 </button> 161 {/each} 162 </div> 163 </div> 164 165 <!-- Import options (shown when folder selected) --> 166 {#if selectedFolder} 167 <div class="border-t border-phosphor-dark/20 pt-4 space-y-4"> 168 <!-- Structure preview --> 169 <div class="bg-mnemonic-bg-dark/50 rounded-lg p-3"> 170 <div class="text-sm font-medium mb-2">Structure Preview</div> 171 <div class="text-xs text-text-muted space-y-1"> 172 {#if selectedFolder.structure.type === 'nested'} 173 <div class="flex items-center gap-2"> 174 <svg class="w-4 h-4 text-phosphor" fill="currentColor" viewBox="0 0 20 20"> 175 <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /> 176 </svg> 177 <span class="font-medium">{selectedFolder.title}</span> 178 <span class="text-text-muted">(Parent)</span> 179 </div> 180 {#each selectedFolder.structure.subfolders as subfolder} 181 <div class="flex items-center gap-2 ml-6"> 182 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 183 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 184 </svg> 185 <span>{subfolder.name}</span> 186 <span class="text-text-muted">({subfolder.bookmarkCount} tabs)</span> 187 </div> 188 {/each} 189 {:else} 190 <div class="flex items-center gap-2"> 191 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 192 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd" /> 193 </svg> 194 <span class="font-medium">{selectedFolder.title}</span> 195 <span>({selectedFolder.structure.bookmarkCount} tabs)</span> 196 </div> 197 {/if} 198 </div> 199 </div> 200 201 <!-- Import mode --> 202 <div class="space-y-2"> 203 <label class="text-sm font-medium">Import as:</label> 204 <div class="grid grid-cols-3 gap-2"> 205 <button 206 class="p-2 rounded border text-sm transition-colors text-center {importMode === 'auto' ? 'border-phosphor bg-phosphor/10' : 'border-phosphor-dark/30'}" 207 onclick={() => importMode = 'auto'} 208 > 209 <div class="font-medium">Auto</div> 210 <div class="text-xs text-text-muted"> 211 {selectedFolder.recommendedMode === 'context' ? 'Context' : 'Standalone'} 212 </div> 213 </button> 214 <button 215 class="p-2 rounded border text-sm transition-colors text-center {importMode === 'standalone' ? 'border-phosphor bg-phosphor/10' : 'border-phosphor-dark/30'}" 216 onclick={() => importMode = 'standalone'} 217 > 218 <div class="font-medium">Standalone</div> 219 <div class="text-xs text-text-muted">Single workspace</div> 220 </button> 221 <button 222 class="p-2 rounded border text-sm transition-colors text-center {importMode === 'context' ? 'border-phosphor bg-phosphor/10' : 'border-phosphor-dark/30'}" 223 onclick={() => importMode = 'context'} 224 > 225 <div class="font-medium">Context</div> 226 <div class="text-xs text-text-muted">Parent + children</div> 227 </button> 228 </div> 229 </div> 230 231 <!-- Optional: Import as child of existing parent --> 232 {#if getEffectiveMode() === 'standalone' && parentWorkspaces.length > 0} 233 <div class="space-y-2"> 234 <label class="text-sm font-medium flex items-center gap-2"> 235 <span>Add to existing context</span> 236 <span class="text-xs text-text-muted">(optional)</span> 237 </label> 238 <select 239 bind:value={selectedParentId} 240 class="w-full px-3 py-2 rounded bg-mnemonic-bg border border-phosphor-dark/30 text-sm focus:border-phosphor focus:outline-none" 241 > 242 <option value="">Create as standalone</option> 243 {#each parentWorkspaces as parent (parent.id)} 244 <option value={parent.id}>Add to "{parent.name}"</option> 245 {/each} 246 </select> 247 </div> 248 {/if} 249 </div> 250 {/if} 251 {/if} 252 </div> 253 254 {#snippet footer()} 255 <Button variant="ghost" onclick={onclose} disabled={importing}> 256 Cancel 257 </Button> 258 <Button 259 variant="primary" 260 onclick={handleImport} 261 disabled={!selectedFolder || importing || loading} 262 > 263 {#if importing} 264 <svg class="w-4 h-4 mr-2 animate-spin" fill="none" viewBox="0 0 24 24"> 265 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> 266 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> 267 </svg> 268 Importing... 269 {:else} 270 Import 271 {/if} 272 </Button> 273 {/snippet} 274 </Modal>