forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>