diff --git a/frontend/src/pages/Templates.jsx b/frontend/src/pages/Templates.jsx index cb62d74a..317fe0e1 100644 --- a/frontend/src/pages/Templates.jsx +++ b/frontend/src/pages/Templates.jsx @@ -4,7 +4,7 @@ import { Search, X, Star, ExternalLink, BookOpen, Container, Globe, BarChart3, Database, Shield, Cloud, MessageSquare, Video, Music, Image, Home, Code, Server, GitBranch, Workflow, HardDrive, Lock, Users, FileText, - Settings, Layers, LayoutTemplate, ChevronDown, Copy, Check, Tag, Cpu, + Settings, Layers, LayoutTemplate, Copy, Check, Tag, Cpu, Newspaper, TrendingUp, Rocket } from 'lucide-react'; import api from '../services/api'; @@ -12,9 +12,12 @@ import { useToast } from '../contexts/ToastContext'; import Modal from '@/components/Modal'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + SearchField, FilterDrawer, FilterButton, countActiveFilters, +} from '@/components/ds'; import { useTopbarActions } from '@/hooks/useTopbarActions'; import EmptyState from '../components/EmptyState'; -import { DEPLOY_TEMPLATES } from '@/data/deployTemplates'; // Featured templates (curated list) const FEATURED_TEMPLATES = [ @@ -37,6 +40,7 @@ const TEMPLATE_ICONS = { 'langflow': Workflow, 'anythingllm': Cpu, 'librechat': MessageSquare, + 'agentsite': Cpu, // Monitoring 'uptime-kuma': BarChart3, 'grafana': BarChart3, @@ -153,6 +157,17 @@ const TEMPLATE_ICONS = { 'node-app': Code }; +const SORT_OPTIONS = [ + { value: 'featured', label: 'Featured first' }, + { value: 'name-asc', label: 'Name (A–Z)' }, + { value: 'name-desc', label: 'Name (Z–A)' }, +]; + +const KIND_OPTIONS = [ + { value: 'compose', label: 'One-click' }, + { value: 'repo', label: 'Git repo' }, +]; + const Templates = () => { const navigate = useNavigate(); const toast = useToast(); @@ -165,10 +180,14 @@ const Templates = () => { const [selectedTemplate, setSelectedTemplate] = useState(null); const [showInstallModal, setShowInstallModal] = useState(false); const [copiedCompose, setCopiedCompose] = useState(false); - const [showAllCategories, setShowAllCategories] = useState(false); + const [filtersOpen, setFiltersOpen] = useState(false); + // Repo-template manifest inspection (populated when a repo detail opens). + const [repoManifest, setRepoManifest] = useState(null); + const [repoManifestLoading, setRepoManifestLoading] = useState(false); // Initialize from URL params - const selectedCategory = searchParams.get('category') || null; + const selectedCategory = searchParams.get('category') || ''; + const selectedKind = searchParams.get('kind') || ''; const searchQuery = searchParams.get('search') || ''; const sortBy = searchParams.get('sort') || 'featured'; const installTemplateId = searchParams.get('install'); @@ -177,20 +196,30 @@ const Templates = () => { loadData(); }, []); - // Auto-open install modal if template ID is in URL + // Compat: the old `#deploy-templates` anchor (linked from the wizard + docs) + // now redirects to the repo-kind filtered view of the unified grid. + useEffect(() => { + if (window.location.hash === '#deploy-templates' && !selectedKind) { + updateFilters({ kind: 'repo' }); + // Drop the hash so a refresh doesn't re-trigger. + window.history.replaceState(null, '', window.location.pathname + window.location.search); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Auto-open install modal if template ID is in URL (compose templates only; + // repo templates deploy through the wizard, never the install modal). useEffect(() => { if (installTemplateId && templates.length > 0 && !loading) { - // WordPress has its own dedicated page if (installTemplateId === 'wordpress') { navigate('/wordpress', { replace: true }); return; } const template = templates.find(t => t.id === installTemplateId); - if (template) { + if (template && template.kind !== 'repo') { handleViewTemplate(template).then(() => { setShowInstallModal(true); }); - // Clear the install param from URL const newParams = new URLSearchParams(searchParams); newParams.delete('install'); setSearchParams(newParams, { replace: true }); @@ -222,10 +251,6 @@ const Templates = () => { updateFilters({ search: search || null }); } - function setSortByFilter(sort) { - updateFilters({ sort }); - } - function clearAllFilters() { setSearchParams(new URLSearchParams()); } @@ -247,7 +272,7 @@ const Templates = () => { async function loadTemplates() { try { - const result = await api.listTemplates(selectedCategory, searchQuery || null); + const result = await api.listTemplates(selectedCategory || null, searchQuery || null); setTemplates(result.templates || []); } catch (err) { console.error('Failed to load templates:', err); @@ -309,6 +334,8 @@ const Templates = () => { chat: MessageSquare, 'low-code': Workflow, workflow: Workflow, + website: Globe, + deployment: Rocket, // Search search: Search, vector: Database, @@ -360,14 +387,37 @@ const Templates = () => { const result = await api.getTemplate(template.id); if (result.template) { setSelectedTemplate(result.template); + if (result.template.kind === 'repo') { + loadRepoManifest(result.template); + } } } catch (err) { toast.error('Failed to load template details'); } } - // Hover-reveal Deploy: skip the detail modal, go straight to install + // Fetch the repo template's detected manifests / env from the Phase 1 + // endpoint so the detail body shows real data (or labeled hints). + function loadRepoManifest(template) { + setRepoManifest(null); + setRepoManifestLoading(true); + api.inspectTemplateManifest(template.id, template.repo?.branch) + .then(data => setRepoManifest(data)) + .catch(() => setRepoManifest(null)) + .finally(() => setRepoManifestLoading(false)); + } + + function deployRepoTemplate(templateId) { + navigate(`/services/new?template=${encodeURIComponent(templateId)}`); + } + + // Hover-reveal primary action. Repo templates route to the wizard; compose + // templates skip the detail modal and go straight to install. async function handleDeploy(template) { + if (template.kind === 'repo') { + deployRepoTemplate(template.id); + return; + } if (template.id === 'wordpress') { navigate('/wordpress'); return; @@ -388,8 +438,8 @@ const Templates = () => { } // Sort templates - function sortTemplates(templates) { - const sorted = [...templates]; + function sortTemplates(list) { + const sorted = [...list]; switch (sortBy) { case 'name-asc': return sorted.sort((a, b) => a.name.localeCompare(b.name)); @@ -408,27 +458,62 @@ const Templates = () => { } } - const sortedTemplates = sortTemplates(templates); - const hasActiveFilters = selectedCategory || searchQuery; - const visibleCategories = showAllCategories ? categories : categories.slice(0, 10); - const hiddenCategoryCount = Math.max(categories.length - visibleCategories.length, 0); + // Kind is filtered client-side (the list endpoint filters category + search). + const kindFiltered = selectedKind + ? templates.filter(t => (t.kind || 'compose') === selectedKind) + : templates; + const sortedTemplates = sortTemplates(kindFiltered); + + // One row of the largest categories as quick chips (no spillover button). + const quickCategories = categories.slice(0, 8); + + // The active-filter badge counts real filters only (category + kind); sort is + // an ordering, not a filter, so it never inflates the count. + const activeFilterCount = countActiveFilters({ category: selectedCategory, kind: selectedKind }); + const hasActiveFilters = Boolean(selectedCategory || selectedKind || searchQuery + || (sortBy && sortBy !== 'featured')); + + const filterGroups = [ + { + key: 'kind', + label: 'Type', + type: 'single', + options: KIND_OPTIONS, + }, + { + key: 'category', + label: 'Category', + type: 'single', + options: categories.map(cat => ({ value: cat, label: cat })), + }, + { + key: 'sort', + label: 'Sort', + type: 'single', + options: SORT_OPTIONS, + }, + ]; + + const filterValue = { category: selectedCategory, kind: selectedKind, sort: sortBy }; + + function handleFilterChange(next) { + updateFilters({ + category: next.category || null, + kind: next.kind || null, + sort: next.sort && next.sort !== 'featured' ? next.sort : null, + }); + } useTopbarActions(() => -
- - + setSearchQueryFilter(e.target.value)} + onSearch={setSearchQueryFilter} + placeholder="Search templates…" /> - {searchQuery && ( - - )} -
, - [searchQuery] + setFiltersOpen(true)} /> + , + [searchQuery, activeFilterCount] ); if (loading) { @@ -441,45 +526,8 @@ const Templates = () => { return (
- {/* Deploy templates — manifest-ready repos, shared with the New - Service wizard's "Deploy Template" source. */} -
-
-
-

Deploy templates

-

Manifest-ready repositories that prefill the New Service wizard.

-
-
-
- {DEPLOY_TEMPLATES.map(template => ( -
-
-
- {template.name} - {template.example && ( - Example - )} -
-

{template.description}

- {template.repoUrl} -
- {(template.badges || []).map(badge => ( - {badge} - ))} -
-
- -
- ))} -
-
- - {/* Results and Filters */} + {/* Quick category chips + result count. The full category list, kind, + and sort live in the shared FilterDrawer (opened from the topbar). */}
{sortedTemplates.length} template{sortedTemplates.length !== 1 ? 's' : ''} @@ -491,38 +539,32 @@ const Templates = () => { > All - {visibleCategories.map(category => ( + {quickCategories.map(category => ( ))} - {categories.length > 10 && ( - - )} -
-
- - -
{/* Active Filters */} {hasActiveFilters && (
+ {selectedKind && ( + + + {KIND_OPTIONS.find(k => k.value === selectedKind)?.label || selectedKind} + + + )} {selectedCategory && ( @@ -561,58 +603,64 @@ const Templates = () => { )} /> ) : ( - sortedTemplates.map(template => ( -
handleViewTemplate(template)}> - {isFeatured(template.id) && ( - - - - )} -
- - {renderIcon(template, 22)} - -
-
{template.name}
-
v{template.version}
+ sortedTemplates.map(template => { + const isRepo = (template.kind || 'compose') === 'repo'; + return ( +
handleViewTemplate(template)}> + {isFeatured(template.id) && ( + + + + )} +
+ + {renderIcon(template, 22)} + +
+
{template.name}
+
v{template.version}
+
+
+

{template.description}

+
+ + {isRepo ? 'Git repo' : 'One-click'} + + {(template.categories || []).slice(0, 2).map(cat => ( + + {cat} + + ))} + {template.website && ( + + + + )} + {template.documentation && ( + + + + )} +
-

{template.description}

-
- {(template.categories || []).slice(0, 3).map(cat => ( - - {cat} - - ))} - {template.website && ( - - - - )} - {template.documentation && ( - - - - )} - -
-
- )) + ); + }) )}
{/* Template Detail Modal */} {selectedTemplate && !showInstallModal && ( -
setSelectedTemplate(null)}> +
{ setSelectedTemplate(null); setRepoManifest(null); }}>
e.stopPropagation()}>
@@ -624,7 +672,7 @@ const Templates = () => { Version {selectedTemplate.version}
- +

{selectedTemplate.description}

@@ -646,122 +694,133 @@ const Templates = () => { )}
-
-
-

Categories

-
- {(selectedTemplate.categories || []).map(cat => ( - - {getCategoryIcon(cat)} {cat} - - ))} -
-
- - {selectedTemplate.requirements && ( + {selectedTemplate.kind === 'repo' ? ( + + ) : ( +
-

Requirements

-
- {selectedTemplate.requirements.memory && ( -
- Memory: - {selectedTemplate.requirements.memory} -
- )} - {selectedTemplate.requirements.storage && ( -
- Storage: - {selectedTemplate.requirements.storage} -
- )} +

Categories

+
+ {(selectedTemplate.categories || []).map(cat => ( + + {getCategoryIcon(cat)} {cat} + + ))}
- )} - {selectedTemplate.variables && selectedTemplate.variables.length > 0 && ( -
-

Configuration Variables

-
- {selectedTemplate.variables - .filter(v => !v.hidden) - .sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0)) - .map(variable => ( -
-
- {variable.name} - {variable.required && Required} - {variable.auto_generated && Auto} + {selectedTemplate.requirements && ( +
+

Requirements

+
+ {selectedTemplate.requirements.memory && ( +
+ Memory: + {selectedTemplate.requirements.memory}
- {variable.description && ( - {variable.description} - )} - {variable.default && !variable.auto_generated && ( - Default: {variable.default} - )} -
- ))} + )} + {selectedTemplate.requirements.storage && ( +
+ Storage: + {selectedTemplate.requirements.storage} +
+ )} +
-
- )} + )} - {selectedTemplate.ports && selectedTemplate.ports.length > 0 && ( -
-

Exposed Ports

-
- {selectedTemplate.ports.map((port, index) => ( -
- {port.port} - {port.protocol} - {port.description && ( - {port.description} - )} -
- ))} + {selectedTemplate.variables && selectedTemplate.variables.length > 0 && ( +
+

Configuration Variables

+
+ {selectedTemplate.variables + .filter(v => !v.hidden) + .sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0)) + .map(variable => ( +
+
+ {variable.name} + {variable.required && Required} + {variable.auto_generated && Auto} +
+ {variable.description && ( + {variable.description} + )} + {variable.default && !variable.auto_generated && ( + Default: {variable.default} + )} +
+ ))} +
-
- )} + )} - {selectedTemplate.has_compose && ( -
-

- Docker Compose - -

-
- Docker Compose configuration will be generated during installation + {selectedTemplate.ports && selectedTemplate.ports.length > 0 && ( +
+

Exposed Ports

+
+ {selectedTemplate.ports.map((port, index) => ( +
+ {port.port} + {port.protocol} + {port.description && ( + {port.description} + )} +
+ ))} +
-
- )} -
+ )} + + {selectedTemplate.has_compose && ( +
+

+ Docker Compose + +

+
+ Docker Compose configuration will be generated during installation +
+
+ )} +
+ )}
- - + {selectedTemplate.kind === 'repo' ? ( + + ) : ( + + )}
)} - {/* Install Modal */} + {/* Install Modal (compose templates only) */} {showInstallModal && selectedTemplate && ( { }} /> )} + + +
+ ); +}; + +// Repo-template detail body: repo URL, detected manifests, and env preview from +// the /templates//manifest endpoint (real inspection or labeled hints). +const RepoTemplateDetailBody = ({ template, manifest, loading, getCategoryIcon }) => { + const repo = template.repo || {}; + const data = manifest?.manifest || null; + const sourceLabel = manifest?.source === 'repo' + ? 'Detected from repository' + : manifest?.source === 'template-hints' + ? 'From template hints' + : null; + + return ( +
+
+

Repository

+
+ + {repo.url} + +
+ {repo.branch && branch: {repo.branch}} + {repo.app_type && type: {repo.app_type}} + {repo.build_method && build: {repo.build_method}} + {repo.port && port: {repo.port}} +
+
+
+ + {(template.categories || []).length > 0 && ( +
+

Categories

+
+ {(template.categories || []).map(cat => ( + + {getCategoryIcon(cat)} {cat} + + ))} +
+
+ )} + +
+

+ Detected manifests + {sourceLabel && {sourceLabel}} +

+ {loading ? ( +

Inspecting repository…

+ ) : (data?.manifests || []).length > 0 ? ( +
+ {data.manifests.map(m => ( + + {m.file} + + ))} +
+ ) : ( +

No deploy manifests detected.

+ )} +
+ + {!loading && (data?.env || []).length > 0 && ( +
+

Environment

+
+ {data.env.map(env => ( + + {env.key}{env.required ? ' *' : ''} + + ))} +
+

+ Secret values stay empty until you add them to the service environment. +

+
+ )}
); }; diff --git a/frontend/src/styles/pages/_templates.scss b/frontend/src/styles/pages/_templates.scss index 3bd7affb..aa6a4bb2 100644 --- a/frontend/src/styles/pages/_templates.scss +++ b/frontend/src/styles/pages/_templates.scss @@ -363,106 +363,11 @@ } } -// Results Header -.deploy-templates-section { - margin-bottom: $space-5; - padding-bottom: $space-4; - border-bottom: 1px solid $border-color; - - &__head { - margin-bottom: $space-3; - - h3 { - margin: 0 0 2px; - font-size: $font-size-base; - font-weight: $font-weight-semibold; - color: $text-primary; - } - - p { - margin: 0; - font-size: $font-size-sm; - color: $text-tertiary; - } - } -} - -.deploy-templates-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); - gap: $space-3; -} - -.deploy-template-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: $space-3; - padding: $space-3 $space-4; - border: 1px solid $border-color; - border-radius: $radius-md; - background: $bg-card; - - &__main { - min-width: 0; - - p { - margin: 2px 0 $space-1; - font-size: $font-size-sm; - color: $text-secondary; - } - - code { - display: block; - font-size: $font-size-xs; - color: $text-tertiary; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - - &__title { - display: flex; - align-items: center; - gap: $space-2; - - strong { - color: $text-primary; - font-weight: $font-weight-semibold; - } - } - - &__example { - padding: 1px $space-2; - border-radius: $radius-sm; - background: rgba($accent-primary-raw, 0.15); - color: $accent-primary; - font-size: $font-size-xs; - font-weight: $font-weight-medium; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - &__badges { - display: flex; - flex-wrap: wrap; - gap: $space-1; - margin-top: $space-2; - - span { - padding: 1px $space-2; - border: 1px solid $border-color; - border-radius: $radius-sm; - font-size: $font-size-xs; - color: $text-tertiary; - } - } -} - +// Quick-chips row + result count. Full category list, kind, and sort live in +// the shared FilterDrawer opened from the topbar. .templates-results-header { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: $space-3; margin-bottom: $space-4; @@ -477,44 +382,93 @@ white-space: nowrap; } -.sort-dropdown { - display: flex; - align-items: center; - gap: $space-2; - position: relative; +// Kind badge on catalog cards ("Git repo" / "One-click"). +.tpl-kind { + text-transform: none; +} - label { +// Repo-template detail body +.repo-detail { + display: flex; + flex-direction: column; + gap: $space-2; + + &__url { font-family: var(--mono); - font-size: 11px; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-faint); + font-size: $font-size-sm; + color: var(--accent-bright); + word-break: break-all; } - select { - appearance: none; - padding: $space-2 $space-8 $space-2 $space-3; - background: var(--surface); - border: 1px solid var(--border); - border-radius: $radius-md; - font-size: $font-size-sm + 1; - color: $text-primary; - cursor: pointer; + &__meta { + display: flex; + flex-wrap: wrap; + gap: $space-3; + font-size: $font-size-sm; + color: $text-tertiary; - &:focus { - outline: none; - border-color: var(--accent-primary); + code { + font-family: var(--mono); + color: $text-secondary; } } +} - .dropdown-icon { - position: absolute; - right: $space-2; - pointer-events: none; - color: var(--text-faint); +.detail-source-tag { + margin-left: auto; + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: none; + color: var(--text-faint); + background: var(--surface-2); + padding: 2px $space-2; + border-radius: $radius-sm; +} + +.manifest-file-list { + display: flex; + flex-wrap: wrap; + gap: $space-2; +} + +.manifest-file { + display: inline-flex; + align-items: center; + gap: $space-1; + padding: 2px 8px; + font-family: var(--mono); + font-size: 11px; + color: var(--green); + background: var(--green-bg); + border-radius: $radius-sm; +} + +.env-preview-list { + display: flex; + flex-wrap: wrap; + gap: $space-2; +} + +.env-preview-chip { + padding: 2px 8px; + font-family: var(--mono); + font-size: 11px; + color: var(--text-dim); + background: var(--surface-2); + border-radius: $radius-sm; + + &--secret { + color: var(--amber); + background: rgba($accent-primary-raw, 0.1); } } +.env-preview-note { + margin: $space-2 0 0; + font-size: $font-size-xs; +} + // Template icon (detail modal header) .template-icon-large { width: 80px; @@ -908,12 +862,4 @@ .category-filters { width: 100%; } - - .sort-dropdown { - width: 100%; - - select { - flex: 1; - } - } }