mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 12:18:59 +00:00
feat(app-store): editorial hero, category rail, security scan signal per tile (#679)
* feat(app-store): editorial hero, category rail, security scan signal per tile Rework the App Store view into an editorial layout with a 180px category rail, a featured-template hero, and compact tiles that surface star counts and vulnerability scan status at a glance. The deploy sheet splits into Essentials (one-click deploy) and Advanced (full port/volume/env control) tabs. Backend enriches /api/templates with scan_status, scan_cve_count, and a featured flag computed from the highest-star template with recorded stars. Scan lookups use a single batched SQL query to avoid N+1 round-trips. * fix(app-store): split firstSentence helper into its own module The firstSentence helper lived alongside the TemplateLogo component, which violates react-refresh/only-export-components — a file that exports a component must not also export non-component values, or Fast Refresh cannot establish an HMR boundary. Move the helper into appstore/util.ts and update the two import sites.
This commit is contained in:
+30
-1
@@ -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' });
|
||||
|
||||
@@ -2508,6 +2508,44 @@ export class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
public getLatestScanSummaryByImageRefs(
|
||||
nodeId: number,
|
||||
imageRefs: string[],
|
||||
): Map<string, { total: number; critical: number; high: number; scannedAt: number }> {
|
||||
const summary = new Map<string, { total: number; critical: number; high: number; scannedAt: number }>();
|
||||
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) {
|
||||
|
||||
+20
-10
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-store/app-store-overview.png" alt="App Store showing a grid of application templates with category filters" />
|
||||
<img src="/images/app-store/app-store-hero.png" alt="App Store with a category sidebar, a featured template hero banner, and editorial tiles with security scan badges" />
|
||||
</Frame>
|
||||
|
||||
## 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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/app-store/app-store-deploy.png" alt="Deployment sheet for heimdall showing stack name, ports, volumes, and environment variables" />
|
||||
<img src="/images/app-store/deploy-sheet-essentials.png" alt="Deployment sheet opened on the Essentials tab, showing the stack name field and a deploy-with-defaults hint" />
|
||||
</Frame>
|
||||
|
||||
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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 216 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 189 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -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<Record<string, string>>({});
|
||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||
const [volVars, setVolVars] = useState<Record<string, string>>({});
|
||||
const [customEnvs, setCustomEnvs] = useState<Array<{ key: string, value: string }>>([]);
|
||||
const [customEnvs, setCustomEnvs] = useState<Array<{ key: string; value: string }>>([]);
|
||||
const [newEnvKey, setNewEnvKey] = useState('');
|
||||
const [portsInUse, setPortsInUse] = useState<Record<string, PortInUseInfo>>({});
|
||||
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<string, string> = {};
|
||||
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<string>();
|
||||
templates.forEach(t => t.categories?.forEach(c => cats.add(c)));
|
||||
return ['All', ...Array.from(cats).sort()];
|
||||
const categoryEntries = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
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 (
|
||||
<div className="flex flex-col h-full space-y-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex flex-col h-full gap-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -270,94 +257,69 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
onChange={(e) => { setSearchQuery(e.target.value); }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-stat-subtitle font-mono tabular-nums shrink-0">
|
||||
{filtered.length} app{filtered.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!loading && categories.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1 flex-1 scrollbar-none">
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={selectedCategory === cat ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="shrink-0 h-7 text-xs px-3 rounded-full"
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
{filtered.length} app{filtered.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
||||
{filtered.map((t, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
className="cursor-pointer flex flex-col overflow-hidden h-full"
|
||||
onClick={() => handleSelectTemplate(t)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-start gap-4 space-y-0">
|
||||
<div className="w-12 h-12 rounded bg-muted/50 p-1 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{t.logo && !imgErrors[t.logo] ? (
|
||||
<img src={t.logo} alt={t.title} className="w-full h-full object-contain" onError={() => setImgErrors(prev => ({ ...prev, [t.logo!]: true }))} />
|
||||
) : (
|
||||
<Rocket className="w-6 h-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate">{t.title}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1 min-h-[40px]">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{t.categories && t.categories.length > 0 && (
|
||||
<CardContent className="pt-0 mt-auto">
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{t.categories.slice(0, 3).map(c => (
|
||||
<Badge
|
||||
variant={selectedCategory === c ? 'default' : 'secondary'}
|
||||
key={c}
|
||||
className="text-[10px] px-1.5 py-0 pb-0.5 cursor-pointer"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedCategory(c); }}
|
||||
>
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-muted-foreground">
|
||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
{templates.length === 0 ? (
|
||||
<p>Registry returned no templates. Check your registry URL in Settings.</p>
|
||||
) : (
|
||||
<p>No apps found matching "{searchQuery}"</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 gap-5">
|
||||
{!loading && categoryEntries.length > 1 && (
|
||||
<CategorySidebar
|
||||
categories={categoryEntries}
|
||||
selected={selectedCategory}
|
||||
onSelect={setSelectedCategory}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 pb-8 pr-4">
|
||||
{featuredTemplate && (
|
||||
<FeaturedHero
|
||||
template={featuredTemplate}
|
||||
category={selectedCategory !== 'All' ? selectedCategory : undefined}
|
||||
onOpen={handleSelectTemplate}
|
||||
imgError={!!featuredTemplate.logo && !!imgErrors[featuredTemplate.logo]}
|
||||
onImgError={() => featuredTemplate.logo && setImgErrors(prev => ({ ...prev, [featuredTemplate.logo!]: true }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gridTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{gridTemplates.map((t, idx) => (
|
||||
<TemplateTile
|
||||
key={`${t.title}-${idx}`}
|
||||
template={t}
|
||||
onSelect={handleSelectTemplate}
|
||||
imgError={!!t.logo && !!imgErrors[t.logo]}
|
||||
onImgError={() => t.logo && setImgErrors(prev => ({ ...prev, [t.logo!]: true }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : !featuredTemplate ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
{templates.length === 0 ? (
|
||||
<p>Registry returned no templates. Check your registry URL in Settings.</p>
|
||||
) : (
|
||||
<p>No apps found matching "{searchQuery}"</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||
<SheetContent className="w-full sm:max-w-md flex flex-col h-full" side="right">
|
||||
{selectedTemplate && (
|
||||
<div className="flex flex-col h-full">
|
||||
<SheetHeader className="mb-6 text-left">
|
||||
<SheetHeader className="mb-4 text-left">
|
||||
{activeNode?.type === 'remote' && (
|
||||
<div className="mb-2">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
@@ -373,10 +335,10 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
<Rocket className="w-8 h-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SheetTitle className="text-xl">{selectedTemplate.title}</SheetTitle>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="text-xl truncate">{selectedTemplate.title}</SheetTitle>
|
||||
<div className="mt-1">
|
||||
<SheetDescription className={isDescExpanded ? "" : "line-clamp-3 text-sm text-muted-foreground"}>
|
||||
<SheetDescription className={isDescExpanded ? '' : 'line-clamp-3 text-sm text-muted-foreground'}>
|
||||
{selectedTemplate.description}
|
||||
</SheetDescription>
|
||||
<span className="text-xs text-primary cursor-pointer hover:underline mt-1 inline-block" onClick={() => setIsDescExpanded(!isDescExpanded)}>
|
||||
@@ -422,157 +384,178 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="flex-1 pr-4 -mx-4 px-4">
|
||||
<div className="space-y-6 pb-8">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stackName" className="font-semibold">
|
||||
Stack Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="stackName"
|
||||
value={stackName}
|
||||
onChange={(e) => setStackName(e.target.value)}
|
||||
placeholder="e.g. my-app"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This determines the directory name and docker project name.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs value={sheetTab} onValueChange={(v) => setSheetTab(v as 'essentials' | 'advanced')} className="flex flex-col flex-1 min-h-0">
|
||||
<TabsList className="self-start mb-3">
|
||||
<TabsTrigger value="essentials">Essentials</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{selectedTemplate.ports && selectedTemplate.ports.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Ports (Host : Container)</h4>
|
||||
{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 (
|
||||
<div key={idx} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={hostPort}
|
||||
onChange={(e) => {
|
||||
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 ? (
|
||||
<CursorProvider>
|
||||
<CursorContainer className="inline-flex items-center gap-2">
|
||||
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
||||
<span className="w-2 h-2 rounded-full bg-warning animate-pulse" />
|
||||
</CursorContainer>
|
||||
<Cursor>
|
||||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||||
</Cursor>
|
||||
<CursorFollow side="bottom" sideOffset={4} align="center" transition={{ stiffness: 400, damping: 40, bounce: 0 }}>
|
||||
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
|
||||
<span className="font-mono tabular-nums text-xs text-stat-value">
|
||||
{conflict.stack !== null
|
||||
? <>Port {hostPort} used by <span className="text-brand">{conflict.stack}</span></>
|
||||
: <>Port {hostPort} used by an external app</>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
) : (
|
||||
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Volumes (Host : Container)</h4>
|
||||
{selectedTemplate.volumes.map((v, idx: number) => {
|
||||
const containerPath = v.container;
|
||||
if (!containerPath) return null;
|
||||
return (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-mono">Container: {containerPath}</Label>
|
||||
<Input
|
||||
value={volVars[containerPath] !== undefined ? volVars[containerPath] : ''}
|
||||
onChange={(e) => setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))}
|
||||
placeholder={`/path/to/host/dir`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate.env && selectedTemplate.env.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Environment Variables</h4>
|
||||
{selectedTemplate.env.map((e, idx) => (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
<Label htmlFor={`env-${e.name}`} className="text-sm">
|
||||
{e.label || e.name}
|
||||
</Label>
|
||||
<Input
|
||||
id={`env-${e.name}`}
|
||||
value={envVars[e.name] !== undefined ? envVars[e.name] : ''}
|
||||
onChange={(ev) => setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))}
|
||||
placeholder={e.default || `Enter value for ${e.name}`}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground font-mono">
|
||||
{e.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold text-sm">Add Custom Variable</h4>
|
||||
{customEnvs.map((ce, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<Input value={ce.key} readOnly className="w-1/3 bg-muted font-mono text-xs" />
|
||||
<Input value={ce.value} readOnly className="flex-1 bg-muted font-mono text-xs" />
|
||||
<Button variant="ghost" size="icon" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" onClick={() => setCustomEnvs(prev => prev.filter((_, i) => i !== idx))}>-</Button>
|
||||
<TabsContent value="essentials" className="flex-1 min-h-0 mt-0">
|
||||
<ScrollArea className="h-full pr-4 -mx-4 px-4">
|
||||
<div className="space-y-4 pb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stackName" className="font-semibold">
|
||||
Stack Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="stackName"
|
||||
value={stackName}
|
||||
onChange={(e) => setStackName(e.target.value)}
|
||||
placeholder="e.g. my-app"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This determines the directory name and docker project name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2.5 text-xs text-stat-subtitle">
|
||||
Deploy with defaults: the template's recommended ports, volumes, and environment variables.
|
||||
Use the Advanced tab to customize.
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="KEY" value={newEnvKey} onChange={e => setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" />
|
||||
<Input placeholder="VALUE" value={newEnvVal} onChange={e => setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" />
|
||||
<Button variant="secondary" onClick={() => {
|
||||
if (newEnvKey.trim()) {
|
||||
if (selectedTemplate?.env?.find(e => e.name === newEnvKey.trim())) {
|
||||
toast.warning(`"${newEnvKey.trim()}" already exists in template defaults. The custom value will override it.`);
|
||||
}
|
||||
setCustomEnvs(prev => [...prev, { key: newEnvKey, value: newEnvVal }]);
|
||||
setNewEnvKey('');
|
||||
setNewEnvVal('');
|
||||
}
|
||||
}}>+</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="flex-1 min-h-0 mt-0">
|
||||
<ScrollArea className="h-full pr-4 -mx-4 px-4">
|
||||
<div className="space-y-6 pb-4">
|
||||
{selectedTemplate.ports && selectedTemplate.ports.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold">Ports (Host : Container)</h4>
|
||||
{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 (
|
||||
<div key={idx} className="flex items-center space-x-2">
|
||||
<Input
|
||||
value={hostPort}
|
||||
onChange={(e) => {
|
||||
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 ? (
|
||||
<CursorProvider>
|
||||
<CursorContainer className="inline-flex items-center gap-2">
|
||||
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
||||
<span className="w-2 h-2 rounded-full bg-warning animate-pulse" />
|
||||
</CursorContainer>
|
||||
<Cursor>
|
||||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||||
</Cursor>
|
||||
<CursorFollow side="bottom" sideOffset={4} align="center" transition={{ stiffness: 400, damping: 40, bounce: 0 }}>
|
||||
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
|
||||
<span className="font-mono tabular-nums text-xs text-stat-value">
|
||||
{conflict.stack !== null
|
||||
? <>Port {hostPort} used by <span className="text-brand">{conflict.stack}</span></>
|
||||
: <>Port {hostPort} used by an external app</>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
) : (
|
||||
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Volumes (Host : Container)</h4>
|
||||
{selectedTemplate.volumes.map((v, idx) => {
|
||||
const containerPath = v.container;
|
||||
if (!containerPath) return null;
|
||||
return (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-mono">Container: {containerPath}</Label>
|
||||
<Input
|
||||
value={volVars[containerPath] !== undefined ? volVars[containerPath] : ''}
|
||||
onChange={(e) => setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))}
|
||||
placeholder="/path/to/host/dir"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate.env && selectedTemplate.env.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold">Environment Variables</h4>
|
||||
{selectedTemplate.env.map((e, idx) => (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
<Label htmlFor={`env-${e.name}`} className="text-sm">
|
||||
{e.label || e.name}
|
||||
</Label>
|
||||
<Input
|
||||
id={`env-${e.name}`}
|
||||
value={envVars[e.name] !== undefined ? envVars[e.name] : ''}
|
||||
onChange={(ev) => setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))}
|
||||
placeholder={e.default || `Enter value for ${e.name}`}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground font-mono">
|
||||
{e.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-semibold text-sm">Add Custom Variable</h4>
|
||||
{customEnvs.map((ce, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<Input value={ce.key} readOnly className="w-1/3 bg-muted font-mono text-xs" />
|
||||
<Input value={ce.value} readOnly className="flex-1 bg-muted font-mono text-xs" />
|
||||
<Button variant="ghost" size="icon" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" onClick={() => setCustomEnvs(prev => prev.filter((_, i) => i !== idx))}>-</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="KEY" value={newEnvKey} onChange={e => setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" />
|
||||
<Input placeholder="VALUE" value={newEnvVal} onChange={e => setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" />
|
||||
<Button variant="secondary" onClick={() => {
|
||||
if (newEnvKey.trim()) {
|
||||
if (selectedTemplate?.env?.find(e => e.name === newEnvKey.trim())) {
|
||||
toast.warning(`"${newEnvKey.trim()}" already exists in template defaults. The custom value will override it.`);
|
||||
}
|
||||
setCustomEnvs(prev => [...prev, { key: newEnvKey, value: newEnvVal }]);
|
||||
setNewEnvKey('');
|
||||
setNewEnvVal('');
|
||||
}
|
||||
}}>+</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trivyAvailable && (
|
||||
<div className="flex items-center gap-2 pt-4 border-t">
|
||||
<Checkbox
|
||||
id="auto-scan"
|
||||
checked={autoScan}
|
||||
onCheckedChange={(checked) => setAutoScan(!!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="auto-scan"
|
||||
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Scan images for vulnerabilities after deploy
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SheetFooter className="pt-4 mt-auto border-t sm:justify-start">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
{trivyAvailable && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="auto-scan"
|
||||
checked={autoScan}
|
||||
onCheckedChange={(checked) => setAutoScan(!!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="auto-scan"
|
||||
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
Scan images for vulnerabilities after deploy
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={isDeploying || !stackName.trim() || !can('stack:create')}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CategoryEntry {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface CategorySidebarProps {
|
||||
categories: CategoryEntry[];
|
||||
selected: string;
|
||||
onSelect: (name: string) => void;
|
||||
}
|
||||
|
||||
export function CategorySidebar({ categories, selected, onSelect }: CategorySidebarProps) {
|
||||
return (
|
||||
<aside className="w-[180px] shrink-0 border-r border-border/60 pr-4">
|
||||
<div className="sticky top-0 flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle px-2 py-1.5">
|
||||
Categories
|
||||
</span>
|
||||
<ul className="flex flex-col">
|
||||
{categories.map(cat => {
|
||||
const isActive = selected === cat.name;
|
||||
return (
|
||||
<li key={cat.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(cat.name)}
|
||||
className={cn(
|
||||
'relative flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left transition-colors',
|
||||
isActive
|
||||
? 'text-brand bg-gradient-to-r from-brand/[0.12] to-transparent'
|
||||
: 'text-foreground/80 hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
{isActive && <span className="absolute inset-y-0 left-0 w-[2px] bg-brand rounded-sm" />}
|
||||
<span className="truncate text-sm">{cat.name}</span>
|
||||
<span className={cn(
|
||||
'shrink-0 font-mono text-[10px] tabular-nums',
|
||||
isActive ? 'text-brand' : 'text-stat-subtitle',
|
||||
)}>
|
||||
{cat.count}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative overflow-hidden rounded-lg border border-brand/25 border-t-brand/35 bg-card shadow-card-bevel">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.10] via-brand/[0.02] to-transparent" />
|
||||
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
|
||||
<div className="relative grid grid-cols-[80px_1fr_auto] items-center gap-5 py-5 pl-7 pr-6">
|
||||
<TemplateLogo
|
||||
logo={template.logo}
|
||||
title={template.title}
|
||||
size="lg"
|
||||
imgError={imgError}
|
||||
onImgError={onImgError}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-brand">
|
||||
Featured{category ? ` · ${category}` : ''}
|
||||
</span>
|
||||
<span className="font-display italic text-3xl leading-tight tracking-tight text-stat-value truncate">
|
||||
{template.title}
|
||||
</span>
|
||||
{pitch ? (
|
||||
<span className="text-sm text-stat-subtitle/90 line-clamp-1">
|
||||
{pitch}.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => onOpen(template)}
|
||||
>
|
||||
<Rocket className="h-4 w-4" strokeWidth={1.5} />
|
||||
Deploy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
SIZE_CLASS[size],
|
||||
'bg-gradient-to-br border border-brand/25 flex items-center justify-center overflow-hidden',
|
||||
size === 'lg'
|
||||
? 'from-brand/40 via-brand/20 to-teal-500/20 border-brand/30'
|
||||
: 'from-brand/25 via-brand/10 to-teal-500/10',
|
||||
)}
|
||||
>
|
||||
{logo && !imgError ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={title}
|
||||
className={cn('w-full h-full object-contain', PAD_CLASS[size])}
|
||||
onError={onImgError}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('font-display italic text-brand leading-none', LETTER_CLASS[size])}>
|
||||
{firstLetter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-success">
|
||||
Clean
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'vulnerable') {
|
||||
return (
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning tabular-nums">
|
||||
{cveCount} CVE{cveCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Unscanned
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(template)}
|
||||
className={cn(
|
||||
'group relative grid grid-cols-[36px_1fr_auto] items-start gap-3 rounded-md border border-card-border border-t-card-border-top bg-card px-3 py-3 text-left shadow-card-bevel transition-colors',
|
||||
'hover:border-t-card-border-hover',
|
||||
)}
|
||||
>
|
||||
<TemplateLogo
|
||||
logo={template.logo}
|
||||
title={template.title}
|
||||
size="sm"
|
||||
imgError={imgError}
|
||||
onImgError={onImgError}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium text-stat-value truncate">{template.title}</span>
|
||||
{pitch ? (
|
||||
<span className="text-xs text-stat-subtitle line-clamp-1">{pitch}.</span>
|
||||
) : null}
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
{typeof template.stars === 'number' && template.stars > 0 ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-stat-subtitle tabular-nums font-mono">
|
||||
<Star className="h-3 w-3 fill-warning text-warning" strokeWidth={1.5} />
|
||||
{template.stars.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
{template.categories?.[0] ? (
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle/80 truncate">
|
||||
{template.categories[0]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start pt-0.5">
|
||||
<ScanBadge status={status} cveCount={cveCount} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function firstSentence(text?: string): string | undefined {
|
||||
if (!text) return undefined;
|
||||
return text.split(/[.!?]/)[0]?.trim() || undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user