@olwiba/ui 0.1.12 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,7 +41,7 @@
41
41
  "@dnd-kit/modifiers": "^9.0.0",
42
42
  "@dnd-kit/sortable": "^10.0.0",
43
43
  "@dnd-kit/utilities": "^3.2.2",
44
- "@olwiba/dx": "0.0.19",
44
+ "@olwiba/dx": "0.0.20",
45
45
  "@tanstack/react-table": "^8.21.3",
46
46
  "clsx": "^2.1.1",
47
47
  "lucide-react": "^0.562.0",
@@ -63,8 +63,8 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@content-collections/mdx": "^0.2.2",
66
- "@olwiba/cn": "0.1.21",
67
- "@olwiba/docs": "0.1.31",
66
+ "@olwiba/cn": "0.1.25",
67
+ "@olwiba/docs": "0.1.32",
68
68
  "@tailwindcss/vite": "^4.1.18",
69
69
  "@tanstack/react-router": "1.154.8",
70
70
  "@tanstack/react-router-devtools": "1.154.8",
@@ -61,6 +61,7 @@ export interface AppShellUser {
61
61
  plan?: string;
62
62
  onSignOut?: () => void;
63
63
  onBilling?: () => void;
64
+ onNotifications?: () => void;
64
65
  }
65
66
 
66
67
  export interface AppShellBrand {
@@ -159,19 +160,25 @@ function NavUser({ user }: { user: AppShellUser }) {
159
160
  </div>
160
161
  </div>
161
162
  </DropdownMenuLabel>
162
- <DropdownMenuSeparator />
163
- <DropdownMenuGroup>
164
- {user.onBilling && (
165
- <DropdownMenuItem onClick={user.onBilling}>
166
- <CreditCardIcon />
167
- Billing
168
- </DropdownMenuItem>
169
- )}
170
- <DropdownMenuItem>
171
- <BellIcon />
172
- Notifications
173
- </DropdownMenuItem>
174
- </DropdownMenuGroup>
163
+ {(user.onBilling || user.onNotifications) && (
164
+ <>
165
+ <DropdownMenuSeparator />
166
+ <DropdownMenuGroup>
167
+ {user.onBilling && (
168
+ <DropdownMenuItem onClick={user.onBilling}>
169
+ <CreditCardIcon />
170
+ Billing
171
+ </DropdownMenuItem>
172
+ )}
173
+ {user.onNotifications && (
174
+ <DropdownMenuItem onClick={user.onNotifications}>
175
+ <BellIcon />
176
+ Notifications
177
+ </DropdownMenuItem>
178
+ )}
179
+ </DropdownMenuGroup>
180
+ </>
181
+ )}
175
182
  {user.onSignOut && (
176
183
  <>
177
184
  <DropdownMenuSeparator />
@@ -1,16 +1,26 @@
1
1
  'use client';
2
2
 
3
3
  import type { ReactNode } from 'react';
4
- import { ArrowLeft, Compass } from 'lucide-react';
4
+ import { ArrowLeft, ArrowUpRight } from 'lucide-react';
5
+ import { cn } from '@olwiba/cn';
5
6
  import { Button } from '../primitives/Button';
6
7
  import type { AppShellRenderLink } from './AppShell';
7
8
 
9
+ export interface ErrorPageLink {
10
+ label: string;
11
+ href: string;
12
+ description?: string;
13
+ icon?: ReactNode;
14
+ }
15
+
8
16
  export interface ErrorPageProps {
9
17
  statusCode?: string;
10
18
  title?: string;
11
19
  description?: string;
12
20
  action?: { label: string; href: string };
13
21
  backAction?: { label: string; href?: string; onClick?: () => void };
22
+ /** Optional "maybe you were looking for" links rendered below the actions. */
23
+ links?: ErrorPageLink[];
14
24
  renderLink?: AppShellRenderLink;
15
25
  }
16
26
 
@@ -24,54 +34,111 @@ export function ErrorPage({
24
34
  description = "The page you're looking for doesn't exist or has been moved. Check the URL or head back home.",
25
35
  action = { label: 'Take me home', href: '/' },
26
36
  backAction = { label: 'Go back' },
37
+ links,
27
38
  renderLink = defaultRenderLink,
28
39
  }: ErrorPageProps) {
40
+ const hasLinks = links && links.length > 0;
41
+
29
42
  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>
43
+ <section className="relative overflow-hidden rounded-2xl border bg-card">
44
+ {/* Dot grid, faded toward the edges */}
45
+ <div
46
+ aria-hidden
47
+ className="absolute inset-0 bg-[radial-gradient(hsl(var(--border))_1px,transparent_1px)] [background-size:24px_24px] [mask-image:radial-gradient(ellipse_at_center,black_30%,transparent_75%)]"
48
+ />
49
+ {/* Soft brand glow behind the numerals */}
50
+ <div
51
+ aria-hidden
52
+ className="absolute left-1/2 top-24 h-64 w-[36rem] max-w-full -translate-x-1/2 rounded-full bg-primary/10 blur-3xl"
53
+ />
39
54
 
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>
55
+ <div
56
+ className={cn(
57
+ 'relative mx-auto flex max-w-3xl flex-col items-center px-6 pt-20 text-center sm:pt-24',
58
+ hasLinks ? 'pb-16' : 'pb-24 sm:pb-28',
59
+ )}
60
+ >
61
+ <span className="inline-flex items-center gap-2 rounded-full border bg-background/80 px-3 py-1 font-mono text-xs text-muted-foreground backdrop-blur">
62
+ <span className="relative flex size-1.5">
63
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
64
+ <span className="relative inline-flex size-1.5 rounded-full bg-primary" />
65
+ </span>
66
+ HTTP {statusCode}
67
+ </span>
68
+
69
+ <div
70
+ aria-hidden
71
+ className="pointer-events-none -mb-8 mt-2 select-none bg-gradient-to-b from-foreground/15 via-foreground/[0.06] to-transparent bg-clip-text text-[9rem] font-black leading-none tracking-tighter text-transparent sm:-mb-12 sm:text-[13rem]"
72
+ >
73
+ {statusCode}
74
+ </div>
47
75
 
48
- <div className="flex flex-wrap items-center justify-center gap-3">
49
- {backAction && (
50
- backAction.href ? (
76
+ <h1 className="text-balance text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
77
+ <p className="mx-auto mt-3 max-w-md text-pretty text-sm leading-6 text-muted-foreground">
78
+ {description}
79
+ </p>
80
+
81
+ <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
82
+ {backAction &&
83
+ (backAction.href ? (
84
+ renderLink({
85
+ href: backAction.href,
86
+ children: (
87
+ <Button variant="outline">
88
+ <ArrowLeft className="mr-2 size-4" />
89
+ {backAction.label}
90
+ </Button>
91
+ ),
92
+ })
93
+ ) : (
94
+ <Button variant="outline" onClick={backAction.onClick}>
95
+ <ArrowLeft className="mr-2 size-4" />
96
+ {backAction.label}
97
+ </Button>
98
+ ))}
99
+ {action &&
100
+ renderLink({
101
+ href: action.href,
102
+ children: <Button>{action.label}</Button>,
103
+ })}
104
+ </div>
105
+
106
+ {hasLinks && (
107
+ <div className="mt-14 w-full">
108
+ <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
109
+ Maybe you were looking for
110
+ </p>
111
+ <div className="mt-4 grid gap-3 text-left sm:grid-cols-2 lg:grid-cols-3">
112
+ {links.map((link) =>
51
113
  renderLink({
52
- href: backAction.href,
114
+ href: link.href,
115
+ className:
116
+ 'group flex items-start gap-3 rounded-xl border bg-background/60 p-4 backdrop-blur transition-colors hover:border-primary/40 hover:bg-accent',
53
117
  children: (
54
- <Button variant="outline">
55
- <ArrowLeft className="mr-2 size-4" />
56
- {backAction.label}
57
- </Button>
118
+ <>
119
+ {link.icon && (
120
+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border bg-muted text-muted-foreground">
121
+ {link.icon}
122
+ </span>
123
+ )}
124
+ <span className="min-w-0 grow">
125
+ <span className="flex items-center gap-1 text-sm font-medium">
126
+ {link.label}
127
+ <ArrowUpRight className="size-3.5 text-muted-foreground transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-foreground" />
128
+ </span>
129
+ {link.description && (
130
+ <span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
131
+ {link.description}
132
+ </span>
133
+ )}
134
+ </span>
135
+ </>
58
136
  ),
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
- )}
137
+ }),
138
+ )}
139
+ </div>
73
140
  </div>
74
- </div>
141
+ )}
75
142
  </div>
76
143
  </section>
77
144
  );
@@ -1,9 +1,14 @@
1
1
  'use client';
2
2
 
3
3
  import * as React from 'react';
4
- import { Badge, cn } from '@olwiba/cn';
5
4
  import type { AppShellRenderLink } from '../app/AppShell';
6
5
 
6
+ export interface PostAuthor {
7
+ name: string;
8
+ role?: string;
9
+ avatar?: string;
10
+ }
11
+
7
12
  export interface PostCardProps {
8
13
  title: string;
9
14
  description?: string;
@@ -11,55 +16,121 @@ export interface PostCardProps {
11
16
  slug: string;
12
17
  image?: string;
13
18
  tags?: string[];
19
+ author?: PostAuthor;
20
+ hrefPrefix?: string;
21
+ tagHrefPrefix?: string;
14
22
  renderLink?: AppShellRenderLink;
15
23
  }
16
24
 
17
25
  function formatDate(dateStr: string) {
18
26
  return new Date(dateStr).toLocaleDateString('en-US', {
19
27
  year: 'numeric',
20
- month: 'long',
28
+ month: 'short',
21
29
  day: 'numeric',
22
30
  });
23
31
  }
24
32
 
25
- export function PostCard({ title, description, date, slug, image, tags, renderLink }: PostCardProps) {
26
- const href = `/blog/${slug}`;
33
+ export function PostCard({
34
+ title,
35
+ description,
36
+ date,
37
+ slug,
38
+ image,
39
+ tags,
40
+ author,
41
+ hrefPrefix = '/blog',
42
+ tagHrefPrefix = '/blog/tag',
43
+ renderLink,
44
+ }: PostCardProps) {
45
+ const href = `${hrefPrefix}/${slug}`;
27
46
 
28
- const LinkWrapper = ({ children, className }: { children: React.ReactNode; className?: string }) =>
29
- renderLink ? renderLink({ href, children, className }) : <a href={href} className={className}>{children}</a>;
47
+ const LinkWrapper = ({
48
+ href: linkHref,
49
+ children,
50
+ className,
51
+ }: {
52
+ href: string;
53
+ children: React.ReactNode;
54
+ className?: string;
55
+ }) =>
56
+ renderLink ? (
57
+ renderLink({ href: linkHref, children, className })
58
+ ) : (
59
+ <a href={linkHref} className={className}>
60
+ {children}
61
+ </a>
62
+ );
30
63
 
31
64
  return (
32
- <article className="group flex flex-col gap-3">
33
- {image && (
34
- <LinkWrapper className="block overflow-hidden rounded-xl">
65
+ <article className="group relative flex flex-col items-start">
66
+ <div className="relative w-full overflow-hidden rounded-2xl bg-muted">
67
+ {image ? (
35
68
  <img
36
69
  src={image}
37
70
  alt={title}
38
- className="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-105"
71
+ className="aspect-video w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
72
+ />
73
+ ) : (
74
+ <div
75
+ aria-hidden
76
+ className="aspect-video w-full bg-gradient-to-br from-primary/15 via-muted to-muted"
39
77
  />
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
78
  )}
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>
79
+ <div className="pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-inset ring-foreground/10" />
80
+ </div>
81
+
82
+ <div className="flex w-full grow flex-col">
83
+ <div className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs">
84
+ <time dateTime={date} className="text-muted-foreground">
85
+ {formatDate(date)}
86
+ </time>
87
+ {tags?.map((tag) => (
88
+ <LinkWrapper
89
+ key={tag}
90
+ href={`${tagHrefPrefix}/${encodeURIComponent(tag)}`}
91
+ className="relative z-10 rounded-full bg-muted px-3 py-1.5 font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
92
+ >
93
+ {tag}
94
+ </LinkWrapper>
95
+ ))}
96
+ </div>
97
+
98
+ <div className="grow">
99
+ <h2 className="mt-3 text-lg font-semibold leading-snug tracking-tight transition-colors group-hover:text-primary">
100
+ <LinkWrapper href={href}>
101
+ <span className="absolute inset-0" aria-hidden />
102
+ {title}
103
+ </LinkWrapper>
104
+ </h2>
105
+ {description && (
106
+ <p className="mt-3 line-clamp-3 text-sm leading-6 text-muted-foreground">
107
+ {description}
108
+ </p>
109
+ )}
110
+ </div>
111
+
112
+ {author && (
113
+ <div className="mt-6 flex items-center gap-x-3">
114
+ {author.avatar ? (
115
+ <img
116
+ src={author.avatar}
117
+ alt={author.name}
118
+ className="size-9 rounded-full bg-muted object-cover"
119
+ />
120
+ ) : (
121
+ <span
122
+ aria-hidden
123
+ className="flex size-9 items-center justify-center rounded-full bg-muted text-sm font-semibold text-muted-foreground"
124
+ >
125
+ {author.name.charAt(0).toUpperCase()}
126
+ </span>
127
+ )}
128
+ <div className="text-sm leading-tight">
129
+ <p className="font-semibold">{author.name}</p>
130
+ {author.role && <p className="text-muted-foreground">{author.role}</p>}
131
+ </div>
132
+ </div>
59
133
  )}
60
- <time dateTime={date} className="text-xs text-muted-foreground">
61
- {formatDate(date)}
62
- </time>
63
134
  </div>
64
135
  </article>
65
136
  );
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import * as React from 'react';
4
+ import { cn } from '@olwiba/cn';
4
5
  import { PostCard, type PostCardProps } from './PostCard';
5
6
  import type { AppShellRenderLink } from '../app/AppShell';
6
7
 
@@ -8,9 +9,18 @@ export interface PostListProps {
8
9
  posts: PostCardProps[];
9
10
  renderLink?: AppShellRenderLink;
10
11
  emptyMessage?: string;
12
+ /** Max columns at the widest breakpoint. */
13
+ columns?: 2 | 3;
14
+ className?: string;
11
15
  }
12
16
 
13
- export function PostList({ posts, renderLink, emptyMessage = 'No posts published yet.' }: PostListProps) {
17
+ export function PostList({
18
+ posts,
19
+ renderLink,
20
+ emptyMessage = 'No posts published yet.',
21
+ columns = 3,
22
+ className,
23
+ }: PostListProps) {
14
24
  if (posts.length === 0) {
15
25
  return (
16
26
  <p className="text-sm text-muted-foreground italic">{emptyMessage}</p>
@@ -18,7 +28,13 @@ export function PostList({ posts, renderLink, emptyMessage = 'No posts published
18
28
  }
19
29
 
20
30
  return (
21
- <div className="grid gap-8 sm:grid-cols-2">
31
+ <div
32
+ className={cn(
33
+ 'grid grid-cols-1 gap-x-8 gap-y-14 sm:grid-cols-2',
34
+ columns === 3 && 'lg:grid-cols-3',
35
+ className,
36
+ )}
37
+ >
22
38
  {posts.map((post) => (
23
39
  <PostCard key={post.slug} {...post} renderLink={renderLink} />
24
40
  ))}
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export {
52
52
 
53
53
  // ─── Marketing — page sections ────────────────────────────────────────────────
54
54
  export { SectionTitle, type SectionTitleProps } from './marketing/SectionTitle';
55
+ export { marketingSectionSpacing, type MarketingSectionSpacing } from './marketing/section-spacing';
55
56
  export { HeroSection, type HeroSectionProps } from './marketing/HeroSection';
56
57
  export { FeaturesSection, type FeaturesSectionProps } from './marketing/FeaturesSection';
57
58
  export { GroupedFeaturesSection, type GroupedFeaturesSectionProps, type GroupedFeatureGroup } from './marketing/GroupedFeaturesSection';
@@ -124,7 +125,7 @@ export { RegisterHotkeys, type Hotkey } from './components/RegisterHotkeys';
124
125
  export { RootErrorFallback } from './components/RootErrorFallback';
125
126
 
126
127
  // ─── Blog ────────────────────────────────────────────────────────────────────
127
- export { PostCard, type PostCardProps } from './blog/PostCard';
128
+ export { PostCard, type PostCardProps, type PostAuthor } from './blog/PostCard';
128
129
  export { PostList, type PostListProps } from './blog/PostList';
129
130
  export { ChangelogList, type ChangelogListProps } from './blog/ChangelogList';
130
131
  export { ChangelogCard, type ChangelogCardProps, type ChangelogHighlight, type ChangelogReleaseType } from './blog/ChangelogCard';
@@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@olwiba/cn';
6
6
  import { FadeIn } from '../motion/FadeIn';
7
7
  import { PhoneFrame } from '../components/PhoneFrame';
8
8
  import type { AppShellRenderLink } from '../app/AppShell';
9
+ import { marketingSectionSpacing } from './section-spacing';
9
10
 
10
11
  export interface HeroSectionProps {
11
12
  heading: string;
@@ -40,7 +41,7 @@ export function HeroSection({
40
41
  }: HeroSectionProps) {
41
42
  return (
42
43
  <section className="overflow-hidden">
43
- <div className="px-6 py-14 sm:px-10 sm:py-20">
44
+ <div className={marketingSectionSpacing.hero}>
44
45
  <div className="mx-auto grid max-w-6xl items-center gap-10 lg:grid-cols-2 lg:gap-16">
45
46
  {/* Left - text content */}
46
47
  <FadeIn direction="left">
@@ -0,0 +1,6 @@
1
+ export const marketingSectionSpacing = {
2
+ standard: 'px-6 py-14 sm:px-10 sm:py-20',
3
+ hero: 'px-6 pb-14 pt-10 sm:px-10 sm:py-20',
4
+ } as const;
5
+
6
+ export type MarketingSectionSpacing = keyof typeof marketingSectionSpacing;