@bizbasics/ui 0.1.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 +17 -0
- package/src/components/AppShell.tsx +44 -0
- package/src/components/AppSidebar.tsx +75 -0
- package/src/components/AppTopBar.tsx +127 -0
- package/src/components/Auth.tsx +197 -0
- package/src/components/Avatar.tsx +81 -0
- package/src/components/Badge.tsx +47 -0
- package/src/components/Button.tsx +99 -0
- package/src/components/Card.tsx +68 -0
- package/src/components/EmptyState.tsx +49 -0
- package/src/components/Input.tsx +146 -0
- package/src/components/Modal.tsx +119 -0
- package/src/components/NavItem.tsx +89 -0
- package/src/components/NavSection.tsx +46 -0
- package/src/components/PlatformNav.tsx +347 -0
- package/src/components/ProfileCallout.tsx +184 -0
- package/src/components/SidebarBrand.tsx +162 -0
- package/src/components/Spinner.tsx +43 -0
- package/src/components/Toast.tsx +131 -0
- package/src/components/UserProfilePanel.tsx +148 -0
- package/src/index.ts +32 -0
- package/src/tokens.css +158 -0
- package/tsconfig.json +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bizbasics/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./tokens.css": "./src/tokens.css"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"react": "^18",
|
|
13
|
+
"react-dom": "^18",
|
|
14
|
+
"lucide-react": ">=0.400.0",
|
|
15
|
+
"next": ">=14"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
sidebar: ReactNode;
|
|
5
|
+
topBar?: ReactNode;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
/** Apply dark content variant — sets --bb-app-bg to dark. Used by relay, monk. */
|
|
8
|
+
dark?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const shellStyle: CSSProperties = {
|
|
12
|
+
display: "flex",
|
|
13
|
+
height: "100vh",
|
|
14
|
+
overflow: "hidden",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const mainColStyle: CSSProperties = {
|
|
18
|
+
display: "flex",
|
|
19
|
+
flexDirection: "column",
|
|
20
|
+
flex: 1,
|
|
21
|
+
minWidth: 0,
|
|
22
|
+
overflow: "hidden",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const contentStyle: CSSProperties = {
|
|
26
|
+
flex: 1,
|
|
27
|
+
overflowY: "auto",
|
|
28
|
+
overflowX: "hidden",
|
|
29
|
+
background: "var(--bb-app-bg)",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function AppShell({ sidebar, topBar, children, dark }: Props) {
|
|
33
|
+
return (
|
|
34
|
+
<div style={shellStyle} className={dark ? "bb-dark-content" : undefined}>
|
|
35
|
+
{sidebar}
|
|
36
|
+
<div style={mainColStyle}>
|
|
37
|
+
{topBar}
|
|
38
|
+
<div style={contentStyle}>
|
|
39
|
+
{children}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
4
|
+
import { ProfileCallout } from "./ProfileCallout";
|
|
5
|
+
import type { ProfileCalloutUser } from "./ProfileCallout";
|
|
6
|
+
|
|
7
|
+
export interface AppSidebarProps {
|
|
8
|
+
/** Brand section — rendered at top of sidebar */
|
|
9
|
+
brand: ReactNode;
|
|
10
|
+
/** Main nav content — NavSection + NavItem components */
|
|
11
|
+
nav: ReactNode;
|
|
12
|
+
/** User for the profile callout at the bottom */
|
|
13
|
+
user: ProfileCalloutUser;
|
|
14
|
+
onSignOut: () => void;
|
|
15
|
+
settingsHref?: string;
|
|
16
|
+
/** Extra profile menu items (e.g. "Profile", "Keyboard shortcuts") */
|
|
17
|
+
profileMenuItems?: Array<{
|
|
18
|
+
label: string;
|
|
19
|
+
href?: string;
|
|
20
|
+
onClick?: () => void;
|
|
21
|
+
danger?: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
/** Status slot — rendered above the profile row (relay uses this for StatusPicker) */
|
|
24
|
+
statusSlot?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sidebarStyle: CSSProperties = {
|
|
28
|
+
width: "var(--bb-sidebar-width)",
|
|
29
|
+
minWidth: "var(--bb-sidebar-width)",
|
|
30
|
+
background: "var(--bb-sidebar-bg)",
|
|
31
|
+
display: "flex",
|
|
32
|
+
flexDirection: "column",
|
|
33
|
+
height: "100vh",
|
|
34
|
+
position: "sticky",
|
|
35
|
+
top: 0,
|
|
36
|
+
borderRight: "1px solid var(--bb-sidebar-border)",
|
|
37
|
+
overflowY: "auto",
|
|
38
|
+
overflowX: "hidden",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function AppSidebar({
|
|
42
|
+
brand,
|
|
43
|
+
nav,
|
|
44
|
+
user,
|
|
45
|
+
onSignOut,
|
|
46
|
+
settingsHref,
|
|
47
|
+
profileMenuItems,
|
|
48
|
+
statusSlot,
|
|
49
|
+
}: AppSidebarProps) {
|
|
50
|
+
return (
|
|
51
|
+
<aside style={sidebarStyle}>
|
|
52
|
+
{/* Brand/header */}
|
|
53
|
+
<div style={{
|
|
54
|
+
borderBottom: "1px solid var(--bb-sidebar-border)",
|
|
55
|
+
flexShrink: 0,
|
|
56
|
+
}}>
|
|
57
|
+
{brand}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{/* Main nav — scrollable */}
|
|
61
|
+
<div style={{ flex: 1, overflowY: "auto", padding: "8px 0" }}>
|
|
62
|
+
{nav}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{/* Profile callout — pinned to bottom */}
|
|
66
|
+
<ProfileCallout
|
|
67
|
+
user={user}
|
|
68
|
+
onSignOut={onSignOut}
|
|
69
|
+
settingsHref={settingsHref}
|
|
70
|
+
extraItems={profileMenuItems}
|
|
71
|
+
statusSlot={statusSlot}
|
|
72
|
+
/>
|
|
73
|
+
</aside>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Left side: page title or breadcrumb */
|
|
7
|
+
title?: ReactNode;
|
|
8
|
+
/** Left side actions/breadcrumb extras */
|
|
9
|
+
titleActions?: ReactNode;
|
|
10
|
+
/** Right side: notification bells, search, etc. */
|
|
11
|
+
actions?: ReactNode;
|
|
12
|
+
/** Optional border-bottom override */
|
|
13
|
+
noBorder?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const barStyle: CSSProperties = {
|
|
17
|
+
display: "flex",
|
|
18
|
+
alignItems: "center",
|
|
19
|
+
justifyContent: "space-between",
|
|
20
|
+
height: "var(--bb-topbar-height)",
|
|
21
|
+
padding: "0 20px",
|
|
22
|
+
background: "var(--bb-topbar-bg)",
|
|
23
|
+
borderBottom: "1px solid var(--bb-topbar-border)",
|
|
24
|
+
flexShrink: 0,
|
|
25
|
+
zIndex: 10,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const leftStyle: CSSProperties = {
|
|
29
|
+
display: "flex",
|
|
30
|
+
alignItems: "center",
|
|
31
|
+
gap: "12px",
|
|
32
|
+
minWidth: 0,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const titleStyle: CSSProperties = {
|
|
36
|
+
fontSize: "var(--bb-text-md)",
|
|
37
|
+
fontWeight: 600,
|
|
38
|
+
color: "var(--bb-topbar-text)",
|
|
39
|
+
overflow: "hidden",
|
|
40
|
+
textOverflow: "ellipsis",
|
|
41
|
+
whiteSpace: "nowrap",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const rightStyle: CSSProperties = {
|
|
45
|
+
display: "flex",
|
|
46
|
+
alignItems: "center",
|
|
47
|
+
gap: "4px",
|
|
48
|
+
flexShrink: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function AppTopBar({ title, titleActions, actions, noBorder }: Props) {
|
|
52
|
+
return (
|
|
53
|
+
<div style={{ ...barStyle, borderBottom: noBorder ? "none" : barStyle.borderBottom }}>
|
|
54
|
+
<div style={leftStyle}>
|
|
55
|
+
{title && (
|
|
56
|
+
<span style={titleStyle}>{title}</span>
|
|
57
|
+
)}
|
|
58
|
+
{titleActions}
|
|
59
|
+
</div>
|
|
60
|
+
<div style={rightStyle}>
|
|
61
|
+
{actions}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Small icon button for the top bar (notifications, search, etc.) */
|
|
68
|
+
export function TopBarIconButton({
|
|
69
|
+
children,
|
|
70
|
+
title,
|
|
71
|
+
onClick,
|
|
72
|
+
badge,
|
|
73
|
+
}: {
|
|
74
|
+
children: ReactNode;
|
|
75
|
+
title?: string;
|
|
76
|
+
onClick?: () => void;
|
|
77
|
+
badge?: number;
|
|
78
|
+
}) {
|
|
79
|
+
return (
|
|
80
|
+
<button
|
|
81
|
+
onClick={onClick}
|
|
82
|
+
title={title}
|
|
83
|
+
style={{
|
|
84
|
+
position: "relative",
|
|
85
|
+
display: "flex",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
justifyContent: "center",
|
|
88
|
+
width: "32px",
|
|
89
|
+
height: "32px",
|
|
90
|
+
borderRadius: "var(--bb-radius)",
|
|
91
|
+
background: "transparent",
|
|
92
|
+
border: "none",
|
|
93
|
+
color: "var(--bb-topbar-muted)",
|
|
94
|
+
cursor: "pointer",
|
|
95
|
+
transition: "background var(--bb-transition), color var(--bb-transition)",
|
|
96
|
+
}}
|
|
97
|
+
onMouseEnter={(e) => {
|
|
98
|
+
(e.currentTarget).style.background = "rgba(255,255,255,0.07)";
|
|
99
|
+
(e.currentTarget).style.color = "var(--bb-topbar-text)";
|
|
100
|
+
}}
|
|
101
|
+
onMouseLeave={(e) => {
|
|
102
|
+
(e.currentTarget).style.background = "transparent";
|
|
103
|
+
(e.currentTarget).style.color = "var(--bb-topbar-muted)";
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{children}
|
|
107
|
+
{badge !== undefined && badge > 0 && (
|
|
108
|
+
<span style={{
|
|
109
|
+
position: "absolute",
|
|
110
|
+
top: "2px",
|
|
111
|
+
right: "2px",
|
|
112
|
+
background: "var(--bb-brand)",
|
|
113
|
+
color: "#fff",
|
|
114
|
+
fontSize: "9px",
|
|
115
|
+
fontWeight: 700,
|
|
116
|
+
lineHeight: 1,
|
|
117
|
+
padding: "1px 3px",
|
|
118
|
+
borderRadius: "var(--bb-radius-full)",
|
|
119
|
+
minWidth: "14px",
|
|
120
|
+
textAlign: "center",
|
|
121
|
+
}}>
|
|
122
|
+
{badge > 99 ? "99+" : badge}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
</button>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, ReactNode, InputHTMLAttributes, ButtonHTMLAttributes } from "react";
|
|
4
|
+
|
|
5
|
+
// ── AuthLayout ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
interface AuthLayoutProps {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
brand?: ReactNode;
|
|
10
|
+
footer?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function AuthLayout({ children, brand, footer }: AuthLayoutProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div style={{
|
|
16
|
+
minHeight: "100vh",
|
|
17
|
+
display: "flex",
|
|
18
|
+
flexDirection: "column",
|
|
19
|
+
alignItems: "center",
|
|
20
|
+
justifyContent: "center",
|
|
21
|
+
background: "#0f1117",
|
|
22
|
+
fontFamily: "var(--bb-font-sans)",
|
|
23
|
+
padding: "24px 16px",
|
|
24
|
+
}}>
|
|
25
|
+
{brand && (
|
|
26
|
+
<div style={{ marginBottom: "28px", textAlign: "center" }}>{brand}</div>
|
|
27
|
+
)}
|
|
28
|
+
{children}
|
|
29
|
+
{footer && (
|
|
30
|
+
<div style={{
|
|
31
|
+
marginTop: "24px",
|
|
32
|
+
fontSize: "var(--bb-text-xs)",
|
|
33
|
+
color: "var(--bb-slate-500)",
|
|
34
|
+
textAlign: "center",
|
|
35
|
+
}}>
|
|
36
|
+
{footer}
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── AuthCard ──────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
interface AuthCardProps {
|
|
46
|
+
title: string;
|
|
47
|
+
subtitle?: ReactNode;
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
width?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function AuthCard({ title, subtitle, children, width = 380 }: AuthCardProps) {
|
|
53
|
+
return (
|
|
54
|
+
<div style={{
|
|
55
|
+
width: "100%",
|
|
56
|
+
maxWidth: `${width}px`,
|
|
57
|
+
background: "#1a1d24",
|
|
58
|
+
border: "1px solid rgba(255,255,255,0.08)",
|
|
59
|
+
borderRadius: "var(--bb-radius-lg)",
|
|
60
|
+
boxShadow: "0 20px 60px rgba(0,0,0,0.4), 0 4px 16px rgba(0,0,0,0.2)",
|
|
61
|
+
padding: "32px",
|
|
62
|
+
}}>
|
|
63
|
+
<div style={{ marginBottom: "24px" }}>
|
|
64
|
+
<h1 style={{
|
|
65
|
+
margin: 0,
|
|
66
|
+
fontSize: "var(--bb-text-xl)",
|
|
67
|
+
fontWeight: 700,
|
|
68
|
+
color: "#f1f5f9",
|
|
69
|
+
letterSpacing: "-0.01em",
|
|
70
|
+
}}>
|
|
71
|
+
{title}
|
|
72
|
+
</h1>
|
|
73
|
+
{subtitle && (
|
|
74
|
+
<p style={{
|
|
75
|
+
margin: "8px 0 0",
|
|
76
|
+
fontSize: "var(--bb-text-sm)",
|
|
77
|
+
color: "var(--bb-slate-400)",
|
|
78
|
+
lineHeight: 1.5,
|
|
79
|
+
}}>
|
|
80
|
+
{subtitle}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
{children}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── AuthInput ─────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
interface AuthInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
92
|
+
label?: string;
|
|
93
|
+
error?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function AuthInput({ label, error, style, ...rest }: AuthInputProps) {
|
|
97
|
+
const inputStyle: CSSProperties = {
|
|
98
|
+
width: "100%",
|
|
99
|
+
boxSizing: "border-box",
|
|
100
|
+
background: "rgba(255,255,255,0.05)",
|
|
101
|
+
border: `1px solid ${error ? "var(--bb-error)" : "rgba(255,255,255,0.10)"}`,
|
|
102
|
+
borderRadius: "var(--bb-radius)",
|
|
103
|
+
padding: "10px 12px",
|
|
104
|
+
fontSize: "var(--bb-text-sm)",
|
|
105
|
+
fontFamily: "var(--bb-font-sans)",
|
|
106
|
+
color: "#e5e7eb",
|
|
107
|
+
outline: "none",
|
|
108
|
+
transition: "border-color var(--bb-transition), box-shadow var(--bb-transition)",
|
|
109
|
+
...style,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div>
|
|
114
|
+
{label && (
|
|
115
|
+
<label style={{
|
|
116
|
+
display: "block",
|
|
117
|
+
fontSize: "var(--bb-text-xs)",
|
|
118
|
+
fontWeight: 500,
|
|
119
|
+
color: "var(--bb-slate-400)",
|
|
120
|
+
marginBottom: "5px",
|
|
121
|
+
}}>
|
|
122
|
+
{label}
|
|
123
|
+
</label>
|
|
124
|
+
)}
|
|
125
|
+
<input
|
|
126
|
+
style={inputStyle}
|
|
127
|
+
onFocus={(e) => {
|
|
128
|
+
e.currentTarget.style.borderColor = error ? "var(--bb-error)" : "rgba(99,102,241,0.7)";
|
|
129
|
+
e.currentTarget.style.boxShadow = error
|
|
130
|
+
? "0 0 0 3px rgba(220,38,38,0.15)"
|
|
131
|
+
: "0 0 0 3px rgba(99,102,241,0.15)";
|
|
132
|
+
}}
|
|
133
|
+
onBlur={(e) => {
|
|
134
|
+
e.currentTarget.style.borderColor = error ? "var(--bb-error)" : "rgba(255,255,255,0.10)";
|
|
135
|
+
e.currentTarget.style.boxShadow = "none";
|
|
136
|
+
}}
|
|
137
|
+
{...rest}
|
|
138
|
+
/>
|
|
139
|
+
{error && (
|
|
140
|
+
<p style={{ margin: "5px 0 0", fontSize: "var(--bb-text-xs)", color: "var(--bb-error)" }}>
|
|
141
|
+
{error}
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── AuthButton ────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
interface AuthButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
151
|
+
loading?: boolean;
|
|
152
|
+
brandColor?: string;
|
|
153
|
+
children: ReactNode;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function AuthButton({ loading, brandColor = "#6366f1", children, disabled, style, ...rest }: AuthButtonProps) {
|
|
157
|
+
return (
|
|
158
|
+
<button
|
|
159
|
+
disabled={disabled || loading}
|
|
160
|
+
style={{
|
|
161
|
+
width: "100%",
|
|
162
|
+
padding: "11px 20px",
|
|
163
|
+
background: disabled || loading ? `${brandColor}99` : brandColor,
|
|
164
|
+
color: "#fff",
|
|
165
|
+
border: "none",
|
|
166
|
+
borderRadius: "var(--bb-radius)",
|
|
167
|
+
fontSize: "var(--bb-text-sm)",
|
|
168
|
+
fontWeight: 600,
|
|
169
|
+
fontFamily: "var(--bb-font-sans)",
|
|
170
|
+
cursor: disabled || loading ? "not-allowed" : "pointer",
|
|
171
|
+
transition: "background var(--bb-transition), opacity var(--bb-transition)",
|
|
172
|
+
display: "flex",
|
|
173
|
+
alignItems: "center",
|
|
174
|
+
justifyContent: "center",
|
|
175
|
+
gap: "8px",
|
|
176
|
+
...style,
|
|
177
|
+
}}
|
|
178
|
+
{...rest}
|
|
179
|
+
>
|
|
180
|
+
{loading && <BtnSpinner />}
|
|
181
|
+
{children}
|
|
182
|
+
</button>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function BtnSpinner() {
|
|
187
|
+
return (
|
|
188
|
+
<svg
|
|
189
|
+
width={14} height={14} viewBox="0 0 24 24" fill="none"
|
|
190
|
+
style={{ animation: "bb-spin 0.7s linear infinite", flexShrink: 0 }}
|
|
191
|
+
>
|
|
192
|
+
<style>{`@keyframes bb-spin { to { transform: rotate(360deg); } }`}</style>
|
|
193
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3"
|
|
194
|
+
strokeDasharray="60" strokeDashoffset="15" strokeLinecap="round" />
|
|
195
|
+
</svg>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
interface Props {
|
|
4
|
+
name: string;
|
|
5
|
+
size?: number;
|
|
6
|
+
/** Online/offline dot */
|
|
7
|
+
online?: boolean;
|
|
8
|
+
src?: string;
|
|
9
|
+
style?: CSSProperties;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function initials(name: string) {
|
|
13
|
+
const parts = name.trim().split(/\s+/);
|
|
14
|
+
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
|
|
15
|
+
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hueFromName(name: string) {
|
|
19
|
+
let h = 0;
|
|
20
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffff;
|
|
21
|
+
return h % 360;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Avatar({ name, size = 32, online, src, style }: Props) {
|
|
25
|
+
const hue = hueFromName(name);
|
|
26
|
+
const bg = `hsl(${hue}, 55%, 40%)`;
|
|
27
|
+
|
|
28
|
+
const containerStyle: CSSProperties = {
|
|
29
|
+
position: "relative",
|
|
30
|
+
display: "inline-flex",
|
|
31
|
+
flexShrink: 0,
|
|
32
|
+
width: size,
|
|
33
|
+
height: size,
|
|
34
|
+
...style,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const imgStyle: CSSProperties = {
|
|
38
|
+
width: size,
|
|
39
|
+
height: size,
|
|
40
|
+
borderRadius: "var(--bb-radius-full)",
|
|
41
|
+
objectFit: "cover",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const initialsStyle: CSSProperties = {
|
|
45
|
+
width: size,
|
|
46
|
+
height: size,
|
|
47
|
+
borderRadius: "var(--bb-radius-full)",
|
|
48
|
+
background: bg,
|
|
49
|
+
display: "flex",
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
justifyContent: "center",
|
|
52
|
+
fontSize: `${Math.round(size * 0.38)}px`,
|
|
53
|
+
fontWeight: 600,
|
|
54
|
+
color: "#fff",
|
|
55
|
+
userSelect: "none",
|
|
56
|
+
letterSpacing: "-0.02em",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const dotStyle: CSSProperties = {
|
|
60
|
+
position: "absolute",
|
|
61
|
+
bottom: -1,
|
|
62
|
+
right: -1,
|
|
63
|
+
width: Math.max(8, Math.round(size * 0.28)),
|
|
64
|
+
height: Math.max(8, Math.round(size * 0.28)),
|
|
65
|
+
borderRadius: "var(--bb-radius-full)",
|
|
66
|
+
background: online ? "#22c55e" : "#6b7280",
|
|
67
|
+
border: "2px solid var(--bb-sidebar-bg)",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div style={containerStyle}>
|
|
72
|
+
{src ? (
|
|
73
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
74
|
+
<img src={src} alt={name} style={imgStyle} />
|
|
75
|
+
) : (
|
|
76
|
+
<div style={initialsStyle}>{initials(name)}</div>
|
|
77
|
+
)}
|
|
78
|
+
{online !== undefined && <div style={dotStyle} />}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type Variant = "default" | "brand" | "success" | "warning" | "error" | "info";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
variant?: Variant;
|
|
8
|
+
dot?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const variantStyles: Record<Variant, CSSProperties> = {
|
|
12
|
+
default: { background: "#f3f4f6", color: "#374151", border: "1px solid #e5e7eb" },
|
|
13
|
+
brand: { background: "rgba(124,58,237,0.12)", color: "var(--bb-brand-light)", border: "1px solid rgba(124,58,237,0.25)" },
|
|
14
|
+
success: { background: "var(--bb-success-bg)", color: "var(--bb-success)", border: "1px solid var(--bb-success-border)" },
|
|
15
|
+
warning: { background: "var(--bb-warning-bg)", color: "var(--bb-warning)", border: "1px solid var(--bb-warning-border)" },
|
|
16
|
+
error: { background: "var(--bb-error-bg)", color: "var(--bb-error)", border: "1px solid var(--bb-error-border)" },
|
|
17
|
+
info: { background: "var(--bb-info-bg)", color: "var(--bb-info)", border: "1px solid var(--bb-info-border)" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function Badge({ children, variant = "default", dot }: Props) {
|
|
21
|
+
return (
|
|
22
|
+
<span style={{
|
|
23
|
+
display: "inline-flex",
|
|
24
|
+
alignItems: "center",
|
|
25
|
+
gap: "5px",
|
|
26
|
+
padding: "2px 8px",
|
|
27
|
+
borderRadius: "var(--bb-radius-full)",
|
|
28
|
+
fontSize: "var(--bb-text-xs)",
|
|
29
|
+
fontWeight: 500,
|
|
30
|
+
lineHeight: "18px",
|
|
31
|
+
userSelect: "none",
|
|
32
|
+
whiteSpace: "nowrap",
|
|
33
|
+
...variantStyles[variant],
|
|
34
|
+
}}>
|
|
35
|
+
{dot && (
|
|
36
|
+
<span style={{
|
|
37
|
+
width: "6px",
|
|
38
|
+
height: "6px",
|
|
39
|
+
borderRadius: "var(--bb-radius-full)",
|
|
40
|
+
background: "currentColor",
|
|
41
|
+
flexShrink: 0,
|
|
42
|
+
}} />
|
|
43
|
+
)}
|
|
44
|
+
{children}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, ReactNode, ButtonHTMLAttributes } from "react";
|
|
4
|
+
|
|
5
|
+
type Variant = "primary" | "secondary" | "ghost" | "danger" | "outline";
|
|
6
|
+
type Size = "sm" | "md" | "lg";
|
|
7
|
+
|
|
8
|
+
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
variant?: Variant;
|
|
10
|
+
size?: Size;
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
fullWidth?: boolean;
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const variantStyles: Record<Variant, CSSProperties> = {
|
|
18
|
+
primary: {
|
|
19
|
+
background: "var(--bb-brand)",
|
|
20
|
+
color: "#fff",
|
|
21
|
+
border: "1px solid transparent",
|
|
22
|
+
},
|
|
23
|
+
secondary: {
|
|
24
|
+
background: "var(--bb-surface)",
|
|
25
|
+
color: "var(--bb-text-secondary)",
|
|
26
|
+
border: "1px solid var(--bb-border)",
|
|
27
|
+
},
|
|
28
|
+
ghost: {
|
|
29
|
+
background: "transparent",
|
|
30
|
+
color: "var(--bb-text-secondary)",
|
|
31
|
+
border: "1px solid transparent",
|
|
32
|
+
},
|
|
33
|
+
danger: {
|
|
34
|
+
background: "var(--bb-error-bg)",
|
|
35
|
+
color: "var(--bb-error)",
|
|
36
|
+
border: "1px solid var(--bb-error-border)",
|
|
37
|
+
},
|
|
38
|
+
outline: {
|
|
39
|
+
background: "transparent",
|
|
40
|
+
color: "var(--bb-brand-light)",
|
|
41
|
+
border: "1px solid var(--bb-brand)",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const sizeStyles: Record<Size, CSSProperties> = {
|
|
46
|
+
sm: { padding: "5px 12px", fontSize: "var(--bb-text-xs)", height: "28px" },
|
|
47
|
+
md: { padding: "8px 16px", fontSize: "var(--bb-text-sm)", height: "36px" },
|
|
48
|
+
lg: { padding: "10px 20px", fontSize: "var(--bb-text-base)", height: "42px" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function Button({
|
|
52
|
+
variant = "primary",
|
|
53
|
+
size = "md",
|
|
54
|
+
loading,
|
|
55
|
+
children,
|
|
56
|
+
fullWidth,
|
|
57
|
+
icon,
|
|
58
|
+
disabled,
|
|
59
|
+
style,
|
|
60
|
+
...rest
|
|
61
|
+
}: Props) {
|
|
62
|
+
const base: CSSProperties = {
|
|
63
|
+
display: "inline-flex",
|
|
64
|
+
alignItems: "center",
|
|
65
|
+
justifyContent: "center",
|
|
66
|
+
gap: "7px",
|
|
67
|
+
borderRadius: "var(--bb-radius)",
|
|
68
|
+
fontWeight: 500,
|
|
69
|
+
fontFamily: "var(--bb-font-sans)",
|
|
70
|
+
cursor: disabled || loading ? "not-allowed" : "pointer",
|
|
71
|
+
opacity: disabled || loading ? 0.55 : 1,
|
|
72
|
+
transition: "background var(--bb-transition), opacity var(--bb-transition), box-shadow var(--bb-transition)",
|
|
73
|
+
userSelect: "none",
|
|
74
|
+
whiteSpace: "nowrap",
|
|
75
|
+
width: fullWidth ? "100%" : undefined,
|
|
76
|
+
...variantStyles[variant],
|
|
77
|
+
...sizeStyles[size],
|
|
78
|
+
...style,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<button disabled={disabled || loading} style={base} {...rest}>
|
|
83
|
+
{loading ? <Spinner size={14} /> : icon}
|
|
84
|
+
{children}
|
|
85
|
+
</button>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Spinner({ size }: { size: number }) {
|
|
90
|
+
return (
|
|
91
|
+
<svg
|
|
92
|
+
width={size} height={size} viewBox="0 0 24 24" fill="none"
|
|
93
|
+
style={{ animation: "bb-spin 0.7s linear infinite", flexShrink: 0 }}
|
|
94
|
+
>
|
|
95
|
+
<style>{`@keyframes bb-spin { to { transform: rotate(360deg); } }`}</style>
|
|
96
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" strokeDasharray="60" strokeDashoffset="15" strokeLinecap="round" />
|
|
97
|
+
</svg>
|
|
98
|
+
);
|
|
99
|
+
}
|