@redbamboo/ui 0.1.1 → 0.2.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.
- package/dist/components/about-dialog.d.ts +17 -0
- package/dist/components/about-dialog.d.ts.map +1 -0
- package/dist/components/feedback-dialog.d.ts +44 -0
- package/dist/components/feedback-dialog.d.ts.map +1 -0
- package/dist/components/toast.d.ts +34 -0
- package/dist/components/toast.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +993 -644
- package/package.json +1 -1
- package/src/components/about-dialog.tsx +120 -0
- package/src/components/feedback-dialog.tsx +310 -0
- package/src/components/toast.tsx +159 -0
- package/src/index.ts +33 -0
package/package.json
CHANGED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { cn } from "../utils"
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from "./dialog"
|
|
10
|
+
import { Badge } from "./badge"
|
|
11
|
+
import { Separator } from "./separator"
|
|
12
|
+
|
|
13
|
+
export interface AboutApp {
|
|
14
|
+
name: string
|
|
15
|
+
version: string
|
|
16
|
+
description?: string
|
|
17
|
+
icon?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AboutDialogProps {
|
|
21
|
+
app: AboutApp
|
|
22
|
+
appGitHub?: string
|
|
23
|
+
companyGitHub?: string
|
|
24
|
+
latestVersion?: string
|
|
25
|
+
open: boolean
|
|
26
|
+
onOpenChange: (open: boolean) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fmtVersion(v: string) {
|
|
30
|
+
return v.startsWith("v") ? v : `v${v}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function AboutDialog({
|
|
34
|
+
app,
|
|
35
|
+
appGitHub,
|
|
36
|
+
companyGitHub,
|
|
37
|
+
latestVersion,
|
|
38
|
+
open,
|
|
39
|
+
onOpenChange,
|
|
40
|
+
}: AboutDialogProps) {
|
|
41
|
+
const hasUpdate = latestVersion && latestVersion !== app.version
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
45
|
+
<DialogContent data-slot="about-dialog">
|
|
46
|
+
<DialogHeader>
|
|
47
|
+
<div className="flex items-center gap-3">
|
|
48
|
+
{app.icon && (
|
|
49
|
+
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
|
|
50
|
+
<i className={cn(app.icon, "text-lg text-primary")} />
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
<div>
|
|
54
|
+
<DialogTitle>{app.name}</DialogTitle>
|
|
55
|
+
<Badge variant="secondary" className="mt-1.5 text-[0.7rem]">
|
|
56
|
+
{fmtVersion(app.version)}
|
|
57
|
+
</Badge>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
{app.description && (
|
|
61
|
+
<DialogDescription>{app.description}</DialogDescription>
|
|
62
|
+
)}
|
|
63
|
+
</DialogHeader>
|
|
64
|
+
|
|
65
|
+
{hasUpdate && (
|
|
66
|
+
<div className="flex items-center gap-2 rounded-lg border border-accent-teal/30 bg-accent-teal/5 px-3 py-2 text-sm text-accent-teal">
|
|
67
|
+
<i className="fa-solid fa-circle-up text-xs" />
|
|
68
|
+
<span>
|
|
69
|
+
Version <strong>{fmtVersion(latestVersion)}</strong> available
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
<Separator />
|
|
75
|
+
|
|
76
|
+
<div className="space-y-3">
|
|
77
|
+
<div>
|
|
78
|
+
<p className="text-sm font-medium">RedBamboo Interactive</p>
|
|
79
|
+
<p className="text-xs text-muted-foreground">
|
|
80
|
+
Open-source tools for creators and developers
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{(appGitHub || companyGitHub) && (
|
|
85
|
+
<div className="flex flex-col gap-1.5">
|
|
86
|
+
{appGitHub && (
|
|
87
|
+
<a
|
|
88
|
+
href={appGitHub}
|
|
89
|
+
target="_blank"
|
|
90
|
+
rel="noopener noreferrer"
|
|
91
|
+
className="inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
92
|
+
>
|
|
93
|
+
<i className="fa-brands fa-github" />
|
|
94
|
+
{app.name}
|
|
95
|
+
</a>
|
|
96
|
+
)}
|
|
97
|
+
{companyGitHub && (
|
|
98
|
+
<a
|
|
99
|
+
href={companyGitHub}
|
|
100
|
+
target="_blank"
|
|
101
|
+
rel="noopener noreferrer"
|
|
102
|
+
className="inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
103
|
+
>
|
|
104
|
+
<i className="fa-brands fa-github" />
|
|
105
|
+
RedBamboo Interactive
|
|
106
|
+
</a>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<DialogFooter showCloseButton>
|
|
113
|
+
<p className="mr-auto text-xs text-muted-foreground">MIT License</p>
|
|
114
|
+
</DialogFooter>
|
|
115
|
+
</DialogContent>
|
|
116
|
+
</Dialog>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { AboutDialog }
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../utils"
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "./dialog"
|
|
11
|
+
import { Button } from "./button"
|
|
12
|
+
import { Label } from "./label"
|
|
13
|
+
import { Separator } from "./separator"
|
|
14
|
+
import {
|
|
15
|
+
Collapsible,
|
|
16
|
+
CollapsibleTrigger,
|
|
17
|
+
CollapsibleContent,
|
|
18
|
+
} from "./collapsible"
|
|
19
|
+
|
|
20
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export type FeedbackCategory = "bug" | "feature" | "suggestion"
|
|
23
|
+
|
|
24
|
+
export interface SystemInfo {
|
|
25
|
+
appName: string
|
|
26
|
+
appVersion: string
|
|
27
|
+
browser: string
|
|
28
|
+
os: string
|
|
29
|
+
screenResolution: string
|
|
30
|
+
currentUrl: string
|
|
31
|
+
timestamp: string
|
|
32
|
+
colorScheme: "light" | "dark" | "unknown"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FeedbackSubmission {
|
|
36
|
+
category: FeedbackCategory
|
|
37
|
+
description: string
|
|
38
|
+
systemInfo: SystemInfo
|
|
39
|
+
customMetadata?: Record<string, string>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FeedbackResult {
|
|
43
|
+
issueUrl: string
|
|
44
|
+
title: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FeedbackDialogProps {
|
|
48
|
+
app: { name: string; version: string }
|
|
49
|
+
customMetadata?: Record<string, string>
|
|
50
|
+
onSubmit: (submission: FeedbackSubmission) => void
|
|
51
|
+
open: boolean
|
|
52
|
+
onOpenChange: (open: boolean) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FeedbackButtonProps {
|
|
56
|
+
onClick: () => void
|
|
57
|
+
className?: string
|
|
58
|
+
variant?: "menu-item" | "button"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Utilities ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function parseBrowser(ua: string): string {
|
|
64
|
+
if (ua.includes("Firefox/")) {
|
|
65
|
+
const match = ua.match(/Firefox\/([\d.]+)/)
|
|
66
|
+
return `Firefox ${match?.[1] ?? ""}`
|
|
67
|
+
}
|
|
68
|
+
if (ua.includes("Edg/")) {
|
|
69
|
+
const match = ua.match(/Edg\/([\d.]+)/)
|
|
70
|
+
return `Edge ${match?.[1] ?? ""}`
|
|
71
|
+
}
|
|
72
|
+
if (ua.includes("Chrome/")) {
|
|
73
|
+
const match = ua.match(/Chrome\/([\d.]+)/)
|
|
74
|
+
return `Chrome ${match?.[1] ?? ""}`
|
|
75
|
+
}
|
|
76
|
+
if (ua.includes("Safari/") && !ua.includes("Chrome")) {
|
|
77
|
+
const match = ua.match(/Version\/([\d.]+)/)
|
|
78
|
+
return `Safari ${match?.[1] ?? ""}`
|
|
79
|
+
}
|
|
80
|
+
return ua.slice(0, 50)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseOS(ua: string): string {
|
|
84
|
+
if (ua.includes("Windows NT 10.0")) return "Windows 10/11"
|
|
85
|
+
if (ua.includes("Windows NT")) return "Windows"
|
|
86
|
+
if (ua.includes("Mac OS X")) {
|
|
87
|
+
const match = ua.match(/Mac OS X ([\d_]+)/)
|
|
88
|
+
return `macOS ${match?.[1]?.replace(/_/g, ".") ?? ""}`
|
|
89
|
+
}
|
|
90
|
+
if (ua.includes("Linux")) return "Linux"
|
|
91
|
+
if (ua.includes("Android")) return "Android"
|
|
92
|
+
if (ua.includes("iOS") || ua.includes("iPhone")) return "iOS"
|
|
93
|
+
return "Unknown"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function collectSystemInfo(app: { name: string; version: string }): SystemInfo {
|
|
97
|
+
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown"
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
appName: app.name,
|
|
101
|
+
appVersion: app.version,
|
|
102
|
+
browser: parseBrowser(ua),
|
|
103
|
+
os: parseOS(ua),
|
|
104
|
+
screenResolution:
|
|
105
|
+
typeof screen !== "undefined"
|
|
106
|
+
? `${screen.width}x${screen.height}`
|
|
107
|
+
: "unknown",
|
|
108
|
+
currentUrl: typeof location !== "undefined" ? location.href : "unknown",
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
colorScheme:
|
|
111
|
+
typeof document !== "undefined"
|
|
112
|
+
? document.documentElement.classList.contains("dark")
|
|
113
|
+
? "dark"
|
|
114
|
+
: "light"
|
|
115
|
+
: "unknown",
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Components ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const categories: { value: FeedbackCategory; label: string; icon: string }[] = [
|
|
122
|
+
{ value: "bug", label: "Bug", icon: "fa-solid fa-bug" },
|
|
123
|
+
{ value: "feature", label: "Feature", icon: "fa-solid fa-lightbulb" },
|
|
124
|
+
{ value: "suggestion", label: "Suggestion", icon: "fa-solid fa-comment" },
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
function FeedbackButton({ onClick, className, variant = "button" }: FeedbackButtonProps) {
|
|
128
|
+
if (variant === "menu-item") {
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
data-slot="feedback-button"
|
|
133
|
+
onClick={onClick}
|
|
134
|
+
className={cn(
|
|
135
|
+
"relative flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
|
136
|
+
className
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<i className="fa-solid fa-message size-4 text-center" />
|
|
140
|
+
Send Feedback
|
|
141
|
+
</button>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Button
|
|
147
|
+
variant="ghost"
|
|
148
|
+
size="sm"
|
|
149
|
+
data-slot="feedback-button"
|
|
150
|
+
onClick={onClick}
|
|
151
|
+
className={className}
|
|
152
|
+
>
|
|
153
|
+
<i className="fa-solid fa-message" />
|
|
154
|
+
Send Feedback
|
|
155
|
+
</Button>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function FeedbackDialog({
|
|
160
|
+
app,
|
|
161
|
+
customMetadata,
|
|
162
|
+
onSubmit,
|
|
163
|
+
open,
|
|
164
|
+
onOpenChange,
|
|
165
|
+
}: FeedbackDialogProps) {
|
|
166
|
+
const [category, setCategory] = React.useState<FeedbackCategory>("bug")
|
|
167
|
+
const [description, setDescription] = React.useState("")
|
|
168
|
+
const [infoOpen, setInfoOpen] = React.useState(false)
|
|
169
|
+
|
|
170
|
+
const systemInfo = React.useMemo(() => collectSystemInfo(app), [app, open])
|
|
171
|
+
|
|
172
|
+
React.useEffect(() => {
|
|
173
|
+
if (open) {
|
|
174
|
+
setCategory("bug")
|
|
175
|
+
setDescription("")
|
|
176
|
+
setInfoOpen(false)
|
|
177
|
+
}
|
|
178
|
+
}, [open])
|
|
179
|
+
|
|
180
|
+
const canSubmit = description.trim().length > 0
|
|
181
|
+
|
|
182
|
+
function handleSubmit(e: React.FormEvent) {
|
|
183
|
+
e.preventDefault()
|
|
184
|
+
if (!canSubmit) return
|
|
185
|
+
|
|
186
|
+
onSubmit({
|
|
187
|
+
category,
|
|
188
|
+
description: description.trim(),
|
|
189
|
+
systemInfo,
|
|
190
|
+
customMetadata,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
onOpenChange(false)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
198
|
+
<DialogContent data-slot="feedback-dialog" className="sm:max-w-md">
|
|
199
|
+
<DialogHeader>
|
|
200
|
+
<div className="flex items-center gap-3">
|
|
201
|
+
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
|
|
202
|
+
<i className="fa-solid fa-message text-lg text-primary" />
|
|
203
|
+
</div>
|
|
204
|
+
<div>
|
|
205
|
+
<DialogTitle>Send Feedback</DialogTitle>
|
|
206
|
+
<DialogDescription>
|
|
207
|
+
Help us improve {app.name}
|
|
208
|
+
</DialogDescription>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</DialogHeader>
|
|
212
|
+
|
|
213
|
+
<form onSubmit={handleSubmit} data-slot="feedback-form" className="space-y-4">
|
|
214
|
+
<div className="space-y-2">
|
|
215
|
+
<Label>Category</Label>
|
|
216
|
+
<div data-slot="feedback-category" className="flex gap-2">
|
|
217
|
+
{categories.map((cat) => (
|
|
218
|
+
<button
|
|
219
|
+
key={cat.value}
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={() => setCategory(cat.value)}
|
|
222
|
+
className={cn(
|
|
223
|
+
"flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-colors",
|
|
224
|
+
category === cat.value
|
|
225
|
+
? "border-primary bg-primary/10 text-primary"
|
|
226
|
+
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<i className={cn(cat.icon, "text-[10px]")} />
|
|
230
|
+
{cat.label}
|
|
231
|
+
</button>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className="space-y-2">
|
|
237
|
+
<Label htmlFor="feedback-description">What's on your mind?</Label>
|
|
238
|
+
<textarea
|
|
239
|
+
id="feedback-description"
|
|
240
|
+
data-slot="feedback-description"
|
|
241
|
+
className="w-full min-h-[120px] rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm transition-colors outline-none resize-y placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30"
|
|
242
|
+
placeholder={
|
|
243
|
+
category === "bug"
|
|
244
|
+
? "Describe the issue you encountered..."
|
|
245
|
+
: category === "feature"
|
|
246
|
+
? "Describe the feature you'd like to see..."
|
|
247
|
+
: "Share your suggestion..."
|
|
248
|
+
}
|
|
249
|
+
value={description}
|
|
250
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
251
|
+
required
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<Separator />
|
|
256
|
+
|
|
257
|
+
<Collapsible open={infoOpen} onOpenChange={setInfoOpen}>
|
|
258
|
+
<CollapsibleTrigger className="flex w-full cursor-pointer items-center gap-2 text-xs text-muted-foreground transition-colors hover:text-foreground">
|
|
259
|
+
<i className={cn("fa-solid fa-chevron-right text-[10px] transition-transform", infoOpen && "rotate-90")} />
|
|
260
|
+
System info included with submission
|
|
261
|
+
</CollapsibleTrigger>
|
|
262
|
+
<CollapsibleContent>
|
|
263
|
+
<div
|
|
264
|
+
data-slot="feedback-system-info"
|
|
265
|
+
className="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
|
|
266
|
+
>
|
|
267
|
+
<span className="font-medium">App</span>
|
|
268
|
+
<span>{systemInfo.appName} v{systemInfo.appVersion}</span>
|
|
269
|
+
<span className="font-medium">Browser</span>
|
|
270
|
+
<span>{systemInfo.browser}</span>
|
|
271
|
+
<span className="font-medium">OS</span>
|
|
272
|
+
<span>{systemInfo.os}</span>
|
|
273
|
+
<span className="font-medium">Screen</span>
|
|
274
|
+
<span>{systemInfo.screenResolution}</span>
|
|
275
|
+
<span className="font-medium">Theme</span>
|
|
276
|
+
<span>{systemInfo.colorScheme}</span>
|
|
277
|
+
{customMetadata && Object.entries(customMetadata).map(([key, value]) => (
|
|
278
|
+
<React.Fragment key={key}>
|
|
279
|
+
<span className="font-medium">{key}</span>
|
|
280
|
+
<span>{value}</span>
|
|
281
|
+
</React.Fragment>
|
|
282
|
+
))}
|
|
283
|
+
</div>
|
|
284
|
+
</CollapsibleContent>
|
|
285
|
+
</Collapsible>
|
|
286
|
+
|
|
287
|
+
<DialogFooter>
|
|
288
|
+
<Button
|
|
289
|
+
type="button"
|
|
290
|
+
variant="outline"
|
|
291
|
+
onClick={() => onOpenChange(false)}
|
|
292
|
+
>
|
|
293
|
+
Cancel
|
|
294
|
+
</Button>
|
|
295
|
+
<Button
|
|
296
|
+
type="submit"
|
|
297
|
+
data-slot="feedback-submit"
|
|
298
|
+
disabled={!canSubmit}
|
|
299
|
+
>
|
|
300
|
+
<i className="fa-solid fa-paper-plane" />
|
|
301
|
+
Send
|
|
302
|
+
</Button>
|
|
303
|
+
</DialogFooter>
|
|
304
|
+
</form>
|
|
305
|
+
</DialogContent>
|
|
306
|
+
</Dialog>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export { FeedbackDialog, FeedbackButton }
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from "../utils"
|
|
3
|
+
|
|
4
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export type ToastVariant = "default" | "success" | "error" | "loading"
|
|
7
|
+
|
|
8
|
+
export interface Toast {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
description?: string
|
|
12
|
+
variant: ToastVariant
|
|
13
|
+
duration?: number
|
|
14
|
+
action?: { label: string; onClick: () => void }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ToastUpdate {
|
|
18
|
+
title?: string
|
|
19
|
+
description?: string
|
|
20
|
+
variant?: ToastVariant
|
|
21
|
+
duration?: number
|
|
22
|
+
action?: { label: string; onClick: () => void }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ToastContextValue {
|
|
26
|
+
toast: (toast: Omit<Toast, "id">) => string
|
|
27
|
+
update: (id: string, update: ToastUpdate) => void
|
|
28
|
+
dismiss: (id: string) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Context ────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const ToastContext = React.createContext<ToastContextValue | null>(null)
|
|
34
|
+
|
|
35
|
+
export function useToast(): ToastContextValue {
|
|
36
|
+
const ctx = React.useContext(ToastContext)
|
|
37
|
+
if (!ctx) throw new Error("useToast must be used within a ToastProvider")
|
|
38
|
+
return ctx
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Provider & Renderer ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
let idCounter = 0
|
|
44
|
+
|
|
45
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
46
|
+
const [toasts, setToasts] = React.useState<Toast[]>([])
|
|
47
|
+
const timers = React.useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
|
|
48
|
+
|
|
49
|
+
function scheduleRemoval(id: string, duration: number) {
|
|
50
|
+
const existing = timers.current.get(id)
|
|
51
|
+
if (existing) clearTimeout(existing)
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
54
|
+
timers.current.delete(id)
|
|
55
|
+
}, duration)
|
|
56
|
+
timers.current.set(id, timer)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const toast = React.useCallback((t: Omit<Toast, "id">) => {
|
|
60
|
+
const id = `toast-${++idCounter}`
|
|
61
|
+
const newToast: Toast = { ...t, id }
|
|
62
|
+
setToasts((prev) => [...prev, newToast])
|
|
63
|
+
if (t.variant !== "loading") {
|
|
64
|
+
scheduleRemoval(id, t.duration ?? 5000)
|
|
65
|
+
}
|
|
66
|
+
return id
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
const update = React.useCallback((id: string, u: ToastUpdate) => {
|
|
70
|
+
setToasts((prev) =>
|
|
71
|
+
prev.map((t) => (t.id === id ? { ...t, ...u } : t))
|
|
72
|
+
)
|
|
73
|
+
if (u.variant && u.variant !== "loading") {
|
|
74
|
+
scheduleRemoval(id, u.duration ?? 5000)
|
|
75
|
+
}
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
const dismiss = React.useCallback((id: string) => {
|
|
79
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
80
|
+
const timer = timers.current.get(id)
|
|
81
|
+
if (timer) {
|
|
82
|
+
clearTimeout(timer)
|
|
83
|
+
timers.current.delete(id)
|
|
84
|
+
}
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
return () => {
|
|
89
|
+
timers.current.forEach((timer) => clearTimeout(timer))
|
|
90
|
+
}
|
|
91
|
+
}, [])
|
|
92
|
+
|
|
93
|
+
const value = React.useMemo(() => ({ toast, update, dismiss }), [toast, update, dismiss])
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<ToastContext.Provider value={value}>
|
|
97
|
+
{children}
|
|
98
|
+
<Toaster toasts={toasts} onDismiss={dismiss} />
|
|
99
|
+
</ToastContext.Provider>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Toaster (renders toasts) ───────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
const variantIcon: Record<ToastVariant, string> = {
|
|
106
|
+
default: "fa-solid fa-circle-info",
|
|
107
|
+
success: "fa-solid fa-check",
|
|
108
|
+
error: "fa-solid fa-triangle-exclamation",
|
|
109
|
+
loading: "fa-solid fa-spinner fa-spin",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const variantColor: Record<ToastVariant, string> = {
|
|
113
|
+
default: "text-primary",
|
|
114
|
+
success: "text-green-500",
|
|
115
|
+
error: "text-destructive",
|
|
116
|
+
loading: "text-primary",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function Toaster({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
|
|
120
|
+
if (toasts.length === 0) return null
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-slot="toaster"
|
|
125
|
+
className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm"
|
|
126
|
+
>
|
|
127
|
+
{toasts.map((t) => (
|
|
128
|
+
<div
|
|
129
|
+
key={t.id}
|
|
130
|
+
data-slot="toast"
|
|
131
|
+
data-variant={t.variant}
|
|
132
|
+
className="flex items-start gap-3 rounded-lg border border-border bg-popover px-4 py-3 shadow-lg shadow-black/20 animate-in slide-in-from-right-full fade-in duration-200"
|
|
133
|
+
>
|
|
134
|
+
<i className={cn(variantIcon[t.variant], variantColor[t.variant], "mt-0.5 text-sm")} />
|
|
135
|
+
<div className="flex-1 min-w-0">
|
|
136
|
+
<p className="text-sm font-medium text-foreground">{t.title}</p>
|
|
137
|
+
{t.description && (
|
|
138
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{t.description}</p>
|
|
139
|
+
)}
|
|
140
|
+
{t.action && (
|
|
141
|
+
<button
|
|
142
|
+
onClick={t.action.onClick}
|
|
143
|
+
className="mt-1.5 text-xs font-medium text-primary hover:underline"
|
|
144
|
+
>
|
|
145
|
+
{t.action.label}
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => onDismiss(t.id)}
|
|
151
|
+
className="text-muted-foreground hover:text-foreground transition-colors text-xs p-0.5"
|
|
152
|
+
>
|
|
153
|
+
<i className="fa-solid fa-xmark" />
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -130,6 +130,13 @@ export {
|
|
|
130
130
|
|
|
131
131
|
export { JsonHighlight } from "./components/json-highlight"
|
|
132
132
|
|
|
133
|
+
export { AboutDialog } from "./components/about-dialog"
|
|
134
|
+
|
|
135
|
+
export type {
|
|
136
|
+
AboutApp,
|
|
137
|
+
AboutDialogProps,
|
|
138
|
+
} from "./components/about-dialog"
|
|
139
|
+
|
|
133
140
|
export {
|
|
134
141
|
AppHeader,
|
|
135
142
|
AppHeaderBrand,
|
|
@@ -139,3 +146,29 @@ export type {
|
|
|
139
146
|
AppHeaderProps,
|
|
140
147
|
AppHeaderBrandProps,
|
|
141
148
|
} from "./components/app-header"
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
ToastProvider,
|
|
152
|
+
useToast,
|
|
153
|
+
} from "./components/toast"
|
|
154
|
+
|
|
155
|
+
export type {
|
|
156
|
+
Toast,
|
|
157
|
+
ToastUpdate,
|
|
158
|
+
ToastVariant,
|
|
159
|
+
} from "./components/toast"
|
|
160
|
+
|
|
161
|
+
export {
|
|
162
|
+
FeedbackDialog,
|
|
163
|
+
FeedbackButton,
|
|
164
|
+
collectSystemInfo,
|
|
165
|
+
} from "./components/feedback-dialog"
|
|
166
|
+
|
|
167
|
+
export type {
|
|
168
|
+
FeedbackCategory,
|
|
169
|
+
FeedbackSubmission,
|
|
170
|
+
FeedbackResult,
|
|
171
|
+
SystemInfo,
|
|
172
|
+
FeedbackDialogProps,
|
|
173
|
+
FeedbackButtonProps,
|
|
174
|
+
} from "./components/feedback-dialog"
|