Skip to content

Commit ae93b0c

Browse files
committed
style: add page banner and refactor container to use common header across the app
1 parent 713e831 commit ae93b0c

File tree

2 files changed

+236
-39
lines changed

2 files changed

+236
-39
lines changed

view/app/containers/page.tsx

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import React from 'react';
4-
import { RefreshCw, Trash2, Loader2, Scissors, Grid, List } from 'lucide-react';
4+
import { RefreshCw, Trash2, Loader2, Scissors, Grid, List, Puzzle, Container } from 'lucide-react';
55
import { Button } from '@/components/ui/button';
66
import ContainersLoading from './components/skeleton';
77
import { DeleteDialog } from '@/components/ui/delete-dialog';
@@ -17,6 +17,7 @@ import PaginationWrapper from '@/components/ui/pagination';
1717
import { SelectWrapper, SelectOption } from '@/components/ui/select-wrapper';
1818
import { SearchBar } from '@/components/ui/search-bar';
1919
import { ContainerCard } from './components/card';
20+
import { Banner } from '@/components/layout/page-banner';
2021

2122
export default function ContainersPage() {
2223
const [viewMode, setViewMode] = React.useState<'table' | 'card'>(() => {
@@ -77,42 +78,16 @@ export default function ContainersPage() {
7778
return (
7879
<ResourceGuard resource="container" action="read" loadingFallback={<ContainersLoading />}>
7980
<PageLayout maxWidth="6xl" padding="md" spacing="lg" className="relative z-10">
80-
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
81-
<span>
82-
<TypographyH1 className="text-2xl font-bold">{t('containers.title')}</TypographyH1>
83-
</span>
84-
<div className="flex items-center gap-2 flex-wrap">
85-
<Button
86-
onClick={handleRefresh}
87-
variant="outline"
88-
size="sm"
89-
disabled={isRefreshing || isFetching}
90-
>
91-
{isRefreshing || isFetching ? (
92-
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
93-
) : (
94-
<RefreshCw className="mr-2 h-4 w-4" />
95-
)}
96-
{t('containers.refresh')}
97-
</Button>
98-
<AnyPermissionGuard
99-
permissions={['container:delete']}
100-
loadingFallback={<Skeleton className="h-8 w-20" />}
101-
>
102-
<Button variant="outline" size="sm" onClick={() => setShowPruneImagesConfirm(true)}>
103-
<Trash2 className="mr-2 h-4 w-4" />
104-
{t('containers.prune_images')}
105-
</Button>
106-
<Button
107-
variant="outline"
108-
size="sm"
109-
onClick={() => setShowPruneBuildCacheConfirm(true)}
110-
>
111-
<Scissors className="mr-2 h-4 w-4" />
112-
{t('containers.prune_build_cache')}
113-
</Button>
114-
</AnyPermissionGuard>
115-
</div>
81+
82+
<div className="w-full mb-6">
83+
<Banner
84+
isLoading={false}
85+
badgeText="beta"
86+
heading={t('containers.title')}
87+
subheading={t('containers.description')}
88+
description="Easily extend functionality with community-built add-ons."
89+
image={<Container className='w-32 h-32 text-primary' />}
90+
/>
11691
</div>
11792

11893
<div className="flex items-center justify-between gap-4 flex-wrap mb-4">
@@ -124,8 +99,41 @@ export default function ContainersPage() {
12499
/>
125100
</div>
126101
<div className="flex items-center gap-2">
102+
<div className="flex items-center gap-2 flex-wrap">
103+
<Button
104+
onClick={handleRefresh}
105+
variant="outline"
106+
size="sm"
107+
disabled={isRefreshing || isFetching}
108+
>
109+
{isRefreshing || isFetching ? (
110+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
111+
) : (
112+
<RefreshCw className="mr-2 h-4 w-4" />
113+
)}
114+
{t('containers.refresh')}
115+
</Button>
116+
<AnyPermissionGuard
117+
permissions={['container:delete']}
118+
loadingFallback={<Skeleton className="h-8 w-20" />}
119+
>
120+
<Button variant="outline" size="sm" onClick={() => setShowPruneImagesConfirm(true)}>
121+
<Trash2 className="mr-2 h-4 w-4" />
122+
{t('containers.prune_images')}
123+
</Button>
124+
<Button
125+
variant="outline"
126+
size="sm"
127+
onClick={() => setShowPruneBuildCacheConfirm(true)}
128+
>
129+
<Scissors className="mr-2 h-4 w-4" />
130+
{t('containers.prune_build_cache')}
131+
</Button>
132+
</AnyPermissionGuard>
133+
</div>
127134
<SelectWrapper
128135
value={String(pageSize)}
136+
size="sm"
129137
onValueChange={(v) => {
130138
const num = parseInt(v, 10);
131139
setPageSize(num);
@@ -140,10 +148,10 @@ export default function ContainersPage() {
140148
placeholder="Page size"
141149
className="w-[110px]"
142150
/>
143-
<div className="hidden sm:flex items-center gap-2 ml-2">
151+
<div className="hidden sm:flex items-center gap-2">
144152
<Button
145153
variant="outline"
146-
size="icon"
154+
size="sm"
147155
onClick={() => {
148156
const next = viewMode === 'table' ? 'card' : 'table';
149157
setViewMode(next);
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { forwardRef } from 'react';
5+
import Image from 'next/image';
6+
import { Slot } from '@radix-ui/react-slot';
7+
import { cva, type VariantProps } from 'class-variance-authority';
8+
import { Skeleton } from '@/components/ui/skeleton';
9+
import { cn } from '@/lib/utils';
10+
import { TypographyH1 } from '../ui/typography';
11+
12+
const bannerContainer = cva(
13+
'relative overflow-hidden rounded-2xl',
14+
{
15+
variants: {
16+
padding: {
17+
sm: 'px-4 py-4',
18+
md: 'px-6 py-6',
19+
},
20+
gradientTone: {
21+
soft: 'bg-gradient-to-br from-primary/20 via-primary/10 to-secondary/20',
22+
none: '',
23+
},
24+
},
25+
defaultVariants: {
26+
padding: 'md',
27+
gradientTone: 'soft',
28+
},
29+
}
30+
);
31+
32+
const bannerLayout = cva(
33+
'relative z-10 flex flex-col items-start justify-between gap-6 md:items-center',
34+
{
35+
variants: {
36+
align: {
37+
right: 'md:flex-row',
38+
left: 'md:flex-row-reverse',
39+
},
40+
},
41+
defaultVariants: {
42+
align: 'right',
43+
},
44+
}
45+
);
46+
47+
type BannerVariants = VariantProps<typeof bannerContainer> & VariantProps<typeof bannerLayout>;
48+
49+
export interface BannerProps extends React.HTMLAttributes<HTMLDivElement>, BannerVariants {
50+
isLoading?: boolean;
51+
asChild?: boolean;
52+
badgeText?: string;
53+
heading?: React.ReactNode;
54+
subheading?: React.ReactNode;
55+
description?: React.ReactNode;
56+
imageSrc?: string;
57+
imageAlt?: string;
58+
image?: React.ReactNode;
59+
gradientClassName?: string;
60+
}
61+
62+
export const Banner = forwardRef<HTMLDivElement, BannerProps>(function Banner(
63+
{
64+
isLoading = false,
65+
asChild,
66+
align = 'right',
67+
padding,
68+
gradientTone = 'soft',
69+
badgeText,
70+
heading,
71+
subheading,
72+
description,
73+
image,
74+
imageSrc = '/plugin.png',
75+
imageAlt = 'Banner image',
76+
className,
77+
gradientClassName,
78+
children,
79+
...props
80+
},
81+
ref
82+
) {
83+
if (isLoading) {
84+
return <BannerSkeleton align={align} padding={padding} gradientTone={gradientTone} className={className} />;
85+
}
86+
87+
const Comp = asChild ? Slot : 'div';
88+
89+
const ImageSection = (
90+
<div className="flex-1">
91+
<div className="relative mx-auto max-w-xs">
92+
<div className="aspect-square">
93+
<div className="flex h-full items-center justify-center">
94+
<div className="relative w-full h-full text-center">
95+
{image ? (
96+
<div className="flex items-center justify-center w-full h-full">
97+
{image}
98+
</div>
99+
) : (
100+
<Image
101+
src={imageSrc}
102+
alt={imageAlt}
103+
className="object-contain"
104+
fill
105+
sizes="(min-width: 768px) 320px, 50vw"
106+
priority={false}
107+
/>
108+
)}
109+
</div>
110+
</div>
111+
</div>
112+
</div>
113+
</div>
114+
);
115+
116+
const DefaultText = (
117+
<div className="flex-1 space-y-4">
118+
{badgeText ? (
119+
<div className="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
120+
{badgeText}
121+
</div>
122+
) : null}
123+
{heading ? (
124+
<TypographyH1 className="text-xl font-bold tracking-tight md:text-2xl lg:text-3xl">
125+
{heading}
126+
</TypographyH1>
127+
) : null}
128+
{subheading ? (
129+
<p className="text-sm text-muted-foreground md:text-base">
130+
{subheading}
131+
</p>
132+
) : null}
133+
{description ? (
134+
<p className="text-xs md:text-sm text-muted-foreground/80">
135+
{description}
136+
</p>
137+
) : null}
138+
</div>
139+
);
140+
141+
return (
142+
<Comp
143+
ref={ref}
144+
className={cn(
145+
bannerContainer({ padding, gradientTone }),
146+
gradientClassName,
147+
className
148+
)}
149+
{...props}
150+
>
151+
<div className={bannerLayout({ align })}>
152+
{children ?? DefaultText}
153+
{ImageSection}
154+
</div>
155+
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-background/5 to-transparent" />
156+
</Comp>
157+
);
158+
});
159+
160+
interface BannerSkeletonProps
161+
extends React.HTMLAttributes<HTMLDivElement>,
162+
BannerVariants { }
163+
164+
const BannerSkeleton = forwardRef<HTMLDivElement, BannerSkeletonProps>(function BannerSkeleton(
165+
{ align = 'right', padding, gradientTone = 'soft', className, ...props },
166+
ref
167+
) {
168+
return (
169+
<div
170+
ref={ref}
171+
className={cn(bannerContainer({ padding, gradientTone }), className)}
172+
{...props}
173+
>
174+
<div className={bannerLayout({ align })}>
175+
<div className="flex-1 space-y-2">
176+
<Skeleton className="h-5 w-12 rounded-full" />
177+
<Skeleton className="h-6 w-48 md:w-56 lg:w-64" />
178+
<Skeleton className="h-4 w-72 md:w-80" />
179+
<Skeleton className="h-8 w-32 mt-2" />
180+
</div>
181+
<div className="flex-1">
182+
<div className="relative mx-auto max-w-xs aspect-square">
183+
<Skeleton className="w-full h-full rounded-2xl" />
184+
</div>
185+
</div>
186+
</div>
187+
</div>
188+
);
189+
});

0 commit comments

Comments
 (0)