feat: shared components for worlds + p2p instances (#5135)
* feat: base content card component * fix: tooltips + colors * feat: fix orgs * feat: add ContentModpackCard * fix: extract types * feat: selection v-model * add show icon in selected for combobox with stories * feat: add project combobox * clean up project combobox * feat: start install to play modal * fix: events * feat: figma alignments * feat: migrate toggle to tailwind * fix: row borders * feat: disabled state * feat: virtual list impl for card table based on window scroll * fix: lint * feat: virtualization + smaller contentcard items * feat: fix gap + border issues on last elm * fix: use TeleportOverflowMenu * fix: hasUpdate type * fix: fallback to svg if src is invalid on avatar component * fix: storybook * feat: start on updater modal * feat: finish content updater modal * feat: i18n pass * remove install to play modal from ui package * pnpm prepr * feat: reusable table component * feat: add column width prop for table and fix stories * feat: add table overflow menu story example * feat: add surface-1.5 and use in table * chore: export table in index * fix: allow more loose typing on columns * feat: update table component to derive key from column instead of data * feat: surface 1.5 for oled + refactor story for contentcardtable + yeet sorting funcs * fix: lint * feat: add no padding story for new modal --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
183
packages/ui/src/components/project/ProjectCombobox.vue
Normal file
183
packages/ui/src/components/project/ProjectCombobox.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<Combobox
|
||||
v-model="projectId"
|
||||
:placeholder="placeholder"
|
||||
:options="options"
|
||||
:searchable="true"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:no-options-message="searchLoading ? loadingMessage : noResultsMessage"
|
||||
:disable-search-filter="true"
|
||||
:disabled="disabled"
|
||||
show-icon-in-selected
|
||||
@search-input="(query) => handleSearch(query)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { defineAsyncComponent, h, ref, watch } from 'vue'
|
||||
|
||||
import { injectModrinthClient, injectNotificationManager } from '../../providers'
|
||||
import type { ComboboxOption } from '../base/Combobox.vue'
|
||||
import Combobox from '../base/Combobox.vue'
|
||||
|
||||
export type ProjectType =
|
||||
| 'mod'
|
||||
| 'modpack'
|
||||
| 'resourcepack'
|
||||
| 'shader'
|
||||
| 'datapack'
|
||||
| 'plugin'
|
||||
| 'server'
|
||||
|
||||
interface SearchHit {
|
||||
project_id: string
|
||||
title: string
|
||||
icon_url?: string
|
||||
project_type: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Filter by project types */
|
||||
projectTypes?: ProjectType[]
|
||||
/** Placeholder text for the combobox */
|
||||
placeholder?: string
|
||||
/** Placeholder text for the search input */
|
||||
searchPlaceholder?: string
|
||||
/** Message shown when loading */
|
||||
loadingMessage?: string
|
||||
/** Message shown when no results found */
|
||||
noResultsMessage?: string
|
||||
/** Whether the combobox is disabled */
|
||||
disabled?: boolean
|
||||
/** Maximum number of results to show */
|
||||
limit?: number
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'Select project',
|
||||
searchPlaceholder: 'Search by name or paste ID...',
|
||||
loadingMessage: 'Loading...',
|
||||
noResultsMessage: 'No results found',
|
||||
disabled: false,
|
||||
limit: 20,
|
||||
},
|
||||
)
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const projectId = defineModel<string>()
|
||||
|
||||
const searchLoading = ref(false)
|
||||
const options = ref<ComboboxOption<string>[]>([])
|
||||
const selectedProject = ref<SearchHit | null>(null)
|
||||
const searchResultsCache = ref<Map<string, SearchHit>>(new Map())
|
||||
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
// Watch for external changes to projectId to update selectedProject
|
||||
watch(
|
||||
projectId,
|
||||
async (newId) => {
|
||||
if (!newId) {
|
||||
selectedProject.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (searchResultsCache.value.has(newId)) {
|
||||
selectedProject.value = searchResultsCache.value.get(newId) || null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await labrinth.projects_v2.get(newId)
|
||||
if (project) {
|
||||
const hit: SearchHit = {
|
||||
project_id: project.id,
|
||||
title: project.title,
|
||||
icon_url: project.icon_url ?? undefined,
|
||||
project_type: project.project_type,
|
||||
slug: project.slug,
|
||||
}
|
||||
searchResultsCache.value.set(project.id, hit)
|
||||
selectedProject.value = hit
|
||||
}
|
||||
} catch {
|
||||
selectedProject.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const search = async (query: string) => {
|
||||
query = query.trim()
|
||||
if (!query) {
|
||||
searchLoading.value = false
|
||||
options.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const projectTypeFacets = props.projectTypes?.map((type) => `project_type:${type}`)
|
||||
|
||||
const results = await labrinth.projects_v2.search({
|
||||
query: query,
|
||||
limit: props.limit,
|
||||
facets: projectTypeFacets ? [projectTypeFacets] : undefined,
|
||||
})
|
||||
|
||||
const resultsByProjectId = await labrinth.projects_v2.search({
|
||||
query: '',
|
||||
limit: props.limit,
|
||||
facets: [[`project_id:${query.replace(/[^a-zA-Z0-9]/g, '')}`]],
|
||||
})
|
||||
|
||||
const allHits = [...resultsByProjectId.hits, ...results.hits]
|
||||
const seenIds = new Set<string>()
|
||||
const uniqueHits: SearchHit[] = []
|
||||
|
||||
for (const hit of allHits) {
|
||||
if (!seenIds.has(hit.project_id)) {
|
||||
seenIds.add(hit.project_id)
|
||||
uniqueHits.push(hit)
|
||||
// Cache the hit for later lookup
|
||||
searchResultsCache.value.set(hit.project_id, hit)
|
||||
}
|
||||
}
|
||||
|
||||
options.value = uniqueHits.map((hit) => ({
|
||||
label: hit.title,
|
||||
value: hit.project_id,
|
||||
icon: defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: hit.icon_url,
|
||||
alt: hit.title,
|
||||
class: 'h-5 w-5 rounded',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
const err = error as { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : String(error),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
searchLoading.value = false
|
||||
}
|
||||
|
||||
const throttledSearch = useDebounceFn(search, 250)
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
searchLoading.value = true
|
||||
await throttledSearch(query)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
selectedProject,
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user