import { useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createTemplate, deleteTemplate, deployTemplate, getCurrentUserProfile, getTemplateById, listProjects, listTemplates, updateTemplate, type TemplateDetailEntity, type TemplateEntity, type TemplateWriteInput, } from '@/lib/api-client'; import { useAuthSession } from '@/lib/use-auth-session'; import { Search, Filter, Check, Loader2, Sparkles, Box, Code, Database, Globe, Layers, Terminal, Play, ArrowRight, Star, Plus, Pencil, Trash2, Upload, X, } from 'lucide-react'; const demoTemplates: TemplateEntity[] = [ { id: 'tpl-react', name: 'React Application', description: 'Single-page frontend with Vite and static serving runtime.', category: 'frontend', logo: 'https://cdn.simpleicons.org/react', configRaw: '{"runtime":"node"}', variablesRaw: '[]', isOfficial: true, ownerId: null, }, { id: 'tpl-go', name: 'Go API Service', description: 'API-ready Go runtime with direct binary startup.', category: 'web', logo: 'https://cdn.simpleicons.org/go', configRaw: '{"runtime":"go"}', variablesRaw: '[]', isOfficial: true, ownerId: null, }, { id: 'tpl-postgres', name: 'PostgreSQL Database', description: 'Managed PostgreSQL service with credential setup variables.', category: 'database', logo: 'https://cdn.simpleicons.org/postgresql', configRaw: '{"runtime":"postgres"}', variablesRaw: '[]', isOfficial: true, ownerId: null, }, { id: 'tpl-mysql', name: 'MySQL Database', description: 'Managed MySQL service for transactional workloads.', category: 'database', logo: 'https://cdn.simpleicons.org/mysql', configRaw: '{"runtime":"mysql"}', variablesRaw: '[]', isOfficial: true, ownerId: null, }, { id: 'tpl-mariadb', name: 'MariaDB Database', description: 'Managed MariaDB service with MySQL compatibility.', category: 'database', logo: 'https://cdn.simpleicons.org/mariadb', configRaw: '{"runtime":"mariadb"}', variablesRaw: '[]', isOfficial: true, ownerId: null, }, { id: 'tpl-clickhouse', name: 'ClickHouse Database', description: 'Columnar analytics database template for high-speed queries.', category: 'database', logo: 'https://cdn.simpleicons.org/clickhouse', configRaw: '{"runtime":"clickhouse"}', variablesRaw: '[]', isOfficial: true, ownerId: null, }, { id: 'tpl-dragonfly', name: 'Dragonfly Database', description: 'Redis-compatible in-memory store powered by Dragonfly.', category: 'database', logo: 'https://cdn.simpleicons.org/redis', configRaw: '{"runtime":"dragonfly"}', variablesRaw: '[]', isOfficial: true, ownerId: null, }, ]; const demoTemplateDetails: Record = { 'tpl-react': { template: demoTemplates[0], config: { type: 'web', runtime: 'node', buildCommand: 'npm install && npm run build', startCommand: 'npx serve -s dist', port: 3000, healthCheck: '/health', environment: {}, nixpacksConfig: {}, }, variables: [ { key: 'VITE_API_URL', label: 'API URL', defaultValue: 'https://api.example.com', required: true, secret: false, description: 'Public API endpoint for frontend calls', }, ], }, 'tpl-go': { template: demoTemplates[1], config: { type: 'web', runtime: 'go', buildCommand: 'go build -o app .', startCommand: './app', port: 8080, healthCheck: '/health', environment: {}, nixpacksConfig: {}, }, variables: [ { key: 'GO_ENV', label: 'Go Environment', defaultValue: 'production', required: false, secret: false, description: 'Runtime environment value', }, ], }, 'tpl-postgres': { template: demoTemplates[2], config: { type: 'database', runtime: 'postgres', buildCommand: '', startCommand: '', port: 5432, healthCheck: '', environment: {}, nixpacksConfig: {}, }, variables: [ { key: 'POSTGRES_USER', label: 'Username', defaultValue: 'postgres', required: true, secret: false, description: 'Database user', }, { key: 'POSTGRES_PASSWORD', label: 'Password', defaultValue: '', required: true, secret: true, description: 'Database password', }, ], }, 'tpl-mysql': { template: demoTemplates[3], config: { type: 'database', runtime: 'mysql', buildCommand: '', startCommand: '', port: 3306, healthCheck: '', environment: {}, nixpacksConfig: {}, }, variables: [ { key: 'MYSQL_DATABASE', label: 'Database Name', defaultValue: 'app', required: true, secret: false, description: 'Initial database to create', }, { key: 'MYSQL_USER', label: 'Username', defaultValue: 'app', required: true, secret: false, description: 'Application DB user', }, { key: 'MYSQL_PASSWORD', label: 'User Password', defaultValue: '', required: true, secret: true, description: 'Application DB password', }, { key: 'MYSQL_ROOT_PASSWORD', label: 'Root Password', defaultValue: '', required: true, secret: true, description: 'Root account password', }, ], }, 'tpl-mariadb': { template: demoTemplates[4], config: { type: 'database', runtime: 'mariadb', buildCommand: '', startCommand: '', port: 3306, healthCheck: '', environment: {}, nixpacksConfig: {}, }, variables: [ { key: 'MARIADB_DATABASE', label: 'Database Name', defaultValue: 'app', required: true, secret: false, description: 'Initial database to create', }, { key: 'MARIADB_USER', label: 'Username', defaultValue: 'app', required: true, secret: false, description: 'Application DB user', }, { key: 'MARIADB_PASSWORD', label: 'User Password', defaultValue: '', required: true, secret: true, description: 'Application DB password', }, { key: 'MARIADB_ROOT_PASSWORD', label: 'Root Password', defaultValue: '', required: true, secret: true, description: 'Root account password', }, ], }, 'tpl-clickhouse': { template: demoTemplates[5], config: { type: 'database', runtime: 'clickhouse', buildCommand: '', startCommand: '', port: 8123, healthCheck: '', environment: {}, nixpacksConfig: {}, }, variables: [ { key: 'CLICKHOUSE_DB', label: 'Database Name', defaultValue: 'app', required: false, secret: false, description: 'Default database name', }, { key: 'CLICKHOUSE_USER', label: 'Username', defaultValue: 'default', required: false, secret: false, description: 'ClickHouse user', }, { key: 'CLICKHOUSE_PASSWORD', label: 'Password', defaultValue: '', required: false, secret: true, description: 'ClickHouse password', }, ], }, 'tpl-dragonfly': { template: demoTemplates[6], config: { type: 'database', runtime: 'dragonfly', buildCommand: '', startCommand: '', port: 6379, healthCheck: '', environment: {}, nixpacksConfig: {}, }, variables: [ { key: 'DRAGONFLY_PASSWORD', label: 'Password', defaultValue: '', required: false, secret: true, description: 'Optional Redis-compatible password', }, ], }, }; const demoProjects = [ { id: 'project-demo', name: 'Demo Project' }, { id: 'project-internal', name: 'Internal Tooling' }, ]; function toServiceName(value: string): string { const normalized = value .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return normalized || 'service'; } function categoryLabel(category: string): string { return category ? category[0].toUpperCase() + category.slice(1) : 'Uncategorized'; } function safeTemplateRuntime(configRaw: string): string { try { const parsed = JSON.parse(configRaw || '{}') as { runtime?: string }; return parsed.runtime || 'n/a'; } catch { return 'n/a'; } } function categoryIcon(category: string): typeof Box { switch (category) { case 'frontend': return Globe; case 'web': return Code; case 'database': return Database; case 'backend': return Terminal; default: return Box; } } const EMPTY_TEMPLATE_JSON = JSON.stringify( { name: 'my-template', description: 'What this template deploys.', category: 'app', logo: '', config: { type: 'web', runtime: 'node', build_command: '', start_command: '', port: 3000, health_check: '/health', environment: {}, }, variables: [ { key: 'EXAMPLE_VAR', label: 'Example variable', default: '', required: false, secret: false, description: 'Shown at deploy time', }, ], }, null, 2, ); export function TemplatesPage() { const queryClient = useQueryClient(); const [searchParams] = useSearchParams(); const isDemoMode = searchParams.get('demo') === '1'; const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const signedIn = isDemoMode || Boolean(sessionQuery.data); const profileQuery = useQuery({ queryKey: ['current-profile'], queryFn: getCurrentUserProfile, enabled: !isDemoMode && Boolean(sessionQuery.data), retry: false, }); const myUserId = profileQuery.data?.id ?? ''; const isAdmin = Boolean(profileQuery.data?.isAdmin); const [editorOpen, setEditorOpen] = useState(false); const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON); const [editingTemplate, setEditingTemplate] = useState(null); const [editorError, setEditorError] = useState(null); const [categoryFilter, setCategoryFilter] = useState(''); const [searchFilter, setSearchFilter] = useState(''); const [selectedTemplateIdState, setSelectedTemplateId] = useState(null); const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? ''); const [deployNameByTemplate, setDeployNameByTemplate] = useState>({}); const [variableValuesByTemplate, setVariableValuesByTemplate] = useState< Record> >({}); const [lastDeployment, setLastDeployment] = useState<{ projectId: string; serviceId: string; serviceName: string; } | null>(null); const templatesQuery = useQuery({ queryKey: ['templates-page', categoryFilter], enabled: !isDemoMode, queryFn: () => listTemplates({ category: categoryFilter || undefined }), }); const projectsQuery = useQuery({ queryKey: ['template-projects'], enabled: !isDemoMode, queryFn: () => listProjects(), }); const templates = useMemo( () => (isDemoMode ? demoTemplates : templatesQuery.data ?? []), [isDemoMode, templatesQuery.data], ); const filteredTemplates = useMemo(() => { const query = searchFilter.trim().toLowerCase(); if (!query) { return templates; } return templates.filter( (template) => template.name.toLowerCase().includes(query) || template.description.toLowerCase().includes(query) || template.category.toLowerCase().includes(query), ); }, [searchFilter, templates]); const categoryOptions = useMemo(() => { const values = new Set(); for (const template of templates) { if (template.category) { values.add(template.category); } } return Array.from(values).sort((left, right) => left.localeCompare(right)); }, [templates]); const selectedTemplateId = useMemo(() => { if (filteredTemplates.length === 0) { return null; } if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) { return selectedTemplateIdState; } return filteredTemplates[0].id; }, [filteredTemplates, selectedTemplateIdState]); const templateDetailQuery = useQuery({ queryKey: ['template-detail-page', selectedTemplateId], enabled: !isDemoMode && Boolean(selectedTemplateId), queryFn: () => getTemplateById(selectedTemplateId!), }); const selectedDetail = isDemoMode ? selectedTemplateId ? demoTemplateDetails[selectedTemplateId] ?? null : null : templateDetailQuery.data ?? null; const projectOptions = useMemo( () => isDemoMode ? demoProjects : (projectsQuery.data ?? []).map((project) => ({ id: project.id, name: project.name, })), [isDemoMode, projectsQuery.data], ); const deployProjectId = useMemo(() => { if (projectOptions.length === 0) { return ''; } if (projectOptions.some((project) => project.id === deployProjectIdState)) { return deployProjectIdState; } return projectOptions[0].id; }, [deployProjectIdState, projectOptions]); const variableDefaults = useMemo(() => { if (!selectedDetail) { return {} as Record; } const defaults: Record = {}; for (const variable of selectedDetail.variables) { defaults[variable.key] = variable.defaultValue; } return defaults; }, [selectedDetail]); const deployName = useMemo(() => { if (!selectedTemplateId || !selectedDetail) { return ''; } return deployNameByTemplate[selectedTemplateId] ?? toServiceName(selectedDetail.template.name); }, [deployNameByTemplate, selectedDetail, selectedTemplateId]); const variableValues = useMemo(() => { if (!selectedTemplateId) { return variableDefaults; } return { ...variableDefaults, ...(variableValuesByTemplate[selectedTemplateId] ?? {}), }; }, [selectedTemplateId, variableDefaults, variableValuesByTemplate]); const missingRequiredVariables = useMemo(() => { if (!selectedDetail) { return []; } return selectedDetail.variables.filter((variable) => { if (!variable.required) { return false; } return !(variableValues[variable.key] ?? '').trim(); }); }, [selectedDetail, variableValues]); const deployMutation = useMutation({ mutationFn: async () => { if (!selectedTemplateId) { throw new Error('No template selected'); } const variables: Record = {}; for (const [key, value] of Object.entries(variableValues)) { if (value.trim()) { variables[key] = value.trim(); } } return deployTemplate(selectedTemplateId, { projectId: deployProjectId, name: deployName.trim(), variables, }); }, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['project-services'] }); setLastDeployment({ projectId: deployProjectId, serviceId: result.serviceId, serviceName: deployName.trim(), }); }, }); const isDeployDisabled = !selectedTemplateId || !deployProjectId || deployName.trim().length === 0 || missingRequiredVariables.length > 0 || deployMutation.isPending; const canManageTemplate = (template: TemplateEntity) => !template.isOfficial && Boolean(template.ownerId) && (template.ownerId === myUserId || isAdmin); const saveTemplateMutation = useMutation({ mutationFn: (input: TemplateWriteInput) => editingTemplate ? updateTemplate(editingTemplate.id, input) : createTemplate(input), onSuccess: (template) => { queryClient.invalidateQueries({ queryKey: ['templates-page'] }); setEditorOpen(false); setEditingTemplate(null); setEditorError(null); setSelectedTemplateId(template.id); }, onError: (error) => { setEditorError(error instanceof Error ? error.message : 'Failed to save template'); }, }); const deleteMutation = useMutation({ mutationFn: (id: string) => deleteTemplate(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['templates-page'] }); setSelectedTemplateId(null); }, }); const openCreateEditor = () => { setEditingTemplate(null); setEditorJson(EMPTY_TEMPLATE_JSON); setEditorError(null); setEditorOpen(true); }; const openEditEditor = (template: TemplateEntity) => { let config: unknown = {}; let variables: unknown = []; try { config = JSON.parse(template.configRaw || '{}'); } catch { /* keep {} */ } try { variables = JSON.parse(template.variablesRaw || '[]'); } catch { /* keep [] */ } setEditingTemplate(template); setEditorJson( JSON.stringify( { name: template.name, description: template.description, category: template.category, logo: template.logo, config, variables, }, null, 2, ), ); setEditorError(null); setEditorOpen(true); }; const submitEditor = () => { let parsed: unknown; try { parsed = JSON.parse(editorJson); } catch (err) { setEditorError(`Invalid JSON: ${err instanceof Error ? err.message : 'parse error'}`); return; } const body = parsed as Partial; if (!body || typeof body !== 'object' || !body.name || typeof body.config !== 'object' || body.config === null) { setEditorError('Template JSON needs at least "name" and a "config" object.'); return; } saveTemplateMutation.mutate({ name: String(body.name), description: body.description ?? '', category: body.category ?? 'other', logo: body.logo ?? '', config: body.config as Record, variables: Array.isArray(body.variables) ? body.variables : [], }); }; const uploadJsonFile = (file: File) => { if (!file.name.toLowerCase().endsWith('.json') && file.type !== 'application/json') { setEditorError('Only .json files are accepted.'); return; } file .text() .then((text) => { JSON.parse(text); setEditorJson(text); setEditorError(null); }) .catch(() => setEditorError('File is not valid JSON.')); }; return (
{/* Header */}

Template Catalog_

Deploy services from pre-configured templates

{filteredTemplates.length} templates {signedIn && !isDemoMode ? ( ) : null}
{/* Demo Mode Banner */} {isDemoMode && (
Demo mode active — using sample data
)} {/* Main Content */}
{/* Template List */}
Filters
setSearchFilter(e.target.value)} className="w-full h-10 pl-10 pr-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors" placeholder="Search templates..." />
{!isDemoMode && templatesQuery.isLoading ? (

Loading templates...

) : null} {!isDemoMode && templatesQuery.isError ? (

Failed to load templates

) : null} {filteredTemplates.length === 0 && !templatesQuery.isLoading ? (

No templates match filters

) : null} {filteredTemplates.length > 0 ? (
{filteredTemplates.map((template) => { const selected = template.id === selectedTemplateId; const Icon = categoryIcon(template.category); const categoryColor = template.category === 'database' ? '#7ab8ff' : template.category === 'frontend' ? '#f2c94c' : template.category === 'web' ? 'var(--accent-primary)' : '#9295a4'; return ( ); })}
) : null}
{/* Template Detail */}
{!selectedTemplateId ? (

Select a template to view details and deploy

) : null} {selectedTemplateId && !isDemoMode && templateDetailQuery.isLoading ? (

Loading template details...

) : null} {selectedTemplateId && !isDemoMode && templateDetailQuery.isError ? (

Failed to load template details

) : null} {selectedDetail ? ( <> {/* Header */}
{(() => { const Icon = categoryIcon(selectedDetail.template.category); return ; })()}

{selectedDetail.template.name}

{selectedDetail.template.isOfficial && ( Official )}

{selectedDetail.template.description}

{categoryLabel(selectedDetail.template.category)} {selectedDetail.config.runtime || 'n/a'} {canManageTemplate(selectedDetail.template) ? ( <> ) : null}
{/* Config Grid */}

Type

{selectedDetail.config.type || '—'}

Port

{selectedDetail.config.port || '—'}

Build

{selectedDetail.config.buildCommand || '—'}

Start

{selectedDetail.config.startCommand || '—'}

How Templates Work

Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page.

{selectedDetail.config.type === 'database' && (

Database templates create preconfigured database services with credentials and runtime settings, so you only need to provide required secrets.

)}
{/* Deploy Section */}

Deploy from Template

Configure and create a new service

{!signedIn ? (

Sign in to deploy this template into one of your projects.

Sign in
) : ( <>
{ if (!selectedTemplateId) return; setDeployNameByTemplate((current) => ({ ...current, [selectedTemplateId]: e.target.value, })); }} className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors" placeholder="service-name" />
{selectedDetail.variables.length > 0 && (
{selectedDetail.variables.map((variable) => { const hasError = variable.required && !(variableValues[variable.key] ?? '').trim(); return (
{ if (!selectedTemplateId) return; setVariableValuesByTemplate((current) => ({ ...current, [selectedTemplateId]: { ...(current[selectedTemplateId] ?? {}), [variable.key]: e.target.value, }, })); }} className={`w-full h-10 px-3 rounded-[var(--radius-md)] border bg-[var(--surface-muted)] text-sm transition-colors ${ hasError ? 'border-[var(--error)]' : 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)]' }`} placeholder={variable.defaultValue} />

{variable.description}

); })}
)} {missingRequiredVariables.length > 0 && (
Fill required variables: {missingRequiredVariables.map((v) => v.key).join(', ')}
)} {!isDemoMode && projectsQuery.isError && (
Failed to load projects
)} {deployMutation.isError && (
{deployMutation.error instanceof Error ? deployMutation.error.message : 'Failed to create service'}
)} {lastDeployment && (
Service {lastDeployment.serviceName} created View
)}
)}
) : null}
{/* Template editor — JSON only */} {editorOpen ? (

{editingTemplate ? `Edit ${editingTemplate.name}` : 'New template'}

JSON definition — name, category, config and variables.