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:
Anso
2026-04-18 12:55:50 -04:00
committed by GitHub
parent 5f6fdfcba8
commit ec7620675e
14 changed files with 645 additions and 302 deletions
@@ -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>
);
}
+34
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
export function firstSentence(text?: string): string | undefined {
if (!text) return undefined;
return text.split(/[.!?]/)[0]?.trim() || undefined;
}