mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +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:
@@ -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