diff --git a/backend/src/index.ts b/backend/src/index.ts index f6d77ee1..3b0169d0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7258,7 +7258,36 @@ app.post('/api/system/networks', async (req: Request, res: Response) => { app.get('/api/templates', authMiddleware, async (req: Request, res: Response) => { try { const templates = await templateService.getTemplates(); - res.json(templates); + + const imageRefs = templates.map(t => t.image).filter((i): i is string => !!i); + const scanSummary = DatabaseService.getInstance().getLatestScanSummaryByImageRefs(req.nodeId, imageRefs); + + let featuredIndex = -1; + let featuredStars = 0; + templates.forEach((t, i) => { + const s = t.stars ?? 0; + if (s > featuredStars) { + featuredStars = s; + featuredIndex = i; + } + }); + + const enriched = templates.map((t, i) => { + const summary = t.image ? scanSummary.get(t.image) : undefined; + const scan_status: 'clean' | 'vulnerable' | 'unscanned' = summary + ? (summary.total === 0 ? 'clean' : 'vulnerable') + : 'unscanned'; + return { + ...t, + scan_status, + scan_cve_count: summary?.total ?? 0, + scan_critical_count: summary?.critical ?? 0, + scan_high_count: summary?.high ?? 0, + featured: i === featuredIndex, + }; + }); + + res.json(enriched); } catch (error) { console.error('[Templates] Failed to fetch:', error); res.status(500).json({ error: 'Failed to fetch templates' }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index d1e3fe1b..66ffbc1e 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -2508,6 +2508,44 @@ export class DatabaseService { ); } + public getLatestScanSummaryByImageRefs( + nodeId: number, + imageRefs: string[], + ): Map { + const summary = new Map(); + if (imageRefs.length === 0) return summary; + + const placeholders = imageRefs.map(() => '?').join(','); + const rows = this.db + .prepare( + `SELECT image_ref, total_vulnerabilities, critical_count, high_count, scanned_at + FROM vulnerability_scans v1 + WHERE node_id = ? + AND image_ref IN (${placeholders}) + AND scanned_at = ( + SELECT MAX(scanned_at) FROM vulnerability_scans v2 + WHERE v2.node_id = v1.node_id AND v2.image_ref = v1.image_ref + )`, + ) + .all(nodeId, ...imageRefs) as Array<{ + image_ref: string; + total_vulnerabilities: number; + critical_count: number; + high_count: number; + scanned_at: number; + }>; + + for (const row of rows) { + summary.set(row.image_ref, { + total: row.total_vulnerabilities, + critical: row.critical_count, + high: row.high_count, + scannedAt: row.scanned_at, + }); + } + return summary; + } + public getLatestScanByDigest(digest: string, scannersUsed?: string): VulnerabilityScan | null { if (!digest) return null; if (scannersUsed) { diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx index 2a3b0a90..d128bd31 100644 --- a/docs/features/app-store.mdx +++ b/docs/features/app-store.mdx @@ -6,29 +6,31 @@ description: Browse and deploy pre-configured application templates in one click The **App Store** tab lets you browse a curated catalogue of Docker Compose templates and deploy any of them as a new stack with environment-specific configuration, no YAML required. - App Store showing a grid of application templates with category filters + App Store with a category sidebar, a featured template hero banner, and editorial tiles with security scan badges ## Browsing templates -Templates are loaded from a remote registry (configurable in **Settings > App Store**). The default registry is LinuxServer.io. A live count of available apps is shown next to the category pills. +Templates are loaded from a remote registry (configurable in **Settings > App Store**). The default registry is LinuxServer.io. A live count of available apps is shown in the top right of the view. **Search:** Type in the search bar to filter templates by name, description, or category in real-time. -**Categories:** Click any category pill to narrow the list: -`All` · `Automation` · `Books` · `Development` · `Documentation` · `Downloaders` · `Media` · `Monitoring` · `Networking` · `Other` · `Productivity` · `Security` · `Utilities` +**Category sidebar:** The left rail lists every category with its count. Click a category to narrow the grid. The active category is highlighted with a cyan rail and tinted background. -Each template card shows: -- Application logo (or a placeholder icon if unavailable) -- Name and short description -- Up to three category badges (click a badge to filter by that category) +**Featured hero:** A featured template is pinned at the top of the grid with its logo, title, description, and a primary **Deploy** button. The hero disappears while you have an active search query. + +Each tile shows: +- A brand-tinted logo square (falls back to the first letter of the template name) +- Name and first-sentence pitch +- GitHub star count and primary category +- A **security scan badge** on the right: `CLEAN` (no CVEs detected), `{n} CVE(s)` (amber, open vulnerabilities), or `UNSCANNED` (no scan recorded yet) ## Deploying a template -Click any template card to open the **deployment sheet** on the right side of the screen. +Click any tile to open the **deployment sheet** on the right side of the screen. The sheet splits configuration into two tabs: **Essentials** for a fast one-click deploy and **Advanced** for full control over ports, volumes, and environment. - Deployment sheet for heimdall showing stack name, ports, volumes, and environment variables + Deployment sheet opened on the Essentials tab, showing the stack name field and a deploy-with-defaults hint The sheet header displays the app logo, title, and a truncated description with a **Read more** / **Read less** toggle. Below the description you may see: @@ -40,10 +42,18 @@ The sheet header displays the app logo, title, and a truncated description with When deploying to a remote node, a badge at the top of the sheet shows the target node name. +### Essentials tab + +The **Essentials** tab has a single field (the stack name) plus a hint that the template's recommended ports, volumes, and environment variables will be used as-is. Click **Deploy {template}** to ship it in one click. For anything more nuanced, switch to **Advanced**. + ### Stack name Pre-filled with the template name in lowercase. You can change it; the same [naming rules](/features/stack-management#creating-a-stack) apply (lowercase, hyphens, no spaces). +### Advanced tab + +The **Advanced** tab exposes every knob the template declares: port mappings, volumes, environment variables, custom key/value pairs, and the post-deploy vulnerability scan toggle. Fields on this tab are pre-populated with the template's defaults, so customizing is additive rather than required. + ### Ports For each exposed port, the sheet shows an editable **host port** field (left side) and the fixed **container port** (right side). Edit the host port to avoid conflicts with other services on the same machine. Ports must be in the valid range (1 to 65535); invalid values are highlighted and block deployment. diff --git a/docs/images/app-store/app-store-deploy.png b/docs/images/app-store/app-store-deploy.png deleted file mode 100644 index 4f60b9aa..00000000 Binary files a/docs/images/app-store/app-store-deploy.png and /dev/null differ diff --git a/docs/images/app-store/app-store-hero.png b/docs/images/app-store/app-store-hero.png new file mode 100644 index 00000000..ef9563c5 Binary files /dev/null and b/docs/images/app-store/app-store-hero.png differ diff --git a/docs/images/app-store/app-store-overview.png b/docs/images/app-store/app-store-overview.png deleted file mode 100644 index 7a43ff41..00000000 Binary files a/docs/images/app-store/app-store-overview.png and /dev/null differ diff --git a/docs/images/app-store/deploy-sheet-essentials.png b/docs/images/app-store/deploy-sheet-essentials.png new file mode 100644 index 00000000..6c5ebda0 Binary files /dev/null and b/docs/images/app-store/deploy-sheet-essentials.png differ diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 59295dae..1e071666 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -1,19 +1,23 @@ import { useState, useEffect, useMemo } from 'react'; -import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from "lucide-react"; -import { toast } from "@/components/ui/toast-store"; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from '@/components/ui/sheet'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from 'lucide-react'; +import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; +import { CategorySidebar } from '@/components/appstore/CategorySidebar'; +import { FeaturedHero } from '@/components/appstore/FeaturedHero'; +import { TemplateTile } from '@/components/appstore/TemplateTile'; +import type { Template } from '@/components/appstore/types'; function isValidPort(value: string): boolean { if (!value) return true; @@ -21,34 +25,6 @@ function isValidPort(value: string): boolean { return Number.isInteger(num) && num >= 1 && num <= 65535; } -interface TemplateEnv { - name: string; - label?: string; - default?: string; -} - -interface TemplateVolume { - container?: string; - bind?: string; -} - -interface Template { - type?: number; - title: string; - description: string; - logo?: string; - image?: string; - ports?: string[]; - volumes?: TemplateVolume[]; - env?: TemplateEnv[]; - categories?: string[]; - github_url?: string; - docs_url?: string; - architectures?: string[]; - stars?: number; - source?: string; -} - interface PortInUseInfo { stack: string | null; container: string; @@ -75,12 +51,13 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { const [portVars, setPortVars] = useState>({}); const [isDescExpanded, setIsDescExpanded] = useState(false); const [volVars, setVolVars] = useState>({}); - const [customEnvs, setCustomEnvs] = useState>([]); + const [customEnvs, setCustomEnvs] = useState>([]); const [newEnvKey, setNewEnvKey] = useState(''); const [portsInUse, setPortsInUse] = useState>({}); const [newEnvVal, setNewEnvVal] = useState(''); const [autoScan, setAutoScan] = useState(true); const [trivyAvailable, setTrivyAvailable] = useState(false); + const [sheetTab, setSheetTab] = useState<'essentials' | 'advanced'>('essentials'); useEffect(() => { fetchTemplates(); @@ -99,16 +76,14 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { const data = await res.json(); setTemplates(data || []); } catch (err) { - toast.error((err as Error).message || "Failed to load App Shop"); + toast.error((err as Error).message || 'Failed to load App Store'); } finally { setLoading(false); } }; const handleSelectTemplate = (t: Template) => { - // Work on a copy to avoid mutating the template in state const envsCopy = [...(t.env || [])]; - // Inject LSIO standards if missing from the API if (!envsCopy.find(e => e.name === 'PUID')) envsCopy.push({ name: 'PUID', label: 'User ID (PUID)', default: '1000' }); if (!envsCopy.find(e => e.name === 'PGID')) envsCopy.push({ name: 'PGID', label: 'Group ID (PGID)', default: '1000' }); if (!envsCopy.find(e => e.name === 'TZ')) { @@ -125,7 +100,6 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { }); setEnvVars(initEnvs); - // Initialize Volumes const initVols: Record = {}; t.volumes?.forEach((v) => { if (v.container) { @@ -133,7 +107,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { } }); setVolVars(initVols); - setCustomEnvs([]); // Reset custom envs + setCustomEnvs([]); setNewEnvKey(''); setNewEnvVal(''); @@ -141,13 +115,13 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { t.ports?.forEach(p => { const parts = p.split(':'); if (parts.length > 1) { - initPorts[p] = parts[0]; // Store just the host port for editing + initPorts[p] = parts[0]; } }); setPortVars(initPorts); - setIsDescExpanded(false); // Reset description toggle + setIsDescExpanded(false); + setSheetTab('essentials'); - // Auto-generate stack name from title const defaultName = t.title .toLowerCase() .replace(/[^a-z0-9-]/g, '-') @@ -155,7 +129,6 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { .replace(/^-|-$/g, ''); setStackName(defaultName); - // Fetch currently bound host ports for conflict detection setPortsInUse({}); apiFetch('/ports/in-use').then(r => r.ok ? r.json() : {}).then(setPortsInUse).catch(err => console.error('[AppStore] Failed to fetch ports in use:', err)); @@ -163,19 +136,18 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { }; const handleDeploy = async () => { + if (!selectedTemplate) return; if (!stackName.trim()) { - toast.error("Stack name is required"); + toast.error('Stack name is required'); return; } - // Validate port numbers const invalidPort = Object.entries(portVars).find(([, val]) => val && !isValidPort(val)); if (invalidPort) { toast.error(`Invalid port: ${invalidPort[1]}. Ports must be between 1 and 65535.`); return; } - // Pre-check for duplicate stack name try { const checkRes = await apiFetch('/stacks'); if (checkRes.ok) { @@ -191,18 +163,17 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { setIsDeploying(true); - const modifiedTemplate = { ...selectedTemplate }; + const modifiedTemplate: Template = { ...selectedTemplate }; if (modifiedTemplate.ports) { modifiedTemplate.ports = modifiedTemplate.ports.map(p => { const parts = p.split(':'); if (parts.length > 1 && portVars[p]) { - return `${portVars[p]}:${parts[1]}`; // Stitch the edited host port back + return `${portVars[p]}:${parts[1]}`; } return p; }); } - // Process Volumes if (modifiedTemplate.volumes) { modifiedTemplate.volumes = modifiedTemplate.volumes.map((v) => { if (v.container && volVars[v.container] !== undefined) { @@ -212,7 +183,6 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { }); } - // Merge envs const finalEnvVars = { ...envVars }; customEnvs.forEach(ce => { if (ce.key.trim()) finalEnvVars[ce.key.trim()] = ce.value; @@ -225,8 +195,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { stackName: stackName.trim(), template: modifiedTemplate, envVars: finalEnvVars, - skip_scan: !autoScan - }) + skip_scan: !autoScan, + }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to deploy template'); @@ -241,10 +211,17 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { } }; - const categories = useMemo(() => { - const cats = new Set(); - templates.forEach(t => t.categories?.forEach(c => cats.add(c))); - return ['All', ...Array.from(cats).sort()]; + const categoryEntries = useMemo(() => { + const counts = new Map(); + templates.forEach(t => { + t.categories?.forEach(c => { + counts.set(c, (counts.get(c) || 0) + 1); + }); + }); + const sorted = Array.from(counts.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([name, count]) => ({ name, count })); + return [{ name: 'All', count: templates.length }, ...sorted]; }, [templates]); const filtered = useMemo(() => templates.filter(t => { @@ -257,9 +234,19 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { return matchesCategory && matchesSearch; }), [templates, selectedCategory, searchQuery]); + const featuredTemplate = useMemo(() => { + if (searchQuery) return null; + return filtered.find(t => t.featured) || null; + }, [filtered, searchQuery]); + + const gridTemplates = useMemo(() => { + if (!featuredTemplate) return filtered; + return filtered.filter(t => t.title !== featuredTemplate.title); + }, [filtered, featuredTemplate]); + return ( -
-
+
+
{ setSearchQuery(e.target.value); }} />
+ + {filtered.length} app{filtered.length !== 1 ? 's' : ''} +
- {!loading && categories.length > 1 && ( -
-
- {categories.map(cat => ( - - ))} -
- - {filtered.length} app{filtered.length !== 1 ? 's' : ''} - -
- )} - - - {loading ? ( -
- -
- ) : ( -
- {filtered.map((t, idx) => ( - handleSelectTemplate(t)} - > - -
- {t.logo && !imgErrors[t.logo] ? ( - {t.title} setImgErrors(prev => ({ ...prev, [t.logo!]: true }))} /> - ) : ( - - )} -
-
- {t.title} -

- {t.description} -

-
-
- {t.categories && t.categories.length > 0 && ( - -
- {t.categories.slice(0, 3).map(c => ( - { e.stopPropagation(); setSelectedCategory(c); }} - > - {c} - - ))} -
-
- )} -
- ))} - {filtered.length === 0 && ( -
- - {templates.length === 0 ? ( -

Registry returned no templates. Check your registry URL in Settings.

- ) : ( -

No apps found matching "{searchQuery}"

- )} -
- )} -
+
+ {!loading && categoryEntries.length > 1 && ( + )} - + + + {loading ? ( +
+ +
+ ) : ( +
+ {featuredTemplate && ( + featuredTemplate.logo && setImgErrors(prev => ({ ...prev, [featuredTemplate.logo!]: true }))} + /> + )} + + {gridTemplates.length > 0 ? ( +
+ {gridTemplates.map((t, idx) => ( + t.logo && setImgErrors(prev => ({ ...prev, [t.logo!]: true }))} + /> + ))} +
+ ) : !featuredTemplate ? ( +
+ + {templates.length === 0 ? ( +

Registry returned no templates. Check your registry URL in Settings.

+ ) : ( +

No apps found matching "{searchQuery}"

+ )} +
+ ) : null} +
+ )} +
+
{selectedTemplate && (
- + {activeNode?.type === 'remote' && (
@@ -373,10 +335,10 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { )}
-
- {selectedTemplate.title} +
+ {selectedTemplate.title}
- + {selectedTemplate.description} setIsDescExpanded(!isDescExpanded)}> @@ -422,157 +384,178 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
- -
-
- - setStackName(e.target.value)} - placeholder="e.g. my-app" - /> -

- This determines the directory name and docker project name. -

-
+ setSheetTab(v as 'essentials' | 'advanced')} className="flex flex-col flex-1 min-h-0"> + + Essentials + Advanced + - {selectedTemplate.ports && selectedTemplate.ports.length > 0 && ( -
-

Ports (Host : Container)

- {selectedTemplate.ports.map((p, idx) => { - const parts = p.split(':'); - if (parts.length < 2) return null; - const hostPort = portVars[p] || ''; - const conflict = hostPort ? portsInUse[hostPort] : undefined; - return ( -
- { - const val = e.target.value.replace(/[^0-9]/g, ''); - setPortVars(prev => ({ ...prev, [p]: val })); - }} - className={cn("w-24 text-center font-mono", hostPort && !isValidPort(hostPort) && "border-destructive")} - /> - {conflict ? ( - - - : {parts[1]} - - - -
- - -
- - {conflict.stack !== null - ? <>Port {hostPort} used by {conflict.stack} - : <>Port {hostPort} used by an external app - } - -
-
- - ) : ( - : {parts[1]} - )} -
- ); - })} -
- )} - - {selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && ( -
-

Volumes (Host : Container)

- {selectedTemplate.volumes.map((v, idx: number) => { - const containerPath = v.container; - if (!containerPath) return null; - return ( -
- - setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))} - placeholder={`/path/to/host/dir`} - /> -
- ); - })} -
- )} - - {selectedTemplate.env && selectedTemplate.env.length > 0 && ( -
-

Environment Variables

- {selectedTemplate.env.map((e, idx) => ( -
- - setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))} - placeholder={e.default || `Enter value for ${e.name}`} - /> -

- {e.name} -

-
- ))} -
- )} - -
-

Add Custom Variable

- {customEnvs.map((ce, idx) => ( -
- - - + + +
+
+ + setStackName(e.target.value)} + placeholder="e.g. my-app" + /> +

+ This determines the directory name and docker project name. +

+
+ +
+ Deploy with defaults: the template's recommended ports, volumes, and environment variables. + Use the Advanced tab to customize.
- ))} -
- setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" /> - setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" /> -
-
-
- + + + + + +
+ {selectedTemplate.ports && selectedTemplate.ports.length > 0 && ( +
+

Ports (Host : Container)

+ {selectedTemplate.ports.map((p, idx) => { + const parts = p.split(':'); + if (parts.length < 2) return null; + const hostPort = portVars[p] || ''; + const conflict = hostPort ? portsInUse[hostPort] : undefined; + return ( +
+ { + const val = e.target.value.replace(/[^0-9]/g, ''); + setPortVars(prev => ({ ...prev, [p]: val })); + }} + className={cn('w-24 text-center font-mono', hostPort && !isValidPort(hostPort) && 'border-destructive')} + /> + {conflict ? ( + + + : {parts[1]} + + + +
+ + +
+ + {conflict.stack !== null + ? <>Port {hostPort} used by {conflict.stack} + : <>Port {hostPort} used by an external app + } + +
+
+ + ) : ( + : {parts[1]} + )} +
+ ); + })} +
+ )} + + {selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && ( +
+

Volumes (Host : Container)

+ {selectedTemplate.volumes.map((v, idx) => { + const containerPath = v.container; + if (!containerPath) return null; + return ( +
+ + setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))} + placeholder="/path/to/host/dir" + /> +
+ ); + })} +
+ )} + + {selectedTemplate.env && selectedTemplate.env.length > 0 && ( +
+

Environment Variables

+ {selectedTemplate.env.map((e, idx) => ( +
+ + setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))} + placeholder={e.default || `Enter value for ${e.name}`} + /> +

+ {e.name} +

+
+ ))} +
+ )} + +
+

Add Custom Variable

+ {customEnvs.map((ce, idx) => ( +
+ + + +
+ ))} +
+ setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" /> + setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" /> + +
+
+ + {trivyAvailable && ( +
+ setAutoScan(!!checked)} + /> + +
+ )} +
+ + +
- {trivyAvailable && ( -
- setAutoScan(!!checked)} - /> - -
- )} + + ); + })} + +
+ + ); +} diff --git a/frontend/src/components/appstore/FeaturedHero.tsx b/frontend/src/components/appstore/FeaturedHero.tsx new file mode 100644 index 00000000..b1a867ac --- /dev/null +++ b/frontend/src/components/appstore/FeaturedHero.tsx @@ -0,0 +1,57 @@ +import { Button } from '@/components/ui/button'; +import { Rocket } from 'lucide-react'; +import { TemplateLogo } from './TemplateLogo'; +import { firstSentence } from './util'; +import type { Template } from './types'; + +interface FeaturedHeroProps { + template: Template; + category?: string; + onOpen: (t: Template) => void; + imgError: boolean; + onImgError: () => void; +} + +export function FeaturedHero({ template, category, onOpen, imgError, onImgError }: FeaturedHeroProps) { + const pitch = firstSentence(template.description); + + return ( +
+
+
+
+ + +
+ + Featured{category ? ` · ${category}` : ''} + + + {template.title} + + {pitch ? ( + + {pitch}. + + ) : null} +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/appstore/TemplateLogo.tsx b/frontend/src/components/appstore/TemplateLogo.tsx new file mode 100644 index 00000000..653b0e8e --- /dev/null +++ b/frontend/src/components/appstore/TemplateLogo.tsx @@ -0,0 +1,53 @@ +import { cn } from '@/lib/utils'; + +interface TemplateLogoProps { + logo?: string; + title: string; + size: 'sm' | 'lg'; + imgError: boolean; + onImgError: () => void; +} + +const SIZE_CLASS = { + sm: 'h-9 w-9 rounded-sm', + lg: 'h-20 w-20 rounded-md', +} as const; + +const LETTER_CLASS = { + sm: 'text-lg', + lg: 'text-5xl', +} as const; + +const PAD_CLASS = { + sm: 'p-1', + lg: 'p-3', +} as const; + +export function TemplateLogo({ logo, title, size, imgError, onImgError }: TemplateLogoProps) { + const firstLetter = title.charAt(0).toUpperCase(); + + return ( +
+ {logo && !imgError ? ( + {title} + ) : ( + + {firstLetter} + + )} +
+ ); +} diff --git a/frontend/src/components/appstore/TemplateTile.tsx b/frontend/src/components/appstore/TemplateTile.tsx new file mode 100644 index 00000000..b3649a06 --- /dev/null +++ b/frontend/src/components/appstore/TemplateTile.tsx @@ -0,0 +1,83 @@ +import { Star } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { TemplateLogo } from './TemplateLogo'; +import { firstSentence } from './util'; +import type { Template, ScanStatus } from './types'; + +interface TemplateTileProps { + template: Template; + onSelect: (t: Template) => void; + imgError: boolean; + onImgError: () => void; +} + +function ScanBadge({ status, cveCount }: { status: ScanStatus; cveCount: number }) { + if (status === 'clean') { + return ( + + Clean + + ); + } + if (status === 'vulnerable') { + return ( + + {cveCount} CVE{cveCount === 1 ? '' : 's'} + + ); + } + return ( + + Unscanned + + ); +} + +export function TemplateTile({ template, onSelect, imgError, onImgError }: TemplateTileProps) { + const status: ScanStatus = template.scan_status ?? 'unscanned'; + const cveCount = template.scan_cve_count ?? 0; + const pitch = firstSentence(template.description); + + return ( + + ); +} diff --git a/frontend/src/components/appstore/types.ts b/frontend/src/components/appstore/types.ts new file mode 100644 index 00000000..229ea837 --- /dev/null +++ b/frontend/src/components/appstore/types.ts @@ -0,0 +1,34 @@ +export interface TemplateEnv { + name: string; + label?: string; + default?: string; +} + +export interface TemplateVolume { + container?: string; + bind?: string; +} + +export type ScanStatus = 'clean' | 'vulnerable' | 'unscanned'; + +export interface Template { + type?: number; + title: string; + description: string; + logo?: string; + image?: string; + ports?: string[]; + volumes?: TemplateVolume[]; + env?: TemplateEnv[]; + categories?: string[]; + github_url?: string; + docs_url?: string; + architectures?: string[]; + stars?: number; + source?: string; + scan_status?: ScanStatus; + scan_cve_count?: number; + scan_critical_count?: number; + scan_high_count?: number; + featured?: boolean; +} diff --git a/frontend/src/components/appstore/util.ts b/frontend/src/components/appstore/util.ts new file mode 100644 index 00000000..55129b64 --- /dev/null +++ b/frontend/src/components/appstore/util.ts @@ -0,0 +1,4 @@ +export function firstSentence(text?: string): string | undefined { + if (!text) return undefined; + return text.split(/[.!?]/)[0]?.trim() || undefined; +}