@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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/dist/index.d.ts +762 -0
  4. package/dist/index.js +2102 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +88 -0
  7. package/src/app/AppShell.tsx +365 -0
  8. package/src/app/AuthSection.tsx +141 -0
  9. package/src/app/EmptyState.tsx +34 -0
  10. package/src/app/ErrorPage.tsx +78 -0
  11. package/src/app/UpgradePrompt.tsx +203 -0
  12. package/src/blog/PostCard.tsx +66 -0
  13. package/src/blog/PostList.tsx +27 -0
  14. package/src/components/ConfirmDialog.tsx +43 -0
  15. package/src/components/ContextMenu.tsx +86 -0
  16. package/src/components/DevBanner.tsx +22 -0
  17. package/src/components/Dock.tsx +94 -0
  18. package/src/components/FeatureCard.tsx +45 -0
  19. package/src/components/GlassCard.tsx +30 -0
  20. package/src/components/ImageCard.tsx +60 -0
  21. package/src/components/PageHeader.tsx +101 -0
  22. package/src/components/PricingCard.tsx +90 -0
  23. package/src/components/RegisterHotkeys.tsx +43 -0
  24. package/src/components/RootErrorFallback.tsx +29 -0
  25. package/src/components/Spinner.tsx +14 -0
  26. package/src/components/Spotlight.tsx +104 -0
  27. package/src/components/StatCard.tsx +44 -0
  28. package/src/components/Suspensed.tsx +17 -0
  29. package/src/components/TestimonialCard.tsx +64 -0
  30. package/src/components/ThemeColorUpdater.tsx +23 -0
  31. package/src/components/ThemeSwitchMinimal.tsx +28 -0
  32. package/src/components/VersionBanner.tsx +38 -0
  33. package/src/context/OlwibaUIContext.tsx +58 -0
  34. package/src/hooks/use-confirm.ts +64 -0
  35. package/src/hooks/use-controlled-open.ts +33 -0
  36. package/src/hooks/use-copy-to-clipboard.ts +20 -0
  37. package/src/hooks/use-debounce.ts +14 -0
  38. package/src/hooks/use-intersection-observer.ts +25 -0
  39. package/src/hooks/use-local-storage.ts +31 -0
  40. package/src/hooks/use-media-query.ts +21 -0
  41. package/src/hooks/use-mounted.ts +11 -0
  42. package/src/hooks/use-pagination.ts +31 -0
  43. package/src/hooks/use-scrolled-past.ts +27 -0
  44. package/src/index.ts +113 -0
  45. package/src/lib/utils.ts +6 -0
  46. package/src/marketing/ContactSection.tsx +110 -0
  47. package/src/marketing/CtaSection.tsx +74 -0
  48. package/src/marketing/FaqSection.tsx +44 -0
  49. package/src/marketing/FeaturesSection.tsx +42 -0
  50. package/src/marketing/Footer.tsx +87 -0
  51. package/src/marketing/HeroSection.tsx +112 -0
  52. package/src/marketing/LogoStrip.tsx +94 -0
  53. package/src/marketing/Navbar.tsx +142 -0
  54. package/src/marketing/NewsletterSection.tsx +64 -0
  55. package/src/marketing/PricingSection.tsx +120 -0
  56. package/src/marketing/SectionTitle.tsx +30 -0
  57. package/src/marketing/StatsSection.tsx +63 -0
  58. package/src/marketing/TeamSection.tsx +110 -0
  59. package/src/marketing/TestimonialsSection.tsx +56 -0
  60. package/src/motion/CountUp.tsx +64 -0
  61. package/src/motion/FadeIn.tsx +69 -0
  62. package/src/motion/PageTransition.tsx +49 -0
  63. package/src/motion/StaggerChildren.tsx +74 -0
  64. package/src/overlays/Overlay.tsx +88 -0
  65. package/src/overlays/Underlay.tsx +114 -0
  66. package/src/primitives/Badge.tsx +11 -0
  67. package/src/primitives/Button.tsx +16 -0
  68. package/src/primitives/Card.tsx +22 -0
  69. package/src/primitives/Checkbox.tsx +17 -0
  70. package/src/primitives/Input.tsx +16 -0
  71. package/src/primitives/Switch.tsx +16 -0
  72. package/src/primitives/Textarea.tsx +16 -0
  73. package/src/primitives/index.ts +7 -0
  74. package/src/types/external-packages.d.ts +79 -0
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { ArrowLeft, Compass } from 'lucide-react';
5
+ import { Button } from '../primitives/Button';
6
+ import type { AppShellRenderLink } from './AppShell';
7
+
8
+ export interface ErrorPageProps {
9
+ statusCode?: string;
10
+ title?: string;
11
+ description?: string;
12
+ action?: { label: string; href: string };
13
+ backAction?: { label: string; href?: string; onClick?: () => void };
14
+ renderLink?: AppShellRenderLink;
15
+ }
16
+
17
+ const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
18
+ <a href={href} className={className}>{children}</a>
19
+ );
20
+
21
+ export function ErrorPage({
22
+ statusCode = '404',
23
+ title = 'Page not found',
24
+ description = "The page you're looking for doesn't exist or has been moved. Check the URL or head back home.",
25
+ action = { label: 'Take me home', href: '/' },
26
+ backAction = { label: 'Go back' },
27
+ renderLink = defaultRenderLink,
28
+ }: ErrorPageProps) {
29
+ return (
30
+ <section className="overflow-hidden rounded-2xl border bg-card">
31
+ <div className="relative flex min-h-[560px] flex-col items-center justify-center px-6 py-16 text-center">
32
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,hsl(var(--muted)/0.8),transparent_70%)]" />
33
+ <div className="relative space-y-6">
34
+ <div className="flex justify-center">
35
+ <div className="flex h-16 w-16 items-center justify-center rounded-2xl border bg-muted">
36
+ <Compass className="size-8 text-muted-foreground" />
37
+ </div>
38
+ </div>
39
+
40
+ <div className="space-y-2">
41
+ <div className="text-8xl font-bold tracking-tight text-muted-foreground/20">{statusCode}</div>
42
+ <h1 className="-mt-4 text-2xl font-semibold tracking-tight">{title}</h1>
43
+ <p className="mx-auto max-w-sm text-sm text-muted-foreground">
44
+ {description}
45
+ </p>
46
+ </div>
47
+
48
+ <div className="flex flex-wrap items-center justify-center gap-3">
49
+ {backAction && (
50
+ backAction.href ? (
51
+ renderLink({
52
+ href: backAction.href,
53
+ children: (
54
+ <Button variant="outline">
55
+ <ArrowLeft className="mr-2 size-4" />
56
+ {backAction.label}
57
+ </Button>
58
+ ),
59
+ })
60
+ ) : (
61
+ <Button variant="outline" onClick={backAction.onClick}>
62
+ <ArrowLeft className="mr-2 size-4" />
63
+ {backAction.label}
64
+ </Button>
65
+ )
66
+ )}
67
+ {action && (
68
+ renderLink({
69
+ href: action.href,
70
+ children: <Button>{action.label}</Button>,
71
+ })
72
+ )}
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </section>
77
+ );
78
+ }
@@ -0,0 +1,203 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { X } from 'lucide-react';
5
+ import {
6
+ Button,
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ cn,
14
+ } from '@olwiba/cn';
15
+
16
+ export interface UpgradeComparisonRow {
17
+ key?: React.Key;
18
+ label: React.ReactNode;
19
+ currentValue: React.ReactNode;
20
+ upgradedValue: React.ReactNode;
21
+ }
22
+
23
+ export interface UpgradePromptProps {
24
+ /** Layout variant. `banner` is an inline strip; `comparison` is a full feature-table block. */
25
+ variant: 'banner' | 'comparison';
26
+ /** Primary heading shown in both variants. */
27
+ title: React.ReactNode;
28
+ /** Supporting copy shown below the title. */
29
+ description: React.ReactNode;
30
+ /** Label for the primary upgrade CTA. Include pricing here if relevant. */
31
+ primaryActionLabel: React.ReactNode;
32
+ /** Called when the primary upgrade CTA is clicked. */
33
+ onPrimaryAction: () => void;
34
+ /** Optional secondary action label, typically for "maybe later" or dismiss behavior. */
35
+ secondaryActionLabel?: React.ReactNode;
36
+ /** Called when the optional secondary action is clicked. */
37
+ onSecondaryAction?: () => void;
38
+ /** Small eyebrow label shown above the title in the comparison variant. */
39
+ eyebrow?: React.ReactNode;
40
+ /** Feature comparison rows for the comparison variant. */
41
+ rows?: UpgradeComparisonRow[];
42
+ /** Column header for the current plan. Defaults to "Current". */
43
+ currentPlanLabel?: React.ReactNode;
44
+ /** Column header for the upgraded plan. Defaults to "Upgrade". */
45
+ upgradedPlanLabel?: React.ReactNode;
46
+ /** Small body text shown below the comparison table, before the action buttons. */
47
+ footer?: React.ReactNode;
48
+ /** Optional close icon action for the comparison variant. */
49
+ onClose?: () => void;
50
+ /** Accessible label for the comparison close icon. Defaults to "Close". */
51
+ closeLabel?: string;
52
+ className?: string;
53
+ }
54
+
55
+ export function UpgradePrompt({
56
+ variant,
57
+ title,
58
+ description,
59
+ primaryActionLabel,
60
+ onPrimaryAction,
61
+ secondaryActionLabel,
62
+ onSecondaryAction,
63
+ eyebrow,
64
+ rows,
65
+ currentPlanLabel = 'Current',
66
+ upgradedPlanLabel = 'Upgrade',
67
+ footer,
68
+ onClose,
69
+ closeLabel = 'Close',
70
+ className,
71
+ }: UpgradePromptProps) {
72
+ const hasSecondaryAction = Boolean(secondaryActionLabel && onSecondaryAction);
73
+
74
+ if (variant === 'banner') {
75
+ return (
76
+ <section
77
+ className={cn(
78
+ 'rounded-xl border border-dashed bg-card/40 p-5 sm:p-6',
79
+ className,
80
+ )}
81
+ >
82
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
83
+ <div>
84
+ <h3 className="text-xl font-semibold">{title}</h3>
85
+ <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
86
+ {description}
87
+ </p>
88
+ </div>
89
+
90
+ <div className="flex flex-wrap gap-2">
91
+ {hasSecondaryAction ? (
92
+ <Button
93
+ type="button"
94
+ variant="ghost"
95
+ onClick={onSecondaryAction}
96
+ className="border"
97
+ >
98
+ {secondaryActionLabel}
99
+ </Button>
100
+ ) : null}
101
+ <Button type="button" onClick={onPrimaryAction}>
102
+ {primaryActionLabel}
103
+ </Button>
104
+ </div>
105
+ </div>
106
+ </section>
107
+ );
108
+ }
109
+
110
+ return (
111
+ <section
112
+ className={cn(
113
+ 'w-full max-w-3xl rounded-2xl border bg-card p-6 shadow-sm sm:p-7',
114
+ className,
115
+ )}
116
+ >
117
+ <div className="flex items-start justify-between gap-4">
118
+ <div>
119
+ {eyebrow ? (
120
+ <div className="font-mono text-[11px] uppercase tracking-[0.08em] text-primary">
121
+ {eyebrow}
122
+ </div>
123
+ ) : null}
124
+ <h3
125
+ className={cn(
126
+ 'text-2xl font-semibold sm:text-[1.75rem]',
127
+ eyebrow ? 'mt-3' : '',
128
+ )}
129
+ >
130
+ {title}
131
+ </h3>
132
+ <p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
133
+ {description}
134
+ </p>
135
+ </div>
136
+
137
+ {onClose ? (
138
+ <button
139
+ type="button"
140
+ onClick={onClose}
141
+ className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border transition-colors hover:bg-muted"
142
+ aria-label={closeLabel}
143
+ >
144
+ <X className="size-4" />
145
+ </button>
146
+ ) : null}
147
+ </div>
148
+
149
+ {rows && rows.length > 0 ? (
150
+ <div className="mt-6 overflow-hidden rounded-xl border bg-muted/30">
151
+ <Table>
152
+ <TableHeader>
153
+ <TableRow>
154
+ <TableHead />
155
+ <TableHead className="font-mono text-xs uppercase tracking-wider text-muted-foreground">
156
+ {currentPlanLabel}
157
+ </TableHead>
158
+ <TableHead className="font-mono text-xs uppercase tracking-wider text-primary">
159
+ {upgradedPlanLabel}
160
+ </TableHead>
161
+ </TableRow>
162
+ </TableHeader>
163
+ <TableBody>
164
+ {rows.map((row, index) => (
165
+ <TableRow key={row.key ?? index}>
166
+ <TableCell className="font-medium">{row.label}</TableCell>
167
+ <TableCell className="font-mono text-muted-foreground">
168
+ {row.currentValue}
169
+ </TableCell>
170
+ <TableCell className="font-mono">{row.upgradedValue}</TableCell>
171
+ </TableRow>
172
+ ))}
173
+ </TableBody>
174
+ </Table>
175
+ </div>
176
+ ) : null}
177
+
178
+ <div
179
+ className={cn(
180
+ 'mt-6 flex flex-col gap-3 sm:flex-row sm:items-center',
181
+ footer || hasSecondaryAction ? 'sm:justify-between' : 'sm:justify-end',
182
+ )}
183
+ >
184
+ {footer ? <p className="text-sm text-muted-foreground">{footer}</p> : null}
185
+ <div className="flex flex-wrap gap-2">
186
+ {hasSecondaryAction ? (
187
+ <Button
188
+ type="button"
189
+ variant="ghost"
190
+ onClick={onSecondaryAction}
191
+ className="border"
192
+ >
193
+ {secondaryActionLabel}
194
+ </Button>
195
+ ) : null}
196
+ <Button type="button" onClick={onPrimaryAction}>
197
+ {primaryActionLabel}
198
+ </Button>
199
+ </div>
200
+ </div>
201
+ </section>
202
+ );
203
+ }
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Badge, cn } from '@olwiba/cn';
5
+ import type { AppShellRenderLink } from '../app/AppShell';
6
+
7
+ export interface PostCardProps {
8
+ title: string;
9
+ description?: string;
10
+ date: string;
11
+ slug: string;
12
+ image?: string;
13
+ tags?: string[];
14
+ renderLink?: AppShellRenderLink;
15
+ }
16
+
17
+ function formatDate(dateStr: string) {
18
+ return new Date(dateStr).toLocaleDateString('en-US', {
19
+ year: 'numeric',
20
+ month: 'long',
21
+ day: 'numeric',
22
+ });
23
+ }
24
+
25
+ export function PostCard({ title, description, date, slug, image, tags, renderLink }: PostCardProps) {
26
+ const href = `/blog/${slug}`;
27
+
28
+ const LinkWrapper = ({ children, className }: { children: React.ReactNode; className?: string }) =>
29
+ renderLink ? renderLink({ href, children, className }) : <a href={href} className={className}>{children}</a>;
30
+
31
+ return (
32
+ <article className="group flex flex-col gap-3">
33
+ {image && (
34
+ <LinkWrapper className="block overflow-hidden rounded-xl">
35
+ <img
36
+ src={image}
37
+ alt={title}
38
+ className="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-105"
39
+ />
40
+ </LinkWrapper>
41
+ )}
42
+ <div className="flex flex-col gap-2">
43
+ {tags && tags.length > 0 && (
44
+ <div className="flex flex-wrap gap-1.5">
45
+ {tags.map((tag) => (
46
+ <Badge key={tag} variant="secondary" className="text-xs">
47
+ {tag}
48
+ </Badge>
49
+ ))}
50
+ </div>
51
+ )}
52
+ <h2 className="text-lg font-semibold leading-snug tracking-tight">
53
+ <LinkWrapper className="hover:text-primary transition-colors">
54
+ {title}
55
+ </LinkWrapper>
56
+ </h2>
57
+ {description && (
58
+ <p className="line-clamp-2 text-sm text-muted-foreground">{description}</p>
59
+ )}
60
+ <time dateTime={date} className="text-xs text-muted-foreground">
61
+ {formatDate(date)}
62
+ </time>
63
+ </div>
64
+ </article>
65
+ );
66
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { PostCard, type PostCardProps } from './PostCard';
5
+ import type { AppShellRenderLink } from '../app/AppShell';
6
+
7
+ export interface PostListProps {
8
+ posts: PostCardProps[];
9
+ renderLink?: AppShellRenderLink;
10
+ emptyMessage?: string;
11
+ }
12
+
13
+ export function PostList({ posts, renderLink, emptyMessage = 'No posts published yet.' }: PostListProps) {
14
+ if (posts.length === 0) {
15
+ return (
16
+ <p className="text-sm text-muted-foreground italic">{emptyMessage}</p>
17
+ );
18
+ }
19
+
20
+ return (
21
+ <div className="grid gap-8 sm:grid-cols-2">
22
+ {posts.map((post) => (
23
+ <PostCard key={post.slug} {...post} renderLink={renderLink} />
24
+ ))}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ } from '@olwiba/cn';
13
+ import type { UseConfirmReturn } from '../hooks/use-confirm';
14
+
15
+ export interface ConfirmDialogProps extends Pick<UseConfirmReturn, 'isOpen' | 'options' | 'handleConfirm' | 'handleCancel'> {
16
+ destructive?: boolean;
17
+ }
18
+
19
+ export function ConfirmDialog({ isOpen, options, handleConfirm, handleCancel, destructive = false }: ConfirmDialogProps) {
20
+ return (
21
+ <AlertDialog open={isOpen} onOpenChange={(open) => { if (!open) handleCancel(); }}>
22
+ <AlertDialogContent>
23
+ <AlertDialogHeader>
24
+ <AlertDialogTitle>{options.title ?? 'Are you sure?'}</AlertDialogTitle>
25
+ {options.description && (
26
+ <AlertDialogDescription>{options.description}</AlertDialogDescription>
27
+ )}
28
+ </AlertDialogHeader>
29
+ <AlertDialogFooter>
30
+ <AlertDialogCancel onClick={handleCancel}>
31
+ {options.cancelLabel ?? 'Cancel'}
32
+ </AlertDialogCancel>
33
+ <AlertDialogAction
34
+ onClick={handleConfirm}
35
+ className={destructive ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : undefined}
36
+ >
37
+ {options.confirmLabel ?? 'Confirm'}
38
+ </AlertDialogAction>
39
+ </AlertDialogFooter>
40
+ </AlertDialogContent>
41
+ </AlertDialog>
42
+ );
43
+ }
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ ContextMenuCheckboxItem,
6
+ ContextMenuContent,
7
+ ContextMenuItem,
8
+ ContextMenuLabel,
9
+ ContextMenuRadioGroup,
10
+ ContextMenuRadioItem,
11
+ ContextMenuSeparator,
12
+ ContextMenuShortcut,
13
+ ContextMenuSub,
14
+ ContextMenuSubContent,
15
+ ContextMenuSubTrigger,
16
+ ContextMenuTrigger,
17
+ ContextMenu as RadixContextMenu,
18
+ } from '@olwiba/cn';
19
+
20
+ export interface ContextMenuAction {
21
+ type: 'item';
22
+ label: string;
23
+ shortcut?: string;
24
+ disabled?: boolean;
25
+ onSelect?: () => void;
26
+ }
27
+
28
+ export interface ContextMenuSeparatorDef {
29
+ type: 'separator';
30
+ }
31
+
32
+ export interface ContextMenuLabelDef {
33
+ type: 'label';
34
+ label: string;
35
+ }
36
+
37
+ export interface ContextMenuSubDef {
38
+ type: 'sub';
39
+ label: string;
40
+ items: ContextMenuDef[];
41
+ }
42
+
43
+ export type ContextMenuDef = ContextMenuAction | ContextMenuSeparatorDef | ContextMenuLabelDef | ContextMenuSubDef;
44
+
45
+ export interface ContextMenuProps {
46
+ items: ContextMenuDef[];
47
+ children: React.ReactNode;
48
+ }
49
+
50
+ function renderItems(items: ContextMenuDef[]) {
51
+ return items.map((item, i) => {
52
+ if (item.type === 'separator') return <ContextMenuSeparator key={i} />;
53
+ if (item.type === 'label') return <ContextMenuLabel key={i}>{item.label}</ContextMenuLabel>;
54
+ if (item.type === 'sub') {
55
+ return (
56
+ <ContextMenuSub key={i}>
57
+ <ContextMenuSubTrigger>{item.label}</ContextMenuSubTrigger>
58
+ <ContextMenuSubContent>{renderItems(item.items)}</ContextMenuSubContent>
59
+ </ContextMenuSub>
60
+ );
61
+ }
62
+ return (
63
+ <ContextMenuItem key={i} disabled={item.disabled} onSelect={item.onSelect}>
64
+ {item.label}
65
+ {item.shortcut && <ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>}
66
+ </ContextMenuItem>
67
+ );
68
+ });
69
+ }
70
+
71
+ export function ContextMenu({ items, children }: ContextMenuProps) {
72
+ return (
73
+ <RadixContextMenu>
74
+ <ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
75
+ <ContextMenuContent>
76
+ {renderItems(items)}
77
+ </ContextMenuContent>
78
+ </RadixContextMenu>
79
+ );
80
+ }
81
+
82
+ export {
83
+ ContextMenuCheckboxItem,
84
+ ContextMenuRadioGroup,
85
+ ContextMenuRadioItem,
86
+ };
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import { cn } from '../lib/utils';
4
+
5
+ export interface DevBannerProps {
6
+ environment?: string;
7
+ className?: string;
8
+ }
9
+
10
+ export function DevBanner({ environment = 'development', className }: DevBannerProps) {
11
+ return (
12
+ <div
13
+ className={cn(
14
+ 'flex items-center justify-center gap-2 bg-amber-500/15 px-4 py-1 text-xs font-medium text-amber-700 dark:text-amber-400',
15
+ className,
16
+ )}
17
+ >
18
+ <span className="size-1.5 rounded-full bg-amber-500" />
19
+ <span>{environment}</span>
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { LucideIcon } from 'lucide-react';
5
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from '@olwiba/cn';
6
+
7
+ export interface DockItem {
8
+ icon: LucideIcon;
9
+ label: string;
10
+ href?: string;
11
+ onClick?: () => void;
12
+ }
13
+
14
+ export interface DockProps extends React.HTMLAttributes<HTMLDivElement> {
15
+ items: DockItem[];
16
+ iconSize?: number;
17
+ magnification?: number;
18
+ }
19
+
20
+ const BASE = 40;
21
+ const MAGNIFY_RADIUS = 80;
22
+
23
+ export function Dock({ items, iconSize = BASE, magnification = 1.7, className, ...props }: DockProps) {
24
+ const [mouseX, setMouseX] = React.useState<number | null>(null);
25
+ const containerRef = React.useRef<HTMLDivElement>(null);
26
+
27
+ function getScale(index: number): number {
28
+ if (mouseX === null || !containerRef.current) return 1;
29
+ const container = containerRef.current;
30
+ const buttons = container.querySelectorAll<HTMLElement>('[data-dock-item]');
31
+ const btn = buttons[index];
32
+ if (!btn) return 1;
33
+ const rect = btn.getBoundingClientRect();
34
+ const centerX = rect.left + rect.width / 2;
35
+ const distance = Math.abs(mouseX - centerX);
36
+ if (distance > MAGNIFY_RADIUS) return 1;
37
+ const t = 1 - distance / MAGNIFY_RADIUS;
38
+ return 1 + (magnification - 1) * t;
39
+ }
40
+
41
+ return (
42
+ <TooltipProvider delayDuration={0}>
43
+ <div
44
+ ref={containerRef}
45
+ onMouseMove={(e) => setMouseX(e.clientX)}
46
+ onMouseLeave={() => setMouseX(null)}
47
+ className={cn(
48
+ 'inline-flex items-end gap-2 rounded-2xl border bg-background/80 px-4 py-3 shadow-lg backdrop-blur-md',
49
+ className,
50
+ )}
51
+ {...props}
52
+ >
53
+ {items.map((item, i) => {
54
+ const scale = getScale(i);
55
+ const size = iconSize * scale;
56
+
57
+ const button = (
58
+ <Tooltip key={item.label}>
59
+ <TooltipTrigger asChild>
60
+ <button
61
+ data-dock-item
62
+ onClick={item.onClick}
63
+ className="flex items-center justify-center rounded-xl bg-muted text-foreground transition-colors hover:bg-accent"
64
+ style={{
65
+ width: size,
66
+ height: size,
67
+ transition: 'width 100ms ease, height 100ms ease',
68
+ transformOrigin: 'bottom center',
69
+ }}
70
+ aria-label={item.label}
71
+ >
72
+ <item.icon style={{ width: size * 0.5, height: size * 0.5 }} />
73
+ </button>
74
+ </TooltipTrigger>
75
+ <TooltipContent side="top" sideOffset={8}>
76
+ <p>{item.label}</p>
77
+ </TooltipContent>
78
+ </Tooltip>
79
+ );
80
+
81
+ if (item.href) {
82
+ return (
83
+ <a key={item.label} href={item.href}>
84
+ {button}
85
+ </a>
86
+ );
87
+ }
88
+
89
+ return button;
90
+ })}
91
+ </div>
92
+ </TooltipProvider>
93
+ );
94
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { LucideIcon } from 'lucide-react';
5
+ import { ArrowRight } from 'lucide-react';
6
+ import { cn } from '@olwiba/cn';
7
+
8
+ export interface FeatureCardProps extends React.HTMLAttributes<HTMLDivElement> {
9
+ icon: LucideIcon;
10
+ title: string;
11
+ description: string;
12
+ href?: string;
13
+ }
14
+
15
+ export function FeatureCard({ icon: Icon, title, description, href, className, ...props }: FeatureCardProps) {
16
+ const content = (
17
+ <div
18
+ className={cn(
19
+ 'group relative flex flex-col gap-4 rounded-2xl border bg-card p-6 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
20
+ href && 'cursor-pointer',
21
+ className,
22
+ )}
23
+ {...props}
24
+ >
25
+ <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
26
+ <Icon className="size-5" />
27
+ </div>
28
+ <div className="space-y-1.5">
29
+ <div className="flex items-center justify-between gap-2">
30
+ <h3 className="font-semibold leading-tight">{title}</h3>
31
+ {href && (
32
+ <ArrowRight className="size-4 shrink-0 text-muted-foreground opacity-0 transition-all group-hover:translate-x-0.5 group-hover:opacity-100" />
33
+ )}
34
+ </div>
35
+ <p className="text-sm leading-relaxed text-muted-foreground">{description}</p>
36
+ </div>
37
+ </div>
38
+ );
39
+
40
+ if (href) {
41
+ return <a href={href} className="block">{content}</a>;
42
+ }
43
+
44
+ return content;
45
+ }