[READ-ONLY] a fast, modern browser for the npm registry
at main 395 lines 13 kB view raw
1<script setup lang="ts"> 2import { checkPackageName } from '~/utils/package-name' 3 4const props = defineProps<{ 5 packageName: string 6}>() 7 8const { 9 isConnected, 10 state, 11 npmUser, 12 addOperation, 13 approveOperation, 14 executeOperations, 15 refreshState, 16} = useConnector() 17 18const isPublishing = shallowRef(false) 19const publishSuccess = shallowRef(false) 20const publishError = shallowRef<string | null>(null) 21 22const { 23 data: checkResult, 24 refresh: checkAvailability, 25 status, 26 error: checkError, 27} = useAsyncData( 28 (_nuxtApp, { signal }) => { 29 return checkPackageName(props.packageName, { signal }) 30 }, 31 { default: () => null, immediate: false }, 32) 33 34const isChecking = computed(() => { 35 return status.value === 'pending' 36}) 37 38const mergedError = computed(() => { 39 return checkResult.value !== null 40 ? null 41 : (publishError.value ?? 42 (checkError.value instanceof Error 43 ? checkError.value.message 44 : $t('claim.modal.failed_to_check'))) 45}) 46 47const connectorModal = useModal('connector-modal') 48 49async function handleClaim() { 50 if (!checkResult.value?.available || !isConnected.value) return 51 52 isPublishing.value = true 53 publishError.value = null 54 55 try { 56 // Add the operation 57 const operation = await addOperation({ 58 type: 'package:init', 59 params: { name: props.packageName, ...(npmUser.value && { author: npmUser.value }) }, 60 description: `Initialize package ${props.packageName}`, 61 command: `npm publish (${props.packageName}@0.0.0)`, 62 }) 63 64 if (!operation) { 65 throw new Error('Failed to create operation') 66 } 67 68 // Auto-approve and execute 69 await approveOperation(operation.id) 70 await executeOperations() 71 72 // Refresh state and check if operation completed successfully 73 await refreshState() 74 75 // Find the operation and check its status 76 const completedOp = state.value.operations.find(op => op.id === operation.id) 77 if (completedOp?.status === 'completed') { 78 publishSuccess.value = true 79 } else if (completedOp?.status === 'failed') { 80 if (completedOp.result?.requiresOtp) { 81 // OTP is needed - open connector panel to handle it 82 close() 83 connectorModal.open() 84 } else { 85 publishError.value = completedOp.result?.stderr || 'Failed to publish package' 86 } 87 } else { 88 // Still pending/approved/running - open connector panel to show progress 89 close() 90 connectorModal.open() 91 } 92 } catch (err) { 93 publishError.value = err instanceof Error ? err.message : $t('claim.modal.failed_to_claim') 94 } finally { 95 isPublishing.value = false 96 } 97} 98 99const dialogRef = useTemplateRef('dialogRef') 100 101function open() { 102 // Reset state and check availability each time modal is opened 103 publishError.value = null 104 publishSuccess.value = false 105 checkAvailability() 106 dialogRef.value?.showModal() 107} 108 109function close() { 110 dialogRef.value?.close() 111} 112 113defineExpose({ open, close }) 114 115// Computed for similar packages with warnings 116const hasDangerousSimilarPackages = computed(() => { 117 if (!checkResult.value?.similarPackages) return false 118 return checkResult.value.similarPackages.some( 119 pkg => pkg.similarity === 'exact-match' || pkg.similarity === 'very-similar', 120 ) 121}) 122 123const isScoped = computed(() => props.packageName.startsWith('@')) 124 125// Preview of the package.json that will be published 126const previewPackageJson = computed(() => { 127 const access = isScoped.value ? 'public' : undefined 128 return { 129 name: props.packageName, 130 version: '0.0.0', 131 description: `Placeholder for ${props.packageName}`, 132 main: 'index.js', 133 scripts: {}, 134 keywords: [], 135 author: npmUser.value ? `${npmUser.value} (https://www.npmjs.com/~${npmUser.value})` : '', 136 license: 'UNLICENSED', 137 private: false, 138 ...(access && { publishConfig: { access } }), 139 } 140}) 141</script> 142 143<template> 144 <!-- Modal --> 145 <Modal 146 ref="dialogRef" 147 :modalTitle="$t('claim.modal.title')" 148 id="claim-package-modal" 149 class="max-w-md" 150 > 151 <!-- Loading state --> 152 <div v-if="isChecking" class="py-8 text-center"> 153 <LoadingSpinner :text="$t('claim.modal.checking')" /> 154 </div> 155 156 <!-- Success state --> 157 <div v-else-if="publishSuccess" class="space-y-4"> 158 <div 159 class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg" 160 > 161 <span class="i-lucide:check text-green-500 w-6 h-6" aria-hidden="true" /> 162 <div> 163 <p class="font-mono text-sm text-fg">{{ $t('claim.modal.success') }}</p> 164 <p class="text-xs text-fg-muted"> 165 {{ $t('claim.modal.success_detail', { name: packageName }) }} 166 </p> 167 </div> 168 </div> 169 170 <p class="text-sm text-fg-muted"> 171 {{ $t('claim.modal.success_hint') }} 172 </p> 173 174 <div class="flex gap-3"> 175 <NuxtLink 176 :to="packageRoute(packageName)" 177 class="flex-1 px-4 py-2 font-mono text-sm text-center text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-accent/70" 178 @click="close" 179 > 180 {{ $t('claim.modal.view_package') }} 181 </NuxtLink> 182 <button 183 type="button" 184 class="flex-1 px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70" 185 @click="close" 186 > 187 {{ $t('common.close') }} 188 </button> 189 </div> 190 </div> 191 192 <!-- Check result --> 193 <div v-else-if="checkResult" class="space-y-4"> 194 <!-- Package name display --> 195 <div class="p-4 bg-bg-subtle border border-border rounded-lg"> 196 <p class="font-mono text-lg text-fg">{{ checkResult.name }}</p> 197 </div> 198 199 <!-- Validation errors --> 200 <div 201 v-if="checkResult.validationErrors?.length" 202 class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 203 role="alert" 204 > 205 <p class="font-medium mb-1">{{ $t('claim.modal.invalid_name') }}</p> 206 <ul class="list-disc list-inside space-y-1"> 207 <li v-for="err in checkResult.validationErrors" :key="err">{{ err }}</li> 208 </ul> 209 </div> 210 211 <!-- Validation warnings --> 212 <div 213 v-if="checkResult.validationWarnings?.length" 214 class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 215 role="alert" 216 > 217 <p class="font-medium mb-1">{{ $t('common.warnings') }}</p> 218 <ul class="list-disc list-inside space-y-1"> 219 <li v-for="warn in checkResult.validationWarnings" :key="warn">{{ warn }}</li> 220 </ul> 221 </div> 222 223 <!-- Availability status --> 224 <div v-if="checkResult.valid"> 225 <div 226 v-if="checkResult.available" 227 class="flex items-center gap-3 p-4 bg-green-500/10 border border-green-500/20 rounded-lg" 228 > 229 <span class="i-lucide:check text-green-500 w-5 h-5" aria-hidden="true" /> 230 <p class="font-mono text-sm text-fg">{{ $t('claim.modal.available') }}</p> 231 </div> 232 233 <div 234 v-else 235 class="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/20 rounded-lg" 236 > 237 <span class="i-lucide:x text-red-500 w-5 h-5" aria-hidden="true" /> 238 <p class="font-mono text-sm text-fg">{{ $t('claim.modal.taken') }}</p> 239 </div> 240 </div> 241 242 <!-- Similar packages warning --> 243 <div v-if="checkResult.similarPackages?.length && checkResult.available"> 244 <div 245 :class=" 246 hasDangerousSimilarPackages 247 ? 'bg-yellow-500/10 border-yellow-500/20' 248 : 'bg-bg-subtle border-border' 249 " 250 class="p-4 border rounded-lg" 251 > 252 <p 253 :class="hasDangerousSimilarPackages ? 'text-yellow-400' : 'text-fg-muted'" 254 class="text-sm font-medium mb-3" 255 > 256 <span v-if="hasDangerousSimilarPackages"> 257 {{ $t('claim.modal.similar_warning') }} 258 </span> 259 <span v-else>{{ $t('claim.modal.related') }}</span> 260 </p> 261 <ul class="space-y-2"> 262 <li 263 v-for="pkg in checkResult.similarPackages.slice(0, 5)" 264 :key="pkg.name" 265 class="flex items-start gap-2" 266 > 267 <span 268 v-if="pkg.similarity === 'exact-match'" 269 class="i-lucide:circle-alert text-red-500 w-4 h-4 mt-0.5 shrink-0" 270 aria-hidden="true" 271 /> 272 <span 273 v-else-if="pkg.similarity === 'very-similar'" 274 class="i-lucide:circle-alert text-yellow-500 w-4 h-4 mt-0.5 shrink-0" 275 aria-hidden="true" 276 /> 277 <span v-else class="w-4 h-4 shrink-0" /> 278 <div class="min-w-0"> 279 <NuxtLink 280 :to="packageRoute(pkg.name)" 281 class="font-mono text-sm text-fg hover:underline focus-visible:outline-accent/70 rounded" 282 target="_blank" 283 > 284 {{ pkg.name }} 285 </NuxtLink> 286 <p v-if="pkg.description" class="text-xs text-fg-subtle truncate"> 287 {{ pkg.description }} 288 </p> 289 </div> 290 </li> 291 </ul> 292 </div> 293 </div> 294 295 <!-- Error message --> 296 <div 297 v-if="mergedError" 298 role="alert" 299 class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 300 > 301 {{ mergedError }} 302 </div> 303 304 <!-- Actions --> 305 <div v-if="checkResult.available && checkResult.valid" class="space-y-3"> 306 <!-- Warning for unscoped packages --> 307 <div 308 v-if="!isScoped" 309 class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 310 > 311 <p class="font-medium mb-1">{{ $t('claim.modal.scope_warning_title') }}</p> 312 <p class="text-xs text-yellow-400/80"> 313 {{ 314 $t('claim.modal.scope_warning_text', { 315 username: npmUser || 'username', 316 name: packageName, 317 }) 318 }} 319 </p> 320 </div> 321 322 <!-- Not connected warning --> 323 <div v-if="!isConnected" class="space-y-3"> 324 <div 325 class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md" 326 > 327 <p>{{ $t('claim.modal.connect_required') }}</p> 328 </div> 329 <button 330 type="button" 331 class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-accent/70" 332 @click="connectorModal.open" 333 > 334 {{ $t('claim.modal.connect_button') }} 335 </button> 336 </div> 337 338 <!-- Claim button --> 339 <div v-else class="space-y-3"> 340 <p class="text-sm text-fg-muted"> 341 {{ $t('claim.modal.publish_hint') }} 342 </p> 343 344 <!-- Expandable package.json preview --> 345 <details class="border border-border rounded-md overflow-hidden"> 346 <summary 347 class="px-3 py-2 text-sm text-fg-muted bg-bg-subtle hover:text-fg transition-colors select-none" 348 > 349 {{ $t('claim.modal.preview_json') }} 350 </summary> 351 <pre class="p-3 text-xs font-mono text-fg-muted bg-bg-muted overflow-x-auto">{{ 352 JSON.stringify(previewPackageJson, null, 2) 353 }}</pre> 354 </details> 355 356 <button 357 type="button" 358 :disabled="isPublishing" 359 class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70" 360 @click="handleClaim" 361 > 362 {{ isPublishing ? $t('claim.modal.publishing') : $t('claim.modal.claim_button') }} 363 </button> 364 </div> 365 </div> 366 367 <!-- Close button for unavailable/invalid --> 368 <button 369 v-if="!checkResult.available || !checkResult.valid" 370 type="button" 371 class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70" 372 @click="close" 373 > 374 {{ $t('common.close') }} 375 </button> 376 </div> 377 378 <!-- Error state --> 379 <div v-else-if="mergedError" class="space-y-4"> 380 <div 381 role="alert" 382 class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md" 383 > 384 {{ mergedError }} 385 </div> 386 <button 387 type="button" 388 class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70" 389 @click="() => checkAvailability()" 390 > 391 {{ $t('common.retry') }} 392 </button> 393 </div> 394 </Modal> 395</template>