import * as plugins from '../plugins.js'; import * as shared from './shared/index.js'; import * as appstate from '../appstate.js'; import * as interfaces from '../../ts_interfaces/index.js'; import { appRouter } from '../router.js'; import { DeesElement, customElement, html, state, css, cssManager, type TemplateResult, } from '@design.estate/dees-element'; @customElement('ob-view-appstore') export class ObViewAppStore extends DeesElement { @state() accessor appStoreState: appstate.IAppStoreState = { apps: [], upgradeableServices: [], }; @state() accessor currentView: 'grid' | 'detail' = 'grid'; @state() accessor selectedApp: interfaces.requests.ICatalogApp | null = null; @state() accessor selectedAppMeta: interfaces.requests.IAppMeta | null = null; @state() accessor selectedAppConfig: interfaces.requests.IAppVersionConfig | null = null; @state() accessor selectedVersion: string = ''; @state() accessor editableEnvVars: Array<{ key: string; value: string; description: string; required?: boolean; platformInjected?: boolean }> = []; @state() accessor serviceName: string = ''; @state() accessor loading: boolean = false; @state() accessor deployMode: boolean = false; public static styles = [ cssManager.defaultStyles, shared.viewHostCss, css` .detail-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 8px; padding: 24px; margin-bottom: 16px; } .detail-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 24px; } .detail-icon { width: 64px; height: 64px; border-radius: 12px; background: var(--ci-shade-2, #27272a); display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 700; color: var(--ci-shade-5, #a1a1aa); flex-shrink: 0; } .detail-title { font-size: 24px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); margin: 0 0 4px 0; } .detail-category { display: inline-block; padding: 2px 10px; border-radius: 9999px; font-size: 12px; font-weight: 500; background: var(--ci-shade-2, #27272a); color: var(--ci-shade-5, #a1a1aa); margin-bottom: 8px; } .detail-description { font-size: 14px; color: var(--ci-shade-5, #a1a1aa); line-height: 1.6; margin: 0; } .detail-meta { display: flex; gap: 16px; margin-top: 8px; font-size: 13px; color: var(--ci-shade-4, #71717a); } .detail-meta a { color: var(--ci-shade-5, #a1a1aa); text-decoration: none; } .detail-meta a:hover { text-decoration: underline; } .section-label { font-size: 13px; font-weight: 600; color: var(--ci-shade-5, #a1a1aa); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; } .badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; background: rgba(59, 130, 246, 0.15); color: #60a5fa; margin-right: 6px; margin-bottom: 6px; } .version-row { display: flex; align-items: center; gap: 16px; } .version-select { background: var(--ci-shade-2, #27272a); border: 1px solid var(--ci-shade-3, #3f3f46); border-radius: 6px; padding: 8px 12px; color: var(--ci-shade-7, #e4e4e7); font-size: 14px; cursor: pointer; } .image-tag { font-family: monospace; font-size: 13px; color: var(--ci-shade-5, #a1a1aa); background: var(--ci-shade-2, #27272a); padding: 4px 8px; border-radius: 4px; } .env-table { width: 100%; border-collapse: collapse; } .env-table th { text-align: left; font-size: 12px; font-weight: 500; color: var(--ci-shade-4, #71717a); padding: 8px 8px 8px 0; border-bottom: 1px solid var(--ci-shade-2, #27272a); } .env-table td { padding: 6px 8px 6px 0; vertical-align: middle; } .env-input { width: 100%; background: var(--ci-shade-2, #27272a); border: 1px solid var(--ci-shade-3, #3f3f46); border-radius: 4px; padding: 6px 8px; color: var(--ci-shade-7, #e4e4e7); font-size: 13px; font-family: monospace; box-sizing: border-box; } .env-input:disabled { opacity: 0.5; cursor: not-allowed; } .env-key { font-family: monospace; font-size: 13px; color: var(--ci-shade-6, #d4d4d8); white-space: nowrap; } .env-desc { font-size: 12px; color: var(--ci-shade-4, #71717a); } .env-badge { font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-left: 6px; } .env-badge.required { background: rgba(239, 68, 68, 0.15); color: #f87171; } .env-badge.auto { background: rgba(34, 197, 94, 0.15); color: #4ade80; } .name-input { background: var(--ci-shade-2, #27272a); border: 1px solid var(--ci-shade-3, #3f3f46); border-radius: 6px; padding: 10px 14px; color: var(--ci-shade-7, #e4e4e7); font-size: 14px; width: 300px; box-sizing: border-box; } .actions-row { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; } .btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 200ms ease; } .btn:hover { opacity: 0.9; } .btn-primary { background: var(--ci-shade-7, #e4e4e7); color: var(--ci-shade-0, #09090b); } .btn-secondary { background: transparent; border: 1px solid var(--ci-shade-2, #27272a); color: var(--ci-shade-6, #d4d4d8); } .loading-spinner { padding: 32px; text-align: center; color: var(--ci-shade-4, #71717a); } `, ]; constructor() { super(); const sub = appstate.appStoreStatePart .select((s) => s) .subscribe((newState) => { this.appStoreState = newState; }); this.rxSubscriptions.push(sub); } async connectedCallback() { super.connectedCallback(); await appstate.appStoreStatePart.dispatchAction(appstate.fetchAppTemplatesAction, null); } public render(): TemplateResult { switch (this.currentView) { case 'detail': return this.renderDetailView(); default: return this.renderGridView(); } } private renderGridView(): TemplateResult { const appTemplates = this.appStoreState.apps.map((app) => ({ id: app.id, name: app.name, description: app.description, category: app.category, iconName: app.iconName, iconUrl: app.iconUrl, image: '', port: 0, })); return html` App Store ${appTemplates.length === 0 ? html`
Loading app templates...
` : html` this.handleViewDetails(e)} @deploy-app=${(e: CustomEvent) => this.handleAppClick(e)} > `} `; } private renderDetailView(): TemplateResult { if (this.loading) { return html` App Store
Loading app details...
`; } const app = this.selectedApp; const meta = this.selectedAppMeta; const config = this.selectedAppConfig; if (!app || !config) { return html` App Store
App not found.
`; } const platformReqs = config.platformRequirements || {}; const hasPlatformReqs = Object.values(platformReqs).some(Boolean); const platformLabels: Record = { mongodb: 'MongoDB', s3: 'S3 (MinIO)', clickhouse: 'ClickHouse', redis: 'Redis', mariadb: 'MariaDB', }; return html` App Store
${(app.name || '?')[0].toUpperCase()}

${app.name}

${app.category}

${app.description}

${meta?.maintainer ? html`Maintainer: ${meta.maintainer}` : ''} ${meta?.links ? Object.entries(meta.links).map(([label, url]) => html`${label}` ) : ''} ${app.tags?.length ? html`Tags: ${app.tags.join(', ')}` : ''}
${hasPlatformReqs ? html`
${Object.entries(platformReqs) .filter(([_, enabled]) => enabled) .map(([key]) => html`${platformLabels[key] || key}`)}
These platform services will be automatically provisioned when you deploy.
` : ''}
${config.image} ${config.minOneboxVersion ? html`Requires onebox ≥ ${config.minOneboxVersion}` : ''}
${this.editableEnvVars.length > 0 ? html`
${this.editableEnvVars.map((ev, index) => html` `)}
Variable Value Description
${ev.key} ${ev.required ? html`required` : ''} ${ev.platformInjected ? html`auto` : ''} this.handleEnvVarChange(index, (e.target as HTMLInputElement).value)} /> ${ev.description || ''}
` : ''} ${this.deployMode ? html`
{ this.serviceName = (e.target as HTMLInputElement).value; }} />
Lowercase letters, numbers, and hyphens only.
` : html`
`} `; } private async handleViewDetails(e: CustomEvent) { const app = e.detail?.app; if (!app) return; const catalogApp = this.appStoreState.apps.find((a) => a.id === app.id); if (!catalogApp) return; this.deployMode = false; this.selectedApp = catalogApp; this.selectedVersion = catalogApp.latestVersion; this.serviceName = catalogApp.id; this.loading = true; this.currentView = 'detail'; await this.fetchVersionConfig(catalogApp.id, catalogApp.latestVersion); this.loading = false; } private async handleAppClick(e: CustomEvent) { const app = e.detail?.app; if (!app) return; const catalogApp = this.appStoreState.apps.find((a) => a.id === app.id); if (!catalogApp) return; this.deployMode = true; this.selectedApp = catalogApp; this.selectedVersion = catalogApp.latestVersion; this.serviceName = catalogApp.id; this.loading = true; this.currentView = 'detail'; await this.fetchVersionConfig(catalogApp.id, catalogApp.latestVersion); this.loading = false; } private async handleVersionChange(version: string) { if (!this.selectedApp || version === this.selectedVersion) return; this.selectedVersion = version; this.loading = true; await this.fetchVersionConfig(this.selectedApp.id, version); this.loading = false; } private async fetchVersionConfig(appId: string, version: string) { try { const identity = appstate.loginStatePart.getState().identity; if (!identity) return; const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_GetAppConfig >('/typedrequest', 'getAppConfig'); const response = await typedRequest.fire({ identity, appId, version }); this.selectedAppMeta = response.appMeta; this.selectedAppConfig = response.config; // Build editable env vars this.editableEnvVars = (response.config.envVars || []).map((ev) => ({ key: ev.key, value: ev.value || '', description: ev.description || '', required: ev.required, platformInjected: ev.value?.includes('${') || false, })); } catch (err) { console.error('Failed to fetch app config:', err); } } private handleEnvVarChange(index: number, value: string) { const updated = [...this.editableEnvVars]; updated[index] = { ...updated[index], value }; this.editableEnvVars = updated; } private async handleDeploy() { const app = this.selectedApp; const config = this.selectedAppConfig; if (!app || !config) return; const envVars: Record = {}; for (const ev of this.editableEnvVars) { if (ev.key && ev.value && !ev.platformInjected) { envVars[ev.key] = ev.value; } } const platformReqs = config.platformRequirements || {}; const serviceConfig: interfaces.data.IServiceCreate = { name: this.serviceName || app.id, image: config.image, port: config.port || 80, envVars, enableMongoDB: platformReqs.mongodb || false, enableS3: platformReqs.s3 || false, enableClickHouse: platformReqs.clickhouse || false, enableRedis: platformReqs.redis || false, enableMariaDB: platformReqs.mariadb || false, appTemplateId: app.id, appTemplateVersion: this.selectedVersion, }; try { await appstate.servicesStatePart.dispatchAction(appstate.createServiceAction, { config: serviceConfig, }); setTimeout(() => { appRouter.navigateToView('services'); }, 0); } catch (err) { console.error('Failed to deploy from App Store:', err); } } }