import { lazy, Suspense, 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, importComposeTemplate, importGitComposeTemplate, listProjects, listTemplates, updateTemplate, type TemplateEntity, type TemplateWriteInput, } from '@/lib/api-client'; import { useAuthSession } from '@/lib/use-auth-session'; import { useDemoMode } from '@/lib/demo-mode'; import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data'; import { GhostBtn, QuietBtn, SPageHead } from '@/shared/components/sentry'; import { Search, Filter, Check, Loader2, Sparkles, Box, Code, Database, Globe, Layers, Terminal, Play, ArrowRight, Star, Plus, Pencil, Trash2, Upload, X, FileCode, Network, } from 'lucide-react'; const TemplateBuilder = lazy(() => import('@/features/templates/builder/TemplateBuilder').then((m) => ({ default: m.TemplateBuilder })), ); 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, setSearchParams] = useSearchParams(); const isDemoMode = useDemoMode(); 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 [editorMode, setEditorMode] = useState<'json' | 'visual'>('json'); const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON); const [editingTemplate, setEditingTemplate] = useState(null); const [editorIsPublic, setEditorIsPublic] = useState(false); const [editorError, setEditorError] = useState(null); const [categoryFilter, setCategoryFilter] = useState(''); const [searchFilter, setSearchFilter] = useState(''); // Selection lives in the URL (?template=) so catalog deep links and // card clicks share one source of truth. const templateParam = searchParams.get('template'); const selectTemplate = (id: string | null) => { const next = new URLSearchParams(searchParams); if (id) { next.set('template', id); } else { next.delete('template'); } setSearchParams(next, { replace: true }); }; const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? ''); const [deployNameByTemplate, setDeployNameByTemplate] = useState>({}); const [variableValuesByTemplate, setVariableValuesByTemplate] = useState< Record> >({}); const [composeOpen, setComposeOpen] = useState(false); const [composeYaml, setComposeYaml] = useState(''); const [composeWarnings, setComposeWarnings] = useState([]); const [composeError, setComposeError] = useState(null); const [composeMode, setComposeMode] = useState<'paste' | 'git'>('paste'); const [gitRepo, setGitRepo] = useState(''); const [gitPath, setGitPath] = useState(''); const [gitRef, setGitRef] = useState(''); const [lastDeployment, setLastDeployment] = useState<{ projectId: string; serviceId?: string; serviceName: string; created?: { key: string; kind: string; id: string; name: string; deploymentId?: 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 (templateParam && filteredTemplates.some((template) => template.id === templateParam)) { return templateParam; } return filteredTemplates[0].id; }, [filteredTemplates, templateParam]); 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 graphMembers = useMemo( () => selectedDetail?.config.services ?? [], [selectedDetail], ); const isGraphTemplate = graphMembers.length > 0; 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: isGraphTemplate ? undefined : deployName.trim(), variables, }); }, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['project-services'] }); queryClient.invalidateQueries({ queryKey: ['databases'] }); setLastDeployment({ projectId: deployProjectId, serviceId: result.serviceId, serviceName: isGraphTemplate ? `${result.created?.length ?? 0} resources` : deployName.trim(), created: result.created, }); }, }); const isDeployDisabled = !selectedTemplateId || !deployProjectId || (!isGraphTemplate && 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); selectTemplate(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'] }); selectTemplate(null); }, }); const composeImportMutation = useMutation({ mutationFn: () => composeMode === 'git' ? importGitComposeTemplate(gitRepo.trim(), gitPath.trim(), gitRef.trim()) : importComposeTemplate(composeYaml), onSuccess: (result) => { setComposeWarnings(result.warnings); setComposeError(null); setComposeOpen(false); // Load the converted graph into the template editor for review/save. setEditingTemplate(null); setEditorIsPublic(false); setEditorError(null); setEditorJson( JSON.stringify( { name: 'compose-stack', description: 'Imported from docker-compose', category: 'stack', logo: '', config: result.config, variables: [], }, null, 2, ), ); setEditorOpen(true); }, onError: (error) => { setComposeError(error instanceof Error ? error.message : 'Failed to convert compose file'); }, }); const openCreateEditor = () => { setEditingTemplate(null); setEditorJson(EMPTY_TEMPLATE_JSON); setEditorIsPublic(false); 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, ), ); setEditorIsPublic(template.isPublic); 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 : [], isPublic: editorIsPublic, }); }; 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 (
{ setComposeYaml(''); setComposeWarnings([]); setComposeError(null); setComposeOpen(true); }} > Import Compose New template ) : undefined} />
{/* 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' ? 'var(--info)' : template.category === 'frontend' ? 'var(--warning)' : template.category === 'web' ? 'var(--accent-primary)' : 'var(--text-tertiary)'; 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.isOfficial && selectedDetail.template.isPublic && ( Public )}

{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.

)}
{isGraphTemplate ? (

Stack — {graphMembers.length} members

{graphMembers.map((member) => (
{member.type === 'database' ? ( ) : ( )} {member.name || member.key} {member.dependsOn && member.dependsOn.length > 0 ? ( after {member.dependsOn.join(', ')} ) : null}
{member.type === 'database' ? `managed ${member.runtime}` : member.runtime || member.repo || '—'}
))}

Members join a private project network; references like {'{{service.db.url}}'} resolve to the member key.

) : null} {/* Deploy Section */}
Deploy from Template

{isGraphTemplate ? 'Deploy all members into a project' : 'Configure and create a new service'}

{sessionQuery.isPending && !isDemoMode ? (
) : !signedIn ? (

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

Sign in
) : ( <>
{!isGraphTemplate ? (
{ 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" />
) : null}
{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 && (
{lastDeployment.created ? `Stack deployed — ${lastDeployment.created.length} resources` : <>Service {lastDeployment.serviceName} created} View
{lastDeployment.created ? (
{lastDeployment.created.map((m) => (
{m.kind === 'database' ? : } {m.name} ({m.kind})
))}
) : null}
)}
)}
) : null}
{/* Template editor — JSON only */} {editorOpen ? (

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

{editorMode === 'visual' ? 'Visual builder — drop service nodes, wire depends_on edges, apply back to JSON.' : 'JSON definition — name, category, config and variables.'}

{(['json', 'visual'] as const).map((mode) => ( ))}
{editorMode === 'visual' ? (
} > {(() => { let cfg: Record = {}; try { const parsed = JSON.parse(editorJson) as { config?: Record }; cfg = parsed?.config ?? {}; } catch { cfg = {}; } return ( setEditorMode('json')} onApply={(newConfig) => { try { const parsed = JSON.parse(editorJson) as Record; setEditorJson(JSON.stringify({ ...parsed, config: newConfig }, null, 2)); } catch { setEditorJson(JSON.stringify({ config: newConfig }, null, 2)); } setEditorMode('json'); }} /> ); })()}
) : (