@olwiba/ui 0.2.20 → 0.2.21
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/index.d.ts +2 -1
- package/dist/index.js +47 -21
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AppPageHero.tsx +8 -2
- package/src/app/AppShell.tsx +45 -36
- package/src/context/OlwibaUIContext.tsx +3 -2
package/package.json
CHANGED
package/src/app/AppPageHero.tsx
CHANGED
|
@@ -35,8 +35,14 @@ export function AppPageHero({
|
|
|
35
35
|
<section
|
|
36
36
|
className={cn(
|
|
37
37
|
'relative overflow-hidden rounded-3xl border bg-card/80 p-5 shadow-sm sm:p-6',
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// The glow blobs are decoration, and `::after` is the section's last
|
|
39
|
+
// child — so with everything on `z-index: auto` it paints above the
|
|
40
|
+
// header content and swallows clicks on whatever sits under it. At
|
|
41
|
+
// narrow widths the action row stacks below the copy, right into the
|
|
42
|
+
// bottom-left blob, which is how a visible button stops responding.
|
|
43
|
+
// pointer-events-none keeps them purely visual.
|
|
44
|
+
'before:pointer-events-none before:absolute before:-right-16 before:-top-16 before:size-44 before:rounded-full before:bg-primary/15 before:blur-3xl',
|
|
45
|
+
'after:pointer-events-none after:absolute after:-bottom-20 after:left-10 after:size-52 after:rounded-full after:bg-primary/10 after:blur-3xl',
|
|
40
46
|
className,
|
|
41
47
|
)}
|
|
42
48
|
>
|
package/src/app/AppShell.tsx
CHANGED
|
@@ -4,10 +4,12 @@ import type { LucideIcon } from 'lucide-react';
|
|
|
4
4
|
import { useCallback, type CSSProperties, type ReactNode } from 'react';
|
|
5
5
|
import {
|
|
6
6
|
BellIcon,
|
|
7
|
+
BirdIcon,
|
|
7
8
|
CreditCardIcon,
|
|
8
9
|
LogOutIcon,
|
|
9
10
|
MoreVerticalIcon,
|
|
10
11
|
PlusCircleIcon,
|
|
12
|
+
SettingsIcon,
|
|
11
13
|
} from 'lucide-react';
|
|
12
14
|
import {
|
|
13
15
|
Avatar,
|
|
@@ -63,6 +65,7 @@ export interface AppShellUser {
|
|
|
63
65
|
onSignOut?: () => void;
|
|
64
66
|
onBilling?: () => void;
|
|
65
67
|
onNotifications?: () => void;
|
|
68
|
+
onSettings?: () => void;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export interface AppShellBrand {
|
|
@@ -142,41 +145,35 @@ const RAIL_SQUARE =
|
|
|
142
145
|
'group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!min-w-8 group-data-[collapsible=icon]:!max-w-8 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:transition-[width,height]';
|
|
143
146
|
|
|
144
147
|
/**
|
|
145
|
-
*
|
|
148
|
+
* A stable miniature identity artwork, instead of one grey square or a pair
|
|
149
|
+
* of initials for everybody.
|
|
146
150
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*/
|
|
151
|
-
function initialsOf(source: string): string {
|
|
152
|
-
const base = source.includes('@') ? (source.split('@')[0] ?? source) : source;
|
|
153
|
-
const words = base.split(/[\s._-]+/).filter(Boolean);
|
|
154
|
-
if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase();
|
|
155
|
-
return (words[0] ?? base).slice(0, 2).toUpperCase();
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* A stable colour per person, instead of one grey square for everybody.
|
|
160
|
-
*
|
|
161
|
-
* The hue comes from a hash of the email, so an avatar is always the same
|
|
162
|
-
* colour for the same account — recognisable at a glance in a member list, and
|
|
163
|
-
* identical across devices without storing anything.
|
|
151
|
+
* FNV-1a gives the same unsigned hash for the same name/email on every device.
|
|
152
|
+
* Different byte ranges choose three hues and the gradient angle, so nearby
|
|
153
|
+
* names do not merely receive nearby shades of the same two-colour gradient.
|
|
164
154
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* chrome rather than as a person. Saturation and lightness are fixed so the
|
|
168
|
-
* whole set sits at one weight and white text clears contrast on all of them;
|
|
169
|
-
* only the hue moves. A gradient across a small hue span gives it some depth
|
|
170
|
-
* without becoming a second brand.
|
|
155
|
+
* Lightness stays in a bounded range so the white bird remains legible. The
|
|
156
|
+
* radial highlight supplies depth without adding a fourth random colour.
|
|
171
157
|
*/
|
|
172
158
|
function identityGradient(seed: string): CSSProperties {
|
|
173
|
-
let hash =
|
|
159
|
+
let hash = 2166136261;
|
|
174
160
|
for (let i = 0; i < seed.length; i += 1) {
|
|
175
|
-
hash
|
|
161
|
+
hash ^= seed.charCodeAt(i);
|
|
162
|
+
hash = Math.imul(hash, 16777619);
|
|
176
163
|
}
|
|
177
|
-
|
|
164
|
+
hash >>>= 0;
|
|
165
|
+
|
|
166
|
+
const firstHue = hash % 360;
|
|
167
|
+
const secondHue = (firstHue + 55 + ((hash >>> 8) % 70)) % 360;
|
|
168
|
+
const thirdHue = (firstHue + 190 + ((hash >>> 16) % 110)) % 360;
|
|
169
|
+
const angleHash = Math.imul(hash ^ (hash >>> 15), 2246822519) >>> 0;
|
|
170
|
+
const angle = angleHash % 360;
|
|
171
|
+
|
|
178
172
|
return {
|
|
179
|
-
backgroundImage:
|
|
173
|
+
backgroundImage: [
|
|
174
|
+
`radial-gradient(circle at 28% 18%, oklch(0.94 0.06 ${thirdHue} / 0.78), transparent 34%)`,
|
|
175
|
+
`linear-gradient(${angle}deg, oklch(0.68 0.18 ${firstHue}), oklch(0.55 0.2 ${secondHue}) 52%, oklch(0.62 0.18 ${thirdHue}))`,
|
|
176
|
+
].join(', '),
|
|
180
177
|
color: 'oklch(0.98 0 0)',
|
|
181
178
|
};
|
|
182
179
|
}
|
|
@@ -185,8 +182,8 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
185
182
|
const { isMobile } = useSidebar();
|
|
186
183
|
const uiMode = useUIMode();
|
|
187
184
|
const avatarMode = uiMode !== 'default' ? (uiMode as 'playful' | 'smooth') : undefined;
|
|
188
|
-
const
|
|
189
|
-
const identityTint = identityGradient(
|
|
185
|
+
const identitySeed = `${user.name?.trim().toLocaleLowerCase() ?? ''}|${user.email.toLocaleLowerCase()}`;
|
|
186
|
+
const identityTint = identityGradient(identitySeed);
|
|
190
187
|
|
|
191
188
|
return (
|
|
192
189
|
<SidebarMenu>
|
|
@@ -206,8 +203,11 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
206
203
|
>
|
|
207
204
|
<Avatar mode={avatarMode} size="sm">
|
|
208
205
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
209
|
-
<AvatarFallback className="
|
|
210
|
-
|
|
206
|
+
<AvatarFallback className="text-white" style={identityTint}>
|
|
207
|
+
<BirdIcon
|
|
208
|
+
aria-hidden
|
|
209
|
+
className="size-4 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
|
|
210
|
+
/>
|
|
211
211
|
</AvatarFallback>
|
|
212
212
|
</Avatar>
|
|
213
213
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
@@ -229,8 +229,11 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
229
229
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
|
230
230
|
<Avatar mode={avatarMode} size="sm">
|
|
231
231
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
232
|
-
<AvatarFallback className="
|
|
233
|
-
|
|
232
|
+
<AvatarFallback className="text-white" style={identityTint}>
|
|
233
|
+
<BirdIcon
|
|
234
|
+
aria-hidden
|
|
235
|
+
className="size-4 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
|
|
236
|
+
/>
|
|
234
237
|
</AvatarFallback>
|
|
235
238
|
</Avatar>
|
|
236
239
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
@@ -239,7 +242,7 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
239
242
|
</div>
|
|
240
243
|
</div>
|
|
241
244
|
</DropdownMenuLabel>
|
|
242
|
-
{(user.onBilling || user.onNotifications) && (
|
|
245
|
+
{(user.onBilling || user.onNotifications || user.onSettings) && (
|
|
243
246
|
<>
|
|
244
247
|
<DropdownMenuSeparator />
|
|
245
248
|
<DropdownMenuGroup>
|
|
@@ -255,6 +258,12 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
255
258
|
Notifications
|
|
256
259
|
</DropdownMenuItem>
|
|
257
260
|
)}
|
|
261
|
+
{user.onSettings && (
|
|
262
|
+
<DropdownMenuItem onClick={user.onSettings}>
|
|
263
|
+
<SettingsIcon />
|
|
264
|
+
Settings
|
|
265
|
+
</DropdownMenuItem>
|
|
266
|
+
)}
|
|
258
267
|
</DropdownMenuGroup>
|
|
259
268
|
</>
|
|
260
269
|
)}
|
|
@@ -500,7 +509,7 @@ export function AppShell({
|
|
|
500
509
|
side={side}
|
|
501
510
|
sidebarPosition={sidebarPosition}
|
|
502
511
|
/>
|
|
503
|
-
<SidebarInset className=
|
|
512
|
+
<SidebarInset className="overflow-y-auto">
|
|
504
513
|
<ShellHeader pageTitle={pageTitle} headerStart={headerStart} headerEnd={headerEnd} />
|
|
505
514
|
<div className={contentClassName}>{children}</div>
|
|
506
515
|
</SidebarInset>
|
|
@@ -35,10 +35,11 @@ export interface OlwibaUIProviderProps {
|
|
|
35
35
|
mode?: UIMode;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function OlwibaUIProvider({ children, isMobile: isMobileProp, mode:
|
|
38
|
+
export function OlwibaUIProvider({ children, isMobile: isMobileProp, mode: modeProp }: OlwibaUIProviderProps) {
|
|
39
39
|
const detectedMobile = useIsMobile();
|
|
40
40
|
const isMobile = isMobileProp ?? detectedMobile;
|
|
41
|
-
const [
|
|
41
|
+
const [uncontrolledMode, setMode] = React.useState<UIMode>('default');
|
|
42
|
+
const mode = modeProp ?? uncontrolledMode;
|
|
42
43
|
|
|
43
44
|
const variant = mode !== 'default' ? (mode as UIVariant) : undefined;
|
|
44
45
|
|