Skip to content

Commit 4c1e4f9

Browse files
authored
feat(web): add URL parameter support for auto-opening plugin install modal (#8)
* feat(web): add URL parameter support for auto-opening plugin install modal Enable direct linking to specific plugins with automatic install modal display. Users can now share URLs like ?plugin=nanobanana or ?install=grafana to show a specific plugin's installation instructions. Features: - URL query parameter detection (?plugin= or ?install=) - Auto-scroll to target plugin card - Auto-open install modal with smooth animation - 500ms delay for natural UX after scroll completion Changes: - apps/web/app/pages/index.vue: Add route query parameter handling and auto-scroll logic - apps/web/app/components/PluginCard.vue: Add autoOpenModal prop and watcher * refactor(web): improve error handling and add VueUse integration Apply critical fixes from PR review to enhance reliability and user experience. Changes: - Add VueUse useTimeoutFn import for proper timer management - Fix Badge color type safety with proper union types - Implement retry logic with up to 5 attempts (200ms intervals) - Add user feedback toast for persistent navigation failures - Store and cleanup timer references to prevent memory leaks - Improve network error handling with detailed logging - Separate HTTP errors from JSON parsing errors - Remove noisy error logs during normal loading Error Handling Improvements: - Silent retries during ref loading (no error spam) - Only log errors on final failure after max retries - Add onBeforeUnmount cleanup for timer references - Guard async callbacks with component lifecycle checks Type Safety: - Badge.color now properly typed as Nuxt UI color union - Prevents TypeScript compilation errors Fixes: - Memory leaks from uncleaned timers - Silent failures with no user feedback - Noisy error logs during normal page load - Race conditions during component unmount
1 parent 147e728 commit 4c1e4f9

2 files changed

Lines changed: 284 additions & 55 deletions

File tree

apps/web/app/components/PluginCard.vue

Lines changed: 126 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -40,46 +40,17 @@
4040
</UBadge>
4141
</div>
4242
</div>
43-
<div v-if="hasContext || hasMcpServer || authorName" class="flex items-center gap-2">
43+
<div v-if="badges.length > 0" class="flex items-center gap-2">
4444
<UBadge
45-
v-if="authorName === 'Google'"
45+
v-for="badge in badges"
46+
:key="badge.key"
4647
variant="soft"
47-
color="warning"
48+
:color="badge.color"
4849
size="sm"
49-
title="Developed by Google"
50+
:title="badge.title"
5051
>
51-
<UIcon name="i-simple-icons-google" class="mr-1" />
52-
Google
53-
</UBadge>
54-
<UBadge
55-
v-if="authorName === 'Anthropic'"
56-
variant="soft"
57-
color="error"
58-
size="sm"
59-
title="Developed by Anthropic"
60-
>
61-
<UIcon name="i-simple-icons-anthropic" class="mr-1" />
62-
Anthropic
63-
</UBadge>
64-
<UBadge
65-
v-if="hasContext"
66-
variant="soft"
67-
color="info"
68-
size="sm"
69-
title="Includes Context File"
70-
>
71-
<UIcon name="i-heroicons-document-text" class="mr-1" />
72-
Context
73-
</UBadge>
74-
<UBadge
75-
v-if="hasMcpServer"
76-
variant="soft"
77-
color="primary"
78-
size="sm"
79-
title="Includes MCP Server"
80-
>
81-
<UIcon name="i-heroicons-server" class="mr-1" />
82-
MCP
52+
<UIcon :name="badge.icon" class="mr-1" />
53+
{{ badge.label }}
8354
</UBadge>
8455
</div>
8556
</div>
@@ -140,6 +111,8 @@
140111
</template>
141112

142113
<script setup lang="ts">
114+
import { useTimeoutFn } from '@vueuse/core'
115+
143116
interface PluginSource {
144117
source: 'github'
145118
repo: string
@@ -163,9 +136,12 @@ interface PluginMetadata {
163136
[key: string]: any
164137
}
165138
166-
const props = defineProps<{
139+
const props = withDefaults(defineProps<{
167140
plugin: Plugin
168-
}>()
141+
autoOpenModal?: boolean
142+
}>(), {
143+
autoOpenModal: false
144+
})
169145
170146
const isModalOpen = ref(false)
171147
const pluginMetadata = ref<PluginMetadata | null>(null)
@@ -183,11 +159,42 @@ const fetchPluginMetadata = async () => {
183159
const url = `https://raw.githubusercontent.com/${props.plugin.source.repo}/main/.claude-plugin/plugin.json`
184160
const response = await fetch(url)
185161
186-
if (response.ok) {
162+
if (!response.ok) {
163+
// Log specific HTTP error
164+
console.error(`Failed to fetch plugin metadata: HTTP ${response.status}`, {
165+
plugin: props.plugin.name,
166+
repo: props.plugin.source.repo,
167+
status: response.status,
168+
statusText: response.statusText
169+
})
170+
171+
// Handle specific error cases
172+
if (response.status === 404) {
173+
console.warn(`Plugin metadata not found for ${props.plugin.name}`)
174+
// 404 is common for plugins without metadata - don't show toast
175+
} else if (response.status === 403) {
176+
console.error(`Access denied to plugin metadata for ${props.plugin.name}`)
177+
}
178+
return
179+
}
180+
181+
// Parse JSON with error handling
182+
try {
187183
pluginMetadata.value = await response.json()
184+
} catch (parseErr) {
185+
console.error('Failed to parse plugin metadata JSON:', {
186+
plugin: props.plugin.name,
187+
error: parseErr instanceof Error ? parseErr.message : String(parseErr)
188+
})
189+
// Don't show toast - this is a plugin configuration issue
188190
}
189191
} catch (err) {
190-
console.error('Failed to fetch plugin metadata:', err)
192+
// Network-level errors (connection failed, CORS, etc.)
193+
console.error('Network error fetching plugin metadata:', {
194+
plugin: props.plugin.name,
195+
repo: props.plugin.source.repo,
196+
error: err instanceof Error ? err.message : String(err)
197+
})
191198
} finally {
192199
loading.value = false
193200
}
@@ -203,17 +210,16 @@ const displayDescription = computed(() => {
203210
return props.plugin.description || pluginMetadata.value?.description || 'No description available'
204211
})
205212
206-
// Computed author - prefer marketplace.json, fallback to metadata
207-
const displayAuthor = computed(() => {
208-
const author = props.plugin.author || pluginMetadata.value?.author
209-
// Handle both string and object formats
213+
// Extract author name from either marketplace.json or fetched metadata
214+
// Handles both string and object formats
215+
function getAuthorName(author: string | { name?: string } | undefined): string | undefined {
210216
return typeof author === 'string' ? author : author?.name
211-
})
217+
}
212218
213-
// Get author name for badge display
214-
const authorName = computed(() => {
219+
// Computed author - prefer marketplace.json, fallback to metadata
220+
const displayAuthor = computed(() => {
215221
const author = props.plugin.author || pluginMetadata.value?.author
216-
return typeof author === 'string' ? author : author?.name
222+
return getAuthorName(author)
217223
})
218224
219225
// Computed license - from fetched metadata
@@ -231,6 +237,61 @@ const hasContext = computed(() => {
231237
return !!pluginMetadata.value?.contextFileName
232238
})
233239
240+
// Consolidated badges configuration
241+
interface Badge {
242+
key: string
243+
icon: string
244+
label: string
245+
color: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
246+
title: string
247+
}
248+
249+
const badges = computed<Badge[]>(() => {
250+
const badgeList: Badge[] = []
251+
252+
// Author badges
253+
if (displayAuthor.value === 'Google') {
254+
badgeList.push({
255+
key: 'google',
256+
icon: 'i-simple-icons-google',
257+
label: 'Google',
258+
color: 'warning',
259+
title: 'Developed by Google'
260+
})
261+
} else if (displayAuthor.value === 'Anthropic') {
262+
badgeList.push({
263+
key: 'anthropic',
264+
icon: 'i-simple-icons-anthropic',
265+
label: 'Anthropic',
266+
color: 'error',
267+
title: 'Developed by Anthropic'
268+
})
269+
}
270+
271+
// Feature badges
272+
if (hasContext.value) {
273+
badgeList.push({
274+
key: 'context',
275+
icon: 'i-heroicons-document-text',
276+
label: 'Context',
277+
color: 'info',
278+
title: 'Includes Context File'
279+
})
280+
}
281+
282+
if (hasMcpServer.value) {
283+
badgeList.push({
284+
key: 'mcp',
285+
icon: 'i-heroicons-server',
286+
label: 'MCP',
287+
color: 'primary',
288+
title: 'Includes MCP Server'
289+
})
290+
}
291+
292+
return badgeList
293+
})
294+
234295
// Fetch metadata on mount
235296
onMounted(() => {
236297
fetchPluginMetadata()
@@ -239,4 +300,21 @@ onMounted(() => {
239300
const openInstallModal = () => {
240301
isModalOpen.value = true
241302
}
303+
304+
// Auto-open modal when autoOpenModal prop is true with automatic cleanup
305+
const { start: startModalTimer, stop: stopModalTimer } = useTimeoutFn(() => {
306+
isModalOpen.value = true
307+
}, 500)
308+
309+
watch(() => props.autoOpenModal, (shouldOpen) => {
310+
// Stop any existing timer
311+
stopModalTimer()
312+
313+
if (shouldOpen) {
314+
// Start timer to open modal after scroll completes
315+
startModalTimer()
316+
}
317+
}, { immediate: true })
318+
319+
// VueUse automatically cleans up on unmount, no need for manual cleanup!
242320
</script>

0 commit comments

Comments
 (0)