@redbamboo/ui 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redbamboo/ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "RedBamboo design system — tokens, Tailwind theme, and components",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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"