@stampui/blocks 1.1.1 → 2.0.0

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 (54) hide show
  1. package/dist/manifests.js +917 -170
  2. package/package.json +15 -10
  3. package/src/components/blocks/animated-counter.tsx +70 -0
  4. package/src/components/blocks/gradient-text.tsx +39 -0
  5. package/src/components/blocks/grid-wave.tsx +40 -0
  6. package/src/components/blocks/loading-card.tsx +48 -0
  7. package/src/components/blocks/loading-dots.tsx +68 -0
  8. package/src/components/blocks/orbit-trail.tsx +30 -0
  9. package/src/components/blocks/progress-ring.tsx +72 -0
  10. package/src/components/blocks/registry-card.tsx +6 -7
  11. package/src/components/blocks/signal-arc.tsx +32 -0
  12. package/src/components/blocks/typewriter-text.tsx +62 -0
  13. package/src/components/core/alert-dialog.tsx +2 -2
  14. package/src/components/core/avatar.tsx +8 -4
  15. package/src/components/core/button.tsx +1 -1
  16. package/src/components/core/checkbox.tsx +1 -1
  17. package/src/components/core/combobox.tsx +1 -1
  18. package/src/components/core/command.tsx +7 -4
  19. package/src/components/core/date-picker.tsx +1 -1
  20. package/src/components/core/dialog.tsx +1 -1
  21. package/src/components/core/drawer.tsx +1 -1
  22. package/src/components/core/input.tsx +2 -0
  23. package/src/components/core/label.tsx +1 -1
  24. package/src/components/core/multi-select.tsx +1 -1
  25. package/src/components/core/native-select.tsx +1 -1
  26. package/src/components/core/password-input.tsx +3 -0
  27. package/src/components/core/radio-group.tsx +1 -1
  28. package/src/components/core/resizable.tsx +1 -1
  29. package/src/components/core/select.tsx +1 -1
  30. package/src/components/core/sheet.tsx +1 -1
  31. package/src/components/core/slider.tsx +1 -1
  32. package/src/components/core/status-pulse.tsx +6 -0
  33. package/src/components/core/switch.tsx +1 -1
  34. package/src/components/core/table.tsx +7 -2
  35. package/src/components/core/tabs.tsx +1 -1
  36. package/src/components/core/toggle.tsx +1 -1
  37. package/src/components/core/typing-indicator.tsx +41 -27
  38. package/src/manifests.ts +932 -183
  39. package/src/components/blocks/ai-chat-shell.tsx +0 -97
  40. package/src/components/blocks/auth-panel.tsx +0 -203
  41. package/src/components/blocks/dashboard-shell.tsx +0 -135
  42. package/src/components/blocks/notification-center.tsx +0 -185
  43. package/src/components/blocks/onboarding-flow.tsx +0 -230
  44. package/src/components/blocks/project-command-center.tsx +0 -188
  45. package/src/components/blocks/prompt-input.tsx +0 -81
  46. package/src/components/blocks/settings-layout.tsx +0 -178
  47. package/src/components/blocks/token-stream.tsx +0 -42
  48. package/src/components/core/carousel.tsx +0 -170
  49. package/src/components/core/chart.tsx +0 -377
  50. package/src/components/core/data-table.tsx +0 -173
  51. package/src/components/core/file-upload.tsx +0 -143
  52. package/src/components/core/input-otp.tsx +0 -108
  53. package/src/components/core/stepper.tsx +0 -111
  54. package/src/components/core/timeline.tsx +0 -81
@@ -1,97 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { Menu, Plus, MessageSquare, Settings } from "lucide-react"
5
- import { cx } from "@/lib/cx"
6
- import { Button } from "@/components/core/button"
7
- import { PromptInput } from "@/components/blocks/prompt-input"
8
- import { TokenStream } from "@/components/blocks/token-stream"
9
-
10
- export function AIChatShell() {
11
- const [sidebarOpen, setSidebarOpen] = React.useState(true)
12
- const [messages, setMessages] = React.useState([
13
- { role: "assistant", content: "Hello! I am ready to help you build your next app. What would you like to create today?" }
14
- ])
15
-
16
- const handleSubmit = (val: string) => {
17
- setMessages([...messages, { role: "user", content: val }])
18
- setTimeout(() => {
19
- setMessages(prev => [...prev, { role: "assistant", content: "I can certainly help with that! Let's break down the requirements..." }])
20
- }, 600)
21
- }
22
-
23
- return (
24
- <div className="flex h-[600px] w-full overflow-hidden rounded-xl border border-border bg-background">
25
- {/* Sidebar */}
26
- <div
27
- className={cx(
28
- "flex flex-col border-r border-border bg-muted/20 transition-all duration-300",
29
- sidebarOpen ? "w-64" : "w-0 overflow-hidden border-r-0"
30
- )}
31
- >
32
- <div className="flex h-14 items-center justify-between px-4 border-b border-border">
33
- <span className="font-semibold text-sm">Chats</span>
34
- <Button variant="ghost" size="icon" className="h-8 w-8">
35
- <Plus className="h-4 w-4" />
36
- </Button>
37
- </div>
38
- <div className="flex-1 overflow-y-auto p-2 space-y-1">
39
- <Button variant="ghost" className="w-full justify-start font-normal text-sm text-muted-foreground hover:text-foreground">
40
- <MessageSquare className="mr-2 h-4 w-4" /> E-commerce Data Model
41
- </Button>
42
- <Button variant="ghost" className="w-full justify-start font-normal text-sm text-muted-foreground hover:text-foreground">
43
- <MessageSquare className="mr-2 h-4 w-4" /> Fix Next.js Hydration Error
44
- </Button>
45
- </div>
46
- <div className="p-2 border-t border-border">
47
- <Button variant="ghost" className="w-full justify-start text-sm">
48
- <Settings className="mr-2 h-4 w-4" /> Settings
49
- </Button>
50
- </div>
51
- </div>
52
-
53
- {/* Main Chat Area */}
54
- <div className="flex flex-1 flex-col bg-background">
55
- <div className="flex h-14 items-center border-b border-border px-4">
56
- <Button
57
- variant="ghost"
58
- size="icon"
59
- className="h-8 w-8 mr-2 md:hidden"
60
- onClick={() => setSidebarOpen(!sidebarOpen)}
61
- >
62
- <Menu className="h-4 w-4" />
63
- </Button>
64
- <span className="font-medium text-sm">New Chat</span>
65
- </div>
66
-
67
- <div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
68
- {messages.map((m, i) => (
69
- <div key={i} className={cx("flex w-full", m.role === "user" ? "justify-end" : "justify-start")}>
70
- <div className={cx(
71
- "max-w-[80%] rounded-2xl px-5 py-3 text-sm",
72
- m.role === "user"
73
- ? "bg-primary text-primary-foreground"
74
- : "bg-muted/50 text-foreground border border-border/50"
75
- )}>
76
- {m.role === "assistant" && i === messages.length - 1 ? (
77
- <TokenStream content={m.content} speed={20} />
78
- ) : (
79
- <span className="whitespace-pre-wrap">{m.content}</span>
80
- )}
81
- </div>
82
- </div>
83
- ))}
84
- </div>
85
-
86
- <div className="p-4 md:p-6 pt-0">
87
- <div className="mx-auto max-w-3xl">
88
- <PromptInput onSubmit={handleSubmit} />
89
- <div className="mt-2 text-center text-xs text-muted-foreground">
90
- AI can make mistakes. Verify important information.
91
- </div>
92
- </div>
93
- </div>
94
- </div>
95
- </div>
96
- )
97
- }
@@ -1,203 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { Eye, EyeOff } from "lucide-react"
5
- import { cx } from "@/lib/cx"
6
-
7
- type AuthMode = "login" | "register"
8
-
9
- interface AuthPanelProps {
10
- defaultMode?: AuthMode
11
- onLogin?: (email: string, password: string) => Promise<void> | void
12
- onRegister?: (email: string, password: string, name: string) => Promise<void> | void
13
- onGithub?: () => void
14
- onGoogle?: () => void
15
- }
16
-
17
- const GitHubIcon = () => (
18
- <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
19
- <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
20
- </svg>
21
- )
22
-
23
- const GoogleIcon = () => (
24
- <svg width="16" height="16" viewBox="0 0 24 24">
25
- <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
26
- <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
27
- <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
28
- <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
29
- </svg>
30
- )
31
-
32
- const INPUT_CLS = "h-9 w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-strong focus-visible:ring-offset-1 focus-visible:ring-offset-background"
33
-
34
- export function AuthPanel({
35
- defaultMode = "login",
36
- onLogin,
37
- onRegister,
38
- onGithub,
39
- onGoogle,
40
- }: AuthPanelProps) {
41
- const [mode, setMode] = React.useState<AuthMode>(defaultMode)
42
- const [email, setEmail] = React.useState("")
43
- const [password, setPassword] = React.useState("")
44
- const [name, setName] = React.useState("")
45
- const [showPassword, setShowPassword] = React.useState(false)
46
- const [loading, setLoading] = React.useState(false)
47
-
48
- async function handleSubmit(e: React.FormEvent) {
49
- e.preventDefault()
50
- setLoading(true)
51
- try {
52
- if (mode === "login") {
53
- await onLogin?.(email, password)
54
- } else {
55
- await onRegister?.(email, password, name)
56
- }
57
- } finally {
58
- setLoading(false)
59
- }
60
- }
61
-
62
- return (
63
- <div className="w-full max-w-sm rounded-2xl border border-border bg-card p-8">
64
- <div className="mb-6">
65
- <h1 className="text-xl font-semibold tracking-tight">
66
- {mode === "login" ? "Sign in" : "Create account"}
67
- </h1>
68
- <p className="text-sm text-muted-foreground mt-1">
69
- {mode === "login"
70
- ? "Welcome back. Enter your credentials to continue."
71
- : "Get started in seconds. No credit card required."}
72
- </p>
73
- </div>
74
-
75
- <div className="flex gap-2 mb-6">
76
- {onGithub && (
77
- <button
78
- type="button"
79
- onClick={onGithub}
80
- className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm font-medium transition-colors hover:bg-surface-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-strong"
81
- >
82
- <GitHubIcon />
83
- GitHub
84
- </button>
85
- )}
86
- {onGoogle && (
87
- <button
88
- type="button"
89
- onClick={onGoogle}
90
- className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm font-medium transition-colors hover:bg-surface-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-strong"
91
- >
92
- <GoogleIcon />
93
- Google
94
- </button>
95
- )}
96
- </div>
97
-
98
- {(onGithub || onGoogle) && (
99
- <div className="relative flex items-center justify-center mb-6">
100
- <div className="absolute inset-0 flex items-center">
101
- <div className="w-full border-t border-border" />
102
- </div>
103
- <span className="relative bg-card px-3 text-xs text-muted-foreground">or continue with email</span>
104
- </div>
105
- )}
106
-
107
- <form onSubmit={handleSubmit} className="space-y-4">
108
- {mode === "register" && (
109
- <div className="space-y-1.5">
110
- <label className="text-xs font-medium text-foreground">Name</label>
111
- <input
112
- type="text"
113
- placeholder="Your name"
114
- value={name}
115
- onChange={(e) => setName(e.target.value)}
116
- required
117
- autoComplete="name"
118
- className={INPUT_CLS}
119
- />
120
- </div>
121
- )}
122
-
123
- <div className="space-y-1.5">
124
- <label className="text-xs font-medium text-foreground">Email</label>
125
- <input
126
- type="email"
127
- placeholder="you@example.com"
128
- value={email}
129
- onChange={(e) => setEmail(e.target.value)}
130
- required
131
- autoComplete="email"
132
- className={INPUT_CLS}
133
- />
134
- </div>
135
-
136
- <div className="space-y-1.5">
137
- <div className="flex items-center justify-between">
138
- <label className="text-xs font-medium text-foreground">Password</label>
139
- {mode === "login" && (
140
- <button type="button" className="text-xs text-muted-foreground hover:text-foreground transition-colors">
141
- Forgot password?
142
- </button>
143
- )}
144
- </div>
145
- <div className="relative">
146
- <input
147
- type={showPassword ? "text" : "password"}
148
- placeholder="••••••••"
149
- value={password}
150
- onChange={(e) => setPassword(e.target.value)}
151
- required
152
- minLength={8}
153
- autoComplete={mode === "login" ? "current-password" : "new-password"}
154
- className={cx(INPUT_CLS, "pr-9")}
155
- />
156
- <button
157
- type="button"
158
- onClick={() => setShowPassword(!showPassword)}
159
- className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
160
- aria-label={showPassword ? "Hide password" : "Show password"}
161
- >
162
- {showPassword ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
163
- </button>
164
- </div>
165
- </div>
166
-
167
- <button
168
- type="submit"
169
- disabled={loading}
170
- className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-strong focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
171
- >
172
- {loading ? "Loading…" : mode === "login" ? "Sign in" : "Create account"}
173
- </button>
174
- </form>
175
-
176
- <p className="mt-5 text-center text-xs text-muted-foreground">
177
- {mode === "login" ? (
178
- <>
179
- Don&apos;t have an account?{" "}
180
- <button
181
- type="button"
182
- onClick={() => setMode("register")}
183
- className="font-medium text-foreground hover:underline underline-offset-2"
184
- >
185
- Sign up
186
- </button>
187
- </>
188
- ) : (
189
- <>
190
- Already have an account?{" "}
191
- <button
192
- type="button"
193
- onClick={() => setMode("login")}
194
- className="font-medium text-foreground hover:underline underline-offset-2"
195
- >
196
- Sign in
197
- </button>
198
- </>
199
- )}
200
- </p>
201
- </div>
202
- )
203
- }
@@ -1,135 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import type { LucideIcon } from "lucide-react"
5
- import { Menu, X } from "lucide-react"
6
- import { cx } from "@/lib/cx"
7
-
8
- export interface NavItem {
9
- label: string
10
- href: string
11
- icon?: LucideIcon
12
- active?: boolean
13
- }
14
-
15
- export interface SidebarConfig {
16
- logo?: React.ReactNode
17
- nav: NavItem[]
18
- footer?: React.ReactNode
19
- }
20
-
21
- export interface DashboardShellProps {
22
- sidebar: SidebarConfig
23
- header?: {
24
- title?: string
25
- actions?: React.ReactNode
26
- }
27
- children: React.ReactNode
28
- className?: string
29
- }
30
-
31
- export function DashboardShell({
32
- sidebar,
33
- header,
34
- children,
35
- className,
36
- }: DashboardShellProps) {
37
- const [mobileOpen, setMobileOpen] = React.useState(false)
38
-
39
- return (
40
- <div className={cx("flex min-h-screen bg-[#070708]", className)}>
41
- {mobileOpen && (
42
- <div
43
- className="fixed inset-0 z-20 bg-[#070708]/80 lg:hidden"
44
- onClick={() => setMobileOpen(false)}
45
- aria-hidden="true"
46
- />
47
- )}
48
-
49
- <aside
50
- className={cx(
51
- "fixed inset-y-0 left-0 z-30 flex w-[240px] flex-col border-r border-[#23252A] bg-[#09090B]",
52
- "transition-transform duration-[200ms] ease-out",
53
- "lg:static lg:translate-x-0",
54
- mobileOpen ? "translate-x-0" : "-translate-x-full"
55
- )}
56
- >
57
- {sidebar.logo && (
58
- <div className="flex h-14 shrink-0 items-center border-b border-[#23252A] px-4">
59
- {sidebar.logo}
60
- </div>
61
- )}
62
-
63
- <nav className="flex-1 overflow-y-auto px-2 py-3">
64
- <ul className="flex flex-col gap-0.5">
65
- {sidebar.nav.map((item, i) => {
66
- const Icon = item.icon
67
- return (
68
- <li key={i}>
69
- <a
70
- href={item.href}
71
- className={cx(
72
- "flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm transition-colors duration-[150ms] ease-out",
73
- item.active
74
- ? "bg-[#101114] text-[#FAFAFA] font-medium"
75
- : "text-muted-foreground hover:bg-[#101114] hover:text-[#FAFAFA]"
76
- )}
77
- >
78
- {Icon && <Icon size={15} strokeWidth={1.75} className="shrink-0" />}
79
- <span className="truncate">{item.label}</span>
80
- </a>
81
- </li>
82
- )
83
- })}
84
- </ul>
85
- </nav>
86
-
87
- {sidebar.footer && (
88
- <div className="shrink-0 border-t border-[#23252A] px-4 py-3">
89
- {sidebar.footer}
90
- </div>
91
- )}
92
- </aside>
93
-
94
- <div className="flex min-w-0 flex-1 flex-col">
95
- {(header || true) && (
96
- <header className="flex h-14 shrink-0 items-center gap-3 border-b border-[#23252A] px-4 lg:px-6">
97
- <button
98
- type="button"
99
- onClick={() => setMobileOpen(true)}
100
- className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-[150ms] ease-out hover:bg-[#101114] hover:text-[#FAFAFA] lg:hidden"
101
- aria-label="Open navigation"
102
- >
103
- <Menu size={16} />
104
- </button>
105
-
106
- {header?.title && (
107
- <h1 className="text-sm font-semibold text-[#FAFAFA] truncate">
108
- {header.title}
109
- </h1>
110
- )}
111
-
112
- {header?.actions && (
113
- <div className="ml-auto flex items-center gap-2">
114
- {header.actions}
115
- </div>
116
- )}
117
- </header>
118
- )}
119
-
120
- <main className="flex-1 overflow-y-auto p-4 lg:p-6">{children}</main>
121
- </div>
122
-
123
- {mobileOpen && (
124
- <button
125
- type="button"
126
- onClick={() => setMobileOpen(false)}
127
- className="fixed right-4 top-4 z-40 flex h-8 w-8 items-center justify-center rounded-lg border border-[#23252A] bg-[#09090B] text-muted-foreground transition-colors duration-[150ms] ease-out hover:text-[#FAFAFA] lg:hidden"
128
- aria-label="Close navigation"
129
- >
130
- <X size={14} />
131
- </button>
132
- )}
133
- </div>
134
- )
135
- }
@@ -1,185 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { Bell, Check, X, MessageSquare, AlertCircle, CreditCard, UserPlus } from "lucide-react"
5
- import { Badge } from "@/components/core/badge"
6
- import { Button } from "@/components/core/button"
7
- import { Separator } from "@/components/core/separator"
8
- import { StatusPulse } from "@/components/core/status-pulse"
9
- import { cx } from "@/lib/cx"
10
-
11
- // ── Types ──────────────────────────────────────────────────────────────────
12
-
13
- export type NotificationType = "message" | "alert" | "billing" | "invite" | "system"
14
-
15
- export interface Notification {
16
- id: string
17
- type: NotificationType
18
- title: string
19
- body: string
20
- time: string
21
- read: boolean
22
- href?: string
23
- }
24
-
25
- export interface NotificationCenterProps {
26
- notifications?: Notification[]
27
- className?: string
28
- }
29
-
30
- // ── Icons ──────────────────────────────────────────────────────────────────
31
-
32
- const typeConfig: Record<NotificationType, { icon: React.ReactNode; color: string }> = {
33
- message: { icon: <MessageSquare className="h-3.5 w-3.5" />, color: "bg-[#101114] text-[#FAFAFA] border-[#23252A]" },
34
- alert: { icon: <AlertCircle className="h-3.5 w-3.5" />, color: "bg-[#101114] text-[#FAFAFA] border-[#23252A]" },
35
- billing: { icon: <CreditCard className="h-3.5 w-3.5" />, color: "bg-[#101114] text-[#FAFAFA] border-[#23252A]" },
36
- invite: { icon: <UserPlus className="h-3.5 w-3.5" />, color: "bg-[#101114] text-[#FAFAFA] border-[#23252A]" },
37
- system: { icon: <AlertCircle className="h-3.5 w-3.5" />, color: "bg-[#101114] text-muted-foreground border-[#23252A]" },
38
- }
39
-
40
- const defaultNotifications: Notification[] = [
41
- { id: "1", type: "invite", title: "New team invite", body: "Sarah invited you to join Acme Design team.", time: "2m ago", read: false },
42
- { id: "2", type: "message", title: "Comment on your block", body: "Alex left a comment on HeroSection: 'Love the variant...'", time: "14m ago", read: false },
43
- { id: "3", type: "billing", title: "Payment successful", body: "Your Pro plan has been renewed for $29.", time: "1h ago", read: false },
44
- { id: "4", type: "alert", title: "Usage limit approaching",body: "You're at 80% of your monthly API limit.", time: "3h ago", read: true },
45
- { id: "5", type: "system", title: "Maintenance window", body: "Scheduled downtime on May 12 from 02:00–04:00 UTC.", time: "1d ago", read: true },
46
- ]
47
-
48
- // ── Notification Item ──────────────────────────────────────────────────────
49
-
50
- function NotifItem({
51
- notif,
52
- onRead,
53
- onDismiss,
54
- }: {
55
- notif: Notification
56
- onRead: (id: string) => void
57
- onDismiss: (id: string) => void
58
- }) {
59
- const { icon, color } = typeConfig[notif.type]
60
- return (
61
- <div
62
- className={cx(
63
- "flex gap-3 px-4 py-3 transition-colors hover:bg-surface-2/50",
64
- !notif.read && "bg-surface-2/30"
65
- )}
66
- onClick={() => onRead(notif.id)}
67
- role="button"
68
- tabIndex={0}
69
- onKeyDown={(e) => e.key === "Enter" && onRead(notif.id)}
70
- >
71
- <div className={cx("mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border", color)}>
72
- {icon}
73
- </div>
74
- <div className="flex-1 min-w-0">
75
- <div className="flex items-start justify-between gap-2">
76
- <p className={cx("text-sm leading-snug", notif.read ? "text-muted-foreground" : "font-medium text-foreground")}>
77
- {notif.title}
78
- </p>
79
- <div className="flex items-center gap-1 shrink-0">
80
- <span className="text-[11px] text-muted-foreground whitespace-nowrap">{notif.time}</span>
81
- {!notif.read && <StatusPulse status="online" size="sm" pulse={false} />}
82
- </div>
83
- </div>
84
- <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2 leading-relaxed">{notif.body}</p>
85
- </div>
86
- <button
87
- type="button"
88
- onClick={(e) => { e.stopPropagation(); onDismiss(notif.id) }}
89
- className="mt-0.5 shrink-0 opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
90
- aria-label="Dismiss"
91
- >
92
- <X className="h-3.5 w-3.5" />
93
- </button>
94
- </div>
95
- )
96
- }
97
-
98
- // ── Root ───────────────────────────────────────────────────────────────────
99
-
100
- export function NotificationCenter({ notifications: initial = defaultNotifications, className }: NotificationCenterProps) {
101
- const [open, setOpen] = React.useState(false)
102
- const [notifs, setNotifs] = React.useState(initial)
103
- const unread = notifs.filter((n) => !n.read).length
104
- const ref = React.useRef<HTMLDivElement>(null)
105
-
106
- React.useEffect(() => {
107
- if (!open) return
108
- const close = (e: MouseEvent) => {
109
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
110
- }
111
- document.addEventListener("mousedown", close)
112
- return () => document.removeEventListener("mousedown", close)
113
- }, [open])
114
-
115
- const markRead = (id: string) => setNotifs((n) => n.map((x) => x.id === id ? { ...x, read: true } : x))
116
- const dismiss = (id: string) => setNotifs((n) => n.filter((x) => x.id !== id))
117
- const markAllRead = () => setNotifs((n) => n.map((x) => ({ ...x, read: true })))
118
-
119
- return (
120
- <div ref={ref} className={cx("relative inline-block", className)}>
121
- <button
122
- type="button"
123
- onClick={() => setOpen((v) => !v)}
124
- aria-label={`Notifications${unread > 0 ? `, ${unread} unread` : ""}`}
125
- className="relative inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border text-muted-foreground hover:bg-surface-2 hover:text-foreground transition-colors"
126
- >
127
- <Bell className="h-4 w-4" />
128
- {unread > 0 && (
129
- <span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background">
130
- {unread > 9 ? "9+" : unread}
131
- </span>
132
- )}
133
- </button>
134
-
135
- {open && (
136
- <div className="absolute right-0 top-full mt-2 z-50 w-[380px] rounded-xl border border-border bg-background shadow-lg overflow-hidden">
137
- {/* Header */}
138
- <div className="flex items-center justify-between px-4 py-3 border-b border-border">
139
- <div className="flex items-center gap-2">
140
- <span className="text-sm font-semibold">Notifications</span>
141
- {unread > 0 && (
142
- <Badge variant="neutral" className="h-5 px-1.5 text-[10px] tabular-nums">{unread}</Badge>
143
- )}
144
- </div>
145
- {unread > 0 && (
146
- <button
147
- type="button"
148
- onClick={markAllRead}
149
- className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
150
- >
151
- <Check className="h-3 w-3" />
152
- Mark all read
153
- </button>
154
- )}
155
- </div>
156
-
157
- {/* List */}
158
- <div className="max-h-[420px] overflow-y-auto divide-y divide-border/50 group">
159
- {notifs.length === 0 ? (
160
- <div className="flex flex-col items-center justify-center py-12 text-center px-6">
161
- <Bell className="h-8 w-8 text-muted-foreground/30 mb-3" />
162
- <p className="text-sm font-medium">All caught up</p>
163
- <p className="text-xs text-muted-foreground mt-1">No new notifications</p>
164
- </div>
165
- ) : (
166
- notifs.map((notif) => (
167
- <NotifItem key={notif.id} notif={notif} onRead={markRead} onDismiss={dismiss} />
168
- ))
169
- )}
170
- </div>
171
-
172
- {/* Footer */}
173
- {notifs.length > 0 && (
174
- <>
175
- <Separator />
176
- <div className="px-4 py-2">
177
- <Button variant="ghost" className="w-full text-xs h-8">View all notifications</Button>
178
- </div>
179
- </>
180
- )}
181
- </div>
182
- )}
183
- </div>
184
- )
185
- }