mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 09:57:21 +00:00
429 lines
12 KiB
Vue
429 lines
12 KiB
Vue
<script lang="ts" setup>
|
|
interface ManagedAppType {
|
|
id: string
|
|
title: string
|
|
description?: string
|
|
category?: string
|
|
version?: string
|
|
install_count?: number
|
|
}
|
|
|
|
interface Props {
|
|
workspaceId: string
|
|
visible: boolean
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const emit = defineEmits(['update:visible', 'installed'])
|
|
|
|
const visible = useVModel(props, 'visible', emit)
|
|
|
|
const { $api } = useNuxtApp()
|
|
const { t } = useI18n()
|
|
|
|
const managedApps = ref<ManagedAppType[]>([])
|
|
const loading = ref(false)
|
|
const installing = ref<string | null>(null)
|
|
const searchQuery = ref('')
|
|
const selectedCategory = ref<string | undefined>(undefined)
|
|
|
|
const categories = computed(() => {
|
|
const cats = new Set<string>()
|
|
managedApps.value.forEach((ma) => {
|
|
if (ma.category) {
|
|
// Split comma-separated categories
|
|
ma.category.split(',').forEach((cat) => {
|
|
const trimmed = cat.trim()
|
|
if (trimmed) cats.add(trimmed)
|
|
})
|
|
}
|
|
})
|
|
return Array.from(cats).sort()
|
|
})
|
|
|
|
const filteredManagedApps = computed(() => {
|
|
let filtered = managedApps.value
|
|
|
|
if (selectedCategory.value) {
|
|
const selected = selectedCategory.value
|
|
filtered = filtered.filter((ma) => {
|
|
if (!ma.category) return false
|
|
// Check if selected category exists in comma-separated list
|
|
const categories = ma.category.split(',').map((c) => c.trim())
|
|
return categories.includes(selected)
|
|
})
|
|
}
|
|
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase()
|
|
filtered = filtered.filter((ma) => searchCompare([ma.title, ma.description, ma.category], query))
|
|
}
|
|
|
|
return filtered
|
|
})
|
|
|
|
const loadManagedApps = async () => {
|
|
if (!props.workspaceId) {
|
|
console.error('WorkspaceId is required')
|
|
return
|
|
}
|
|
|
|
if (typeof props.workspaceId !== 'string') {
|
|
console.error('WorkspaceId must be a string, got:', typeof props.workspaceId, props.workspaceId)
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
try {
|
|
const response = await $api.internal.getOperation(props.workspaceId, NO_SCOPE, {
|
|
operation: 'managedAppStoreList',
|
|
})
|
|
|
|
managedApps.value = response?.list || []
|
|
} catch (e: any) {
|
|
console.error('API error:', e)
|
|
message.error(await extractSdkResponseErrorMsg(e))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const installManagedApp = async (managedApp: ManagedAppType) => {
|
|
installing.value = managedApp.id
|
|
try {
|
|
await $api.internal.postOperation(
|
|
props.workspaceId,
|
|
NO_SCOPE,
|
|
{
|
|
operation: 'managedAppInstall',
|
|
},
|
|
{
|
|
managedAppId: managedApp.id,
|
|
target_workspace_id: props.workspaceId,
|
|
},
|
|
)
|
|
|
|
message.success(t('msg.success.baseInstalled'))
|
|
emit('installed', managedApp)
|
|
visible.value = false
|
|
} catch (e: any) {
|
|
message.error(await extractSdkResponseErrorMsg(e))
|
|
} finally {
|
|
installing.value = null
|
|
}
|
|
}
|
|
|
|
const formatInstallCount = (count: number | null | undefined): string => {
|
|
const num = count || 0
|
|
if (num >= 1000000) {
|
|
return `${(num / 1000000).toFixed(1)}M`
|
|
}
|
|
if (num >= 1000) {
|
|
return `${(num / 1000).toFixed(1)}k`
|
|
}
|
|
return num.toString()
|
|
}
|
|
|
|
watch(
|
|
() => props.workspaceId,
|
|
(newVal) => {
|
|
if (newVal) {
|
|
loadManagedApps()
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="nc-app-market flex flex-col h-full">
|
|
<!-- Header -->
|
|
<div class="nc-app-market-header">
|
|
<div class="flex items-center gap-3">
|
|
<div class="nc-app-market-icon">
|
|
<GeneralIcon icon="ncBox" class="h-5 w-5" />
|
|
</div>
|
|
<div class="flex-1">
|
|
<div class="text-lg font-semibold text-nc-content-gray-emphasis">{{ t('title.appStore') }}</div>
|
|
<div class="text-xs text-nc-content-gray-subtle2">Discover and install managed applications</div>
|
|
</div>
|
|
|
|
<NcButton size="small" type="text" @click="visible = false">
|
|
<GeneralIcon icon="close" class="text-nc-content-gray-muted h-4 w-4" />
|
|
</NcButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search and Filter Bar -->
|
|
<div class="nc-app-market-filters">
|
|
<div class="flex gap-3">
|
|
<a-input
|
|
v-model:value="searchQuery"
|
|
class="flex-1 nc-input-sm nc-input-shadow !rounded-lg"
|
|
:placeholder="t('placeholder.searchByTitle')"
|
|
allow-clear
|
|
>
|
|
<template #prefix>
|
|
<GeneralIcon icon="search" class="h-4 w-4 text-nc-content-gray-muted" />
|
|
</template>
|
|
</a-input>
|
|
|
|
<NcSelect
|
|
v-model:value="selectedCategory"
|
|
class="xs:max-w-30 md:w-48 nc-select-sm"
|
|
:placeholder="t('labels.category')"
|
|
allow-clear
|
|
>
|
|
<a-select-option v-for="cat in categories" :key="cat" :value="cat" class="items-center">
|
|
{{ cat }}
|
|
</a-select-option>
|
|
</NcSelect>
|
|
</div>
|
|
|
|
<!-- Results count -->
|
|
<div v-if="!loading && filteredManagedApps.length > 0" class="mt-3 text-xs text-nc-content-gray-muted">
|
|
{{ filteredManagedApps.length }} {{ filteredManagedApps.length === 1 ? 'app' : 'apps' }} available
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content Area -->
|
|
<div class="flex-1 overflow-y-auto nc-scrollbar-thin">
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="flex items-center justify-center h-full">
|
|
<div class="flex flex-col items-center gap-3">
|
|
<a-spin size="large" />
|
|
<div class="text-sm text-nc-content-gray-muted">Loading applications...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="filteredManagedApps.length === 0" class="nc-app-market-empty">
|
|
<div class="nc-empty-icon">
|
|
<GeneralIcon icon="ncBox" class="h-10 w-10 text-nc-content-gray-muted" />
|
|
</div>
|
|
<div class="text-base font-semibold text-nc-content-gray mb-2">No applications found</div>
|
|
<div class="text-sm text-nc-content-gray-subtle text-center max-w-md">
|
|
{{
|
|
searchQuery || selectedCategory
|
|
? "Try adjusting your search or filters to find what you're looking for."
|
|
: 'No managed applications are available yet. Be the first to publish one!'
|
|
}}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- App List -->
|
|
<div v-else class="nc-app-market-list">
|
|
<div v-for="managedApp in filteredManagedApps" :key="managedApp.id" class="nc-app-item">
|
|
<div class="nc-app-item-content">
|
|
<!-- App Icon & Info -->
|
|
<div class="nc-app-info">
|
|
<div class="nc-app-icon">
|
|
<GeneralIcon icon="ncBox" />
|
|
</div>
|
|
<div class="nc-app-details">
|
|
<div class="nc-app-title-row">
|
|
<h3 class="nc-app-title">{{ managedApp.title }}</h3>
|
|
<div v-if="managedApp.category" class="nc-app-categories">
|
|
<div
|
|
v-for="cat in managedApp.category
|
|
.split(',')
|
|
.map((c) => c.trim())
|
|
.filter(Boolean)"
|
|
:key="cat"
|
|
class="nc-app-category"
|
|
>
|
|
<GeneralIcon icon="ncHash" class="h-3 w-3" />
|
|
<span>{{ cat }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p
|
|
class="nc-app-description"
|
|
:class="{
|
|
'!text-nc-content-gray-muted': !managedApp.description,
|
|
}"
|
|
>
|
|
{{ managedApp.description || 'No description available' }}
|
|
</p>
|
|
<div class="nc-app-meta-row">
|
|
<div class="nc-app-meta">
|
|
<span class="nc-app-meta-item">
|
|
<GeneralIcon icon="download" class="h-3.5 w-3.5" />
|
|
<span class="font-medium">{{ formatInstallCount(managedApp.install_count || 0) }}</span>
|
|
<span class="text-nc-content-gray-muted">installs</span>
|
|
</span>
|
|
<span v-if="managedApp.version" class="nc-app-meta-item">
|
|
<GeneralIcon icon="gitCommit" class="h-3.5 w-3.5" />
|
|
<span>v{{ managedApp.version }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Install Button (inline on mobile) -->
|
|
<div class="nc-app-action md:hidden">
|
|
<NcButton
|
|
:loading="installing === managedApp.id"
|
|
:disabled="!!installing"
|
|
size="xs"
|
|
type="primary"
|
|
@click="installManagedApp(managedApp)"
|
|
>
|
|
<template #icon>
|
|
<GeneralIcon icon="download" class="h-3.5 w-3.5" />
|
|
</template>
|
|
{{ installing === managedApp.id ? 'Installing...' : t('general.install') }}
|
|
</NcButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Install Button (desktop) -->
|
|
<div class="nc-app-action hidden md:block">
|
|
<NcButton
|
|
:loading="installing === managedApp.id"
|
|
:disabled="!!installing"
|
|
size="small"
|
|
type="primary"
|
|
@click="installManagedApp(managedApp)"
|
|
>
|
|
<template #icon>
|
|
<GeneralIcon icon="download" class="h-4 w-4" />
|
|
</template>
|
|
{{ installing === managedApp.id ? 'Installing...' : t('general.install') }}
|
|
</NcButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.nc-app-market {
|
|
@apply bg-nc-bg-gray-extralight;
|
|
}
|
|
|
|
.nc-app-market-header {
|
|
@apply px-4 md:px-6 py-3 md:py-4 bg-nc-bg-default border-b-1 border-nc-border-gray-light;
|
|
}
|
|
|
|
.nc-app-market-icon {
|
|
@apply w-10 h-10 rounded-xl flex items-center justify-center text-white shadow-sm;
|
|
background: linear-gradient(135deg, var(--nc-content-brand) 0%, var(--nc-content-blue-medium) 100%);
|
|
box-shadow: 0 2px 4px rgba(51, 102, 255, 0.15);
|
|
}
|
|
|
|
.nc-app-market-filters {
|
|
@apply px-4 md:px-6 py-4 bg-nc-bg-default border-b-1 border-nc-border-gray-light;
|
|
}
|
|
|
|
.nc-app-market-empty {
|
|
@apply flex flex-col items-center justify-center h-full p-8;
|
|
}
|
|
|
|
.nc-empty-icon {
|
|
@apply w-20 h-20 rounded-full bg-nc-bg-gray-light flex items-center justify-center mb-4;
|
|
}
|
|
|
|
.nc-app-market-list {
|
|
@apply p-4 md:p-6;
|
|
}
|
|
|
|
.nc-app-item {
|
|
@apply bg-nc-bg-default border-1 border-nc-border-gray-medium rounded-xl mb-3 relative overflow-hidden;
|
|
@apply transition-all duration-200 ease-in-out;
|
|
|
|
&::before {
|
|
@apply absolute left-0 top-0 bottom-0 w-1 bg-nc-content-brand opacity-0;
|
|
@apply transition-opacity duration-200 ease-in-out;
|
|
content: '';
|
|
}
|
|
|
|
&:hover {
|
|
@apply border-nc-border-brand transform translate-x-0.5;
|
|
box-shadow: 0 4px 12px rgba(51, 102, 255, 0.08);
|
|
|
|
&::before {
|
|
@apply opacity-100;
|
|
}
|
|
|
|
.nc-app-icon {
|
|
@apply transform scale-105;
|
|
box-shadow: 0 4px 8px rgba(51, 102, 255, 0.15);
|
|
}
|
|
}
|
|
|
|
&:last-child {
|
|
@apply mb-0;
|
|
}
|
|
}
|
|
|
|
.nc-app-item-content {
|
|
@apply flex items-center gap-4 px-4 py-3;
|
|
}
|
|
|
|
.nc-app-info {
|
|
@apply flex gap-3 flex-1 min-w-0;
|
|
}
|
|
|
|
.nc-app-icon {
|
|
@apply w-10 h-10 rounded-lg border-1 border-nc-border-gray-light flex items-center justify-center flex-shrink-0 text-nc-content-brand;
|
|
@apply transition-all duration-200 ease-in-out;
|
|
background: linear-gradient(135deg, var(--nc-bg-brand) 0%, var(--nc-bg-blue-light) 100%);
|
|
|
|
:deep(svg) {
|
|
@apply w-5 h-5;
|
|
}
|
|
}
|
|
|
|
.nc-app-details {
|
|
@apply flex-1 min-w-0;
|
|
}
|
|
|
|
.nc-app-title-row {
|
|
@apply flex items-center gap-2 mb-1.5;
|
|
}
|
|
|
|
.nc-app-title {
|
|
@apply text-base font-semibold text-nc-content-gray-emphasis m-0 truncate flex-shrink-0;
|
|
}
|
|
|
|
.nc-app-categories {
|
|
@apply flex items-center gap-2 flex-wrap;
|
|
}
|
|
|
|
.nc-app-category {
|
|
@apply inline-flex items-center gap-1 px-2.5 py-0.5 bg-nc-bg-gray-light border-1 border-nc-border-gray-light;
|
|
@apply rounded-full text-xs text-nc-content-gray-subtle whitespace-nowrap flex-shrink-0;
|
|
}
|
|
|
|
.nc-app-description {
|
|
@apply text-sm text-nc-content-gray-subtle m-0 mb-2 leading-normal line-clamp-2;
|
|
}
|
|
|
|
.nc-app-meta-row {
|
|
@apply flex items-center justify-between gap-3;
|
|
}
|
|
|
|
.nc-app-meta {
|
|
@apply flex items-center gap-4 text-xs text-nc-content-gray-subtle2;
|
|
}
|
|
|
|
.nc-app-meta-item {
|
|
@apply flex items-center gap-1.5;
|
|
}
|
|
|
|
.nc-app-action {
|
|
@apply flex-shrink-0;
|
|
}
|
|
|
|
// Responsive adjustments
|
|
@media (max-width: 819px) {
|
|
.nc-app-title-row {
|
|
@apply flex-wrap;
|
|
}
|
|
}
|
|
</style>
|