@olwiba/ui 0.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/index.d.ts +762 -0
- package/dist/index.js +2102 -0
- package/dist/index.js.map +1 -0
- package/package.json +88 -0
- package/src/app/AppShell.tsx +365 -0
- package/src/app/AuthSection.tsx +141 -0
- package/src/app/EmptyState.tsx +34 -0
- package/src/app/ErrorPage.tsx +78 -0
- package/src/app/UpgradePrompt.tsx +203 -0
- package/src/blog/PostCard.tsx +66 -0
- package/src/blog/PostList.tsx +27 -0
- package/src/components/ConfirmDialog.tsx +43 -0
- package/src/components/ContextMenu.tsx +86 -0
- package/src/components/DevBanner.tsx +22 -0
- package/src/components/Dock.tsx +94 -0
- package/src/components/FeatureCard.tsx +45 -0
- package/src/components/GlassCard.tsx +30 -0
- package/src/components/ImageCard.tsx +60 -0
- package/src/components/PageHeader.tsx +101 -0
- package/src/components/PricingCard.tsx +90 -0
- package/src/components/RegisterHotkeys.tsx +43 -0
- package/src/components/RootErrorFallback.tsx +29 -0
- package/src/components/Spinner.tsx +14 -0
- package/src/components/Spotlight.tsx +104 -0
- package/src/components/StatCard.tsx +44 -0
- package/src/components/Suspensed.tsx +17 -0
- package/src/components/TestimonialCard.tsx +64 -0
- package/src/components/ThemeColorUpdater.tsx +23 -0
- package/src/components/ThemeSwitchMinimal.tsx +28 -0
- package/src/components/VersionBanner.tsx +38 -0
- package/src/context/OlwibaUIContext.tsx +58 -0
- package/src/hooks/use-confirm.ts +64 -0
- package/src/hooks/use-controlled-open.ts +33 -0
- package/src/hooks/use-copy-to-clipboard.ts +20 -0
- package/src/hooks/use-debounce.ts +14 -0
- package/src/hooks/use-intersection-observer.ts +25 -0
- package/src/hooks/use-local-storage.ts +31 -0
- package/src/hooks/use-media-query.ts +21 -0
- package/src/hooks/use-mounted.ts +11 -0
- package/src/hooks/use-pagination.ts +31 -0
- package/src/hooks/use-scrolled-past.ts +27 -0
- package/src/index.ts +113 -0
- package/src/lib/utils.ts +6 -0
- package/src/marketing/ContactSection.tsx +110 -0
- package/src/marketing/CtaSection.tsx +74 -0
- package/src/marketing/FaqSection.tsx +44 -0
- package/src/marketing/FeaturesSection.tsx +42 -0
- package/src/marketing/Footer.tsx +87 -0
- package/src/marketing/HeroSection.tsx +112 -0
- package/src/marketing/LogoStrip.tsx +94 -0
- package/src/marketing/Navbar.tsx +142 -0
- package/src/marketing/NewsletterSection.tsx +64 -0
- package/src/marketing/PricingSection.tsx +120 -0
- package/src/marketing/SectionTitle.tsx +30 -0
- package/src/marketing/StatsSection.tsx +63 -0
- package/src/marketing/TeamSection.tsx +110 -0
- package/src/marketing/TestimonialsSection.tsx +56 -0
- package/src/motion/CountUp.tsx +64 -0
- package/src/motion/FadeIn.tsx +69 -0
- package/src/motion/PageTransition.tsx +49 -0
- package/src/motion/StaggerChildren.tsx +74 -0
- package/src/overlays/Overlay.tsx +88 -0
- package/src/overlays/Underlay.tsx +114 -0
- package/src/primitives/Badge.tsx +11 -0
- package/src/primitives/Button.tsx +16 -0
- package/src/primitives/Card.tsx +22 -0
- package/src/primitives/Checkbox.tsx +17 -0
- package/src/primitives/Input.tsx +16 -0
- package/src/primitives/Switch.tsx +16 -0
- package/src/primitives/Textarea.tsx +16 -0
- package/src/primitives/index.ts +7 -0
- package/src/types/external-packages.d.ts +79 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '@olwiba/cn';
|
|
5
|
+
|
|
6
|
+
export interface GlassCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
blur?: 'sm' | 'md' | 'lg';
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const blurClass = {
|
|
12
|
+
sm: 'backdrop-blur-sm',
|
|
13
|
+
md: 'backdrop-blur-md',
|
|
14
|
+
lg: 'backdrop-blur-lg',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function GlassCard({ blur = 'md', className, children, ...props }: GlassCardProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={cn(
|
|
21
|
+
'rounded-2xl border border-white/20 bg-background/50 shadow-sm',
|
|
22
|
+
blurClass[blur],
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
{...props}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '@olwiba/cn';
|
|
5
|
+
|
|
6
|
+
export interface ImageCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
src: string;
|
|
8
|
+
alt?: string;
|
|
9
|
+
overlay?: 'none' | 'subtle' | 'strong';
|
|
10
|
+
aspectRatio?: 'video' | 'square' | 'portrait' | 'auto';
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const overlayClass = {
|
|
15
|
+
none: '',
|
|
16
|
+
subtle: 'bg-gradient-to-t from-black/60 via-black/10 to-transparent',
|
|
17
|
+
strong: 'bg-gradient-to-t from-black/80 via-black/30 to-black/10',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const aspectClass = {
|
|
21
|
+
video: 'aspect-video',
|
|
22
|
+
square: 'aspect-square',
|
|
23
|
+
portrait: 'aspect-[3/4]',
|
|
24
|
+
auto: '',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function ImageCard({
|
|
28
|
+
src,
|
|
29
|
+
alt = '',
|
|
30
|
+
overlay = 'subtle',
|
|
31
|
+
aspectRatio = 'video',
|
|
32
|
+
children,
|
|
33
|
+
className,
|
|
34
|
+
...props
|
|
35
|
+
}: ImageCardProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={cn(
|
|
39
|
+
'group relative overflow-hidden rounded-2xl bg-muted',
|
|
40
|
+
aspectClass[aspectRatio],
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
{...props}
|
|
44
|
+
>
|
|
45
|
+
<img
|
|
46
|
+
src={src}
|
|
47
|
+
alt={alt}
|
|
48
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
49
|
+
/>
|
|
50
|
+
{overlay !== 'none' && (
|
|
51
|
+
<div className={cn('absolute inset-0', overlayClass[overlay])} />
|
|
52
|
+
)}
|
|
53
|
+
{children && (
|
|
54
|
+
<div className="absolute inset-0 flex flex-col justify-end p-5 text-white">
|
|
55
|
+
{children}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
5
|
+
import { cn } from '../lib/utils';
|
|
6
|
+
import type { AppShellRenderLink } from '../app/AppShell';
|
|
7
|
+
|
|
8
|
+
export interface PageHeaderBreadcrumb {
|
|
9
|
+
label: string;
|
|
10
|
+
href?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PageHeaderBackButton {
|
|
14
|
+
label?: string;
|
|
15
|
+
href?: string;
|
|
16
|
+
onClick?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PageHeaderProps {
|
|
20
|
+
title: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
breadcrumbs?: PageHeaderBreadcrumb[];
|
|
23
|
+
backButton?: PageHeaderBackButton;
|
|
24
|
+
renderLink?: AppShellRenderLink;
|
|
25
|
+
className?: string;
|
|
26
|
+
children?: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
|
|
30
|
+
<a href={href} className={className}>{children}</a>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export function PageHeader({
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
breadcrumbs,
|
|
37
|
+
backButton,
|
|
38
|
+
renderLink = defaultRenderLink,
|
|
39
|
+
className,
|
|
40
|
+
children,
|
|
41
|
+
}: PageHeaderProps) {
|
|
42
|
+
return (
|
|
43
|
+
<div className={cn('flex flex-col gap-1 pb-6', className)}>
|
|
44
|
+
{breadcrumbs && breadcrumbs.length > 0 && (
|
|
45
|
+
<nav aria-label="Breadcrumb" className="mb-2 flex items-center gap-1 text-sm text-muted-foreground">
|
|
46
|
+
{breadcrumbs.map((crumb, i) => {
|
|
47
|
+
const isLast = i === breadcrumbs.length - 1;
|
|
48
|
+
return (
|
|
49
|
+
<React.Fragment key={i}>
|
|
50
|
+
{i > 0 && <ChevronRight className="size-3.5 shrink-0" />}
|
|
51
|
+
{isLast || !crumb.href ? (
|
|
52
|
+
<span className={cn(isLast && 'text-foreground font-medium')}>{crumb.label}</span>
|
|
53
|
+
) : (
|
|
54
|
+
renderLink({
|
|
55
|
+
href: crumb.href,
|
|
56
|
+
children: crumb.label,
|
|
57
|
+
className: 'hover:text-foreground transition-colors',
|
|
58
|
+
})
|
|
59
|
+
)}
|
|
60
|
+
</React.Fragment>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</nav>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
{backButton && (
|
|
67
|
+
<div className="mb-2">
|
|
68
|
+
{backButton.href ? (
|
|
69
|
+
renderLink({
|
|
70
|
+
href: backButton.href,
|
|
71
|
+
children: (
|
|
72
|
+
<>
|
|
73
|
+
<ChevronLeft className="size-4" />
|
|
74
|
+
<span>{backButton.label ?? 'Back'}</span>
|
|
75
|
+
</>
|
|
76
|
+
),
|
|
77
|
+
className: 'inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors',
|
|
78
|
+
})
|
|
79
|
+
) : (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={backButton.onClick}
|
|
83
|
+
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
84
|
+
>
|
|
85
|
+
<ChevronLeft className="size-4" />
|
|
86
|
+
<span>{backButton.label ?? 'Back'}</span>
|
|
87
|
+
</button>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
<div className="flex items-start justify-between gap-4">
|
|
93
|
+
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
94
|
+
{children && <div className="flex shrink-0 items-center gap-2">{children}</div>}
|
|
95
|
+
</div>
|
|
96
|
+
{description && (
|
|
97
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Check, Minus } from 'lucide-react';
|
|
5
|
+
import { Badge, Button, cn, Separator } from '@olwiba/cn';
|
|
6
|
+
|
|
7
|
+
export interface PricingFeature {
|
|
8
|
+
label: string;
|
|
9
|
+
included: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PricingCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
13
|
+
name: string;
|
|
14
|
+
price: string;
|
|
15
|
+
period?: string;
|
|
16
|
+
description: string;
|
|
17
|
+
features: PricingFeature[];
|
|
18
|
+
cta: string;
|
|
19
|
+
highlighted?: boolean;
|
|
20
|
+
badge?: string;
|
|
21
|
+
onSelect?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function PricingCard({
|
|
25
|
+
name,
|
|
26
|
+
price,
|
|
27
|
+
period = '/mo',
|
|
28
|
+
description,
|
|
29
|
+
features,
|
|
30
|
+
cta,
|
|
31
|
+
highlighted = false,
|
|
32
|
+
badge,
|
|
33
|
+
onSelect,
|
|
34
|
+
className,
|
|
35
|
+
...props
|
|
36
|
+
}: PricingCardProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={cn(
|
|
40
|
+
'relative flex flex-col rounded-2xl border p-6',
|
|
41
|
+
highlighted ? 'border-primary bg-primary/5 shadow-sm' : 'bg-card',
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
{badge && (
|
|
47
|
+
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
48
|
+
<Badge>{badge}</Badge>
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
|
|
52
|
+
<div className="space-y-1">
|
|
53
|
+
<div className="text-sm font-medium text-muted-foreground">{name}</div>
|
|
54
|
+
<div className="flex items-end gap-1">
|
|
55
|
+
<span className="text-4xl font-bold tracking-tight">{price}</span>
|
|
56
|
+
<span className="mb-1 text-sm text-muted-foreground">{period}</span>
|
|
57
|
+
</div>
|
|
58
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<Button
|
|
62
|
+
variant={highlighted ? 'default' : 'outline'}
|
|
63
|
+
className="mt-6 w-full"
|
|
64
|
+
onClick={onSelect}
|
|
65
|
+
>
|
|
66
|
+
{cta}
|
|
67
|
+
</Button>
|
|
68
|
+
|
|
69
|
+
<Separator className="my-6" />
|
|
70
|
+
|
|
71
|
+
<ul className="space-y-3 text-sm">
|
|
72
|
+
{features.map((feat) => (
|
|
73
|
+
<li key={feat.label} className="flex items-center gap-2">
|
|
74
|
+
{feat.included ? (
|
|
75
|
+
<>
|
|
76
|
+
<Check className="size-4 shrink-0 text-primary" />
|
|
77
|
+
<span>{feat.label}</span>
|
|
78
|
+
</>
|
|
79
|
+
) : (
|
|
80
|
+
<>
|
|
81
|
+
<Minus className="size-4 shrink-0 text-muted-foreground/40" />
|
|
82
|
+
<span className="text-muted-foreground/40">{feat.label}</span>
|
|
83
|
+
</>
|
|
84
|
+
)}
|
|
85
|
+
</li>
|
|
86
|
+
))}
|
|
87
|
+
</ul>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
export interface Hotkey {
|
|
6
|
+
key: string;
|
|
7
|
+
ctrl?: boolean;
|
|
8
|
+
meta?: boolean;
|
|
9
|
+
shift?: boolean;
|
|
10
|
+
alt?: boolean;
|
|
11
|
+
handler: () => void;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RegisterHotkeysProps {
|
|
16
|
+
hotkeys: Hotkey[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function RegisterHotkeys({ hotkeys }: RegisterHotkeysProps) {
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
22
|
+
for (const hotkey of hotkeys) {
|
|
23
|
+
const matches =
|
|
24
|
+
event.key.toLowerCase() === hotkey.key.toLowerCase() &&
|
|
25
|
+
!!event.ctrlKey === !!hotkey.ctrl &&
|
|
26
|
+
!!event.metaKey === !!hotkey.meta &&
|
|
27
|
+
!!event.shiftKey === !!hotkey.shift &&
|
|
28
|
+
!!event.altKey === !!hotkey.alt;
|
|
29
|
+
|
|
30
|
+
if (matches) {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
hotkey.handler();
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
39
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
40
|
+
}, [hotkeys]);
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { AlertTriangle } from 'lucide-react';
|
|
5
|
+
import { Button } from '@olwiba/cn';
|
|
6
|
+
|
|
7
|
+
interface RootErrorFallbackProps {
|
|
8
|
+
error: Error;
|
|
9
|
+
resetErrorBoundary?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function RootErrorFallback({ error, resetErrorBoundary }: RootErrorFallbackProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex min-h-svh flex-col items-center justify-center gap-6 p-8 text-center">
|
|
15
|
+
<div className="flex flex-col items-center gap-3">
|
|
16
|
+
<AlertTriangle className="size-12 text-destructive" />
|
|
17
|
+
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
|
18
|
+
<p className="max-w-md text-sm text-muted-foreground">
|
|
19
|
+
{error.message || 'An unexpected error occurred.'}
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
{resetErrorBoundary && (
|
|
23
|
+
<Button onClick={resetErrorBoundary} variant="outline">
|
|
24
|
+
Try again
|
|
25
|
+
</Button>
|
|
26
|
+
)}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Spinner } from '@olwiba/cn';
|
|
2
|
+
|
|
3
|
+
interface FullPageSpinnerProps {
|
|
4
|
+
message?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function FullPageSpinner({ message }: FullPageSpinnerProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-4">
|
|
10
|
+
<Spinner className="size-8" />
|
|
11
|
+
{message && <p className="text-muted-foreground text-sm">{message}</p>}
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Search } from 'lucide-react';
|
|
5
|
+
import {
|
|
6
|
+
Badge,
|
|
7
|
+
Button,
|
|
8
|
+
CommandDialog,
|
|
9
|
+
CommandEmpty,
|
|
10
|
+
CommandGroup,
|
|
11
|
+
CommandInput,
|
|
12
|
+
CommandItem,
|
|
13
|
+
CommandList,
|
|
14
|
+
CommandSeparator,
|
|
15
|
+
cn,
|
|
16
|
+
} from '@olwiba/cn';
|
|
17
|
+
|
|
18
|
+
export interface SpotlightItem {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
icon?: React.ReactNode;
|
|
23
|
+
onSelect: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SpotlightGroup {
|
|
27
|
+
heading: string;
|
|
28
|
+
items: SpotlightItem[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SpotlightProps {
|
|
32
|
+
groups: SpotlightGroup[];
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
trigger?: React.ReactNode;
|
|
35
|
+
triggerClassName?: string;
|
|
36
|
+
shortcut?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Spotlight({
|
|
40
|
+
groups,
|
|
41
|
+
placeholder = 'Search anything...',
|
|
42
|
+
trigger,
|
|
43
|
+
triggerClassName,
|
|
44
|
+
shortcut = '⌘K',
|
|
45
|
+
}: SpotlightProps) {
|
|
46
|
+
const [open, setOpen] = React.useState(false);
|
|
47
|
+
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
const down = (e: KeyboardEvent) => {
|
|
50
|
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
setOpen((v) => !v);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener('keydown', down);
|
|
56
|
+
return () => document.removeEventListener('keydown', down);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
{trigger ? (
|
|
62
|
+
<div onClick={() => setOpen(true)}>{trigger}</div>
|
|
63
|
+
) : (
|
|
64
|
+
<Button
|
|
65
|
+
variant="outline"
|
|
66
|
+
onClick={() => setOpen(true)}
|
|
67
|
+
className={cn('w-64 justify-between text-muted-foreground', triggerClassName)}
|
|
68
|
+
>
|
|
69
|
+
<span className="flex items-center gap-2">
|
|
70
|
+
<Search className="size-4" />
|
|
71
|
+
{placeholder}
|
|
72
|
+
</span>
|
|
73
|
+
<Badge variant="secondary" className="font-mono text-xs">{shortcut}</Badge>
|
|
74
|
+
</Button>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<CommandDialog open={open} onOpenChange={setOpen}>
|
|
78
|
+
<CommandInput placeholder={placeholder} />
|
|
79
|
+
<CommandList>
|
|
80
|
+
<CommandEmpty>No results found.</CommandEmpty>
|
|
81
|
+
{groups.map((group, i) => (
|
|
82
|
+
<React.Fragment key={group.heading}>
|
|
83
|
+
{i > 0 && <CommandSeparator />}
|
|
84
|
+
<CommandGroup heading={group.heading}>
|
|
85
|
+
{group.items.map((item) => (
|
|
86
|
+
<CommandItem
|
|
87
|
+
key={item.id}
|
|
88
|
+
onSelect={() => { item.onSelect(); setOpen(false); }}
|
|
89
|
+
>
|
|
90
|
+
{item.icon && <span className="mr-2">{item.icon}</span>}
|
|
91
|
+
<span>{item.label}</span>
|
|
92
|
+
{item.description && (
|
|
93
|
+
<span className="ml-2 text-xs text-muted-foreground">{item.description}</span>
|
|
94
|
+
)}
|
|
95
|
+
</CommandItem>
|
|
96
|
+
))}
|
|
97
|
+
</CommandGroup>
|
|
98
|
+
</React.Fragment>
|
|
99
|
+
))}
|
|
100
|
+
</CommandList>
|
|
101
|
+
</CommandDialog>
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { TrendingDown, TrendingUp } from 'lucide-react';
|
|
5
|
+
import { cn } from '@olwiba/cn';
|
|
6
|
+
|
|
7
|
+
export interface StatCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
delta?: string;
|
|
11
|
+
trend?: 'up' | 'down' | 'neutral';
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function StatCard({ value, label, delta, trend = 'neutral', description, className, ...props }: StatCardProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={cn('rounded-2xl border bg-card p-5', className)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
<div className="text-sm text-muted-foreground">{label}</div>
|
|
22
|
+
<div className="mt-2 flex items-end justify-between gap-3">
|
|
23
|
+
<div className="text-3xl font-bold tracking-tight">{value}</div>
|
|
24
|
+
{delta && (
|
|
25
|
+
<div
|
|
26
|
+
className={cn(
|
|
27
|
+
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
|
|
28
|
+
trend === 'up' && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
|
29
|
+
trend === 'down' && 'bg-red-500/10 text-red-600 dark:text-red-400',
|
|
30
|
+
trend === 'neutral' && 'bg-muted text-muted-foreground',
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{trend === 'up' && <TrendingUp className="size-3" />}
|
|
34
|
+
{trend === 'down' && <TrendingDown className="size-3" />}
|
|
35
|
+
{delta}
|
|
36
|
+
</div>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
{description && (
|
|
40
|
+
<p className="mt-1.5 text-xs text-muted-foreground">{description}</p>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Spinner } from '@olwiba/cn';
|
|
5
|
+
|
|
6
|
+
interface SuspensedProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
fallback?: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Suspensed({ children, fallback }: SuspensedProps) {
|
|
12
|
+
return (
|
|
13
|
+
<React.Suspense fallback={fallback ?? <Spinner />}>
|
|
14
|
+
{children}
|
|
15
|
+
</React.Suspense>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Quote, Star } from 'lucide-react';
|
|
5
|
+
import { Avatar, AvatarFallback, AvatarImage, cn } from '@olwiba/cn';
|
|
6
|
+
|
|
7
|
+
export interface TestimonialCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
quote: string;
|
|
9
|
+
name: string;
|
|
10
|
+
role: string;
|
|
11
|
+
company?: string;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
initials?: string;
|
|
14
|
+
rating?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function TestimonialCard({
|
|
18
|
+
quote,
|
|
19
|
+
name,
|
|
20
|
+
role,
|
|
21
|
+
company,
|
|
22
|
+
avatar,
|
|
23
|
+
initials,
|
|
24
|
+
rating,
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: TestimonialCardProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className={cn('flex flex-col gap-4 rounded-2xl border bg-card p-6', className)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
<Quote className="size-4 text-muted-foreground/40" />
|
|
34
|
+
{rating != null && (
|
|
35
|
+
<div className="flex gap-0.5">
|
|
36
|
+
{Array.from({ length: 5 }, (_, i) => (
|
|
37
|
+
<Star
|
|
38
|
+
key={i}
|
|
39
|
+
className={cn(
|
|
40
|
+
'size-4',
|
|
41
|
+
i < rating
|
|
42
|
+
? 'fill-current text-amber-400'
|
|
43
|
+
: 'text-muted-foreground/30',
|
|
44
|
+
)}
|
|
45
|
+
/>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
<p className="flex-1 text-sm leading-relaxed">{quote}</p>
|
|
50
|
+
<div className="flex items-center gap-3">
|
|
51
|
+
<Avatar className="size-9 rounded-xl">
|
|
52
|
+
<AvatarImage src={avatar} alt={name} />
|
|
53
|
+
<AvatarFallback className="rounded-xl text-xs">{initials ?? name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
54
|
+
</Avatar>
|
|
55
|
+
<div>
|
|
56
|
+
<div className="text-sm font-medium leading-tight">{name}</div>
|
|
57
|
+
<div className="text-xs text-muted-foreground">
|
|
58
|
+
{role}{company ? ` · ${company}` : ''}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
interface ThemeColorUpdaterProps {
|
|
6
|
+
colorTheme?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Applies a data-theme attribute to the document root when a color theme is selected.
|
|
11
|
+
* Use CSS selectors like `[data-theme="blue"] { --primary: ... }` to apply theme colors.
|
|
12
|
+
*/
|
|
13
|
+
export function ThemeColorUpdater({ colorTheme }: ThemeColorUpdaterProps) {
|
|
14
|
+
React.useEffect(() => {
|
|
15
|
+
if (colorTheme) {
|
|
16
|
+
document.documentElement.setAttribute('data-theme', colorTheme);
|
|
17
|
+
} else {
|
|
18
|
+
document.documentElement.removeAttribute('data-theme');
|
|
19
|
+
}
|
|
20
|
+
}, [colorTheme]);
|
|
21
|
+
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Moon, Sun } from 'lucide-react';
|
|
5
|
+
import { Button } from '@olwiba/cn';
|
|
6
|
+
|
|
7
|
+
export function ThemeSwitchMinimal() {
|
|
8
|
+
const [theme, setThemeState] = React.useState<'light' | 'dark'>('dark');
|
|
9
|
+
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
|
12
|
+
const resolved = stored ?? (document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
|
13
|
+
setThemeState(resolved);
|
|
14
|
+
}, []);
|
|
15
|
+
|
|
16
|
+
const toggle = () => {
|
|
17
|
+
const next = theme === 'dark' ? 'light' : 'dark';
|
|
18
|
+
setThemeState(next);
|
|
19
|
+
localStorage.setItem('theme', next);
|
|
20
|
+
document.documentElement.classList.toggle('dark', next === 'dark');
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Button variant="ghost" size="icon" onClick={toggle} className="size-8" aria-label="Toggle theme">
|
|
25
|
+
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
|
26
|
+
</Button>
|
|
27
|
+
);
|
|
28
|
+
}
|