@playfast/reform-better-hub-ui-primitives 0.0.5 → 0.2.1
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/README.md +2 -2
- package/package.json +2 -2
- package/src/components/agent-icon.tsx +5 -1
- package/src/components/badge.tsx +9 -4
- package/src/components/button.tsx +11 -8
- package/src/components/combobox.tsx +15 -9
- package/src/components/command.tsx +8 -6
- package/src/components/dialog.tsx +5 -3
- package/src/components/dropdown-menu.tsx +10 -6
- package/src/components/github-background.tsx +104 -126
- package/src/components/halftone-background.tsx +71 -53
- package/src/components/input-group.tsx +16 -9
- package/src/components/live-duration.tsx +47 -24
- package/src/components/logo.tsx +6 -2
- package/src/components/resize-handle.tsx +9 -7
- package/src/components/sheet.tsx +2 -2
- package/src/components/time-ago.tsx +41 -17
- package/src/foundation/cn.ts +1 -0
- /package/src/{index.tsx → index.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
# `@playfast/better-hub-ui-primitives`
|
|
3
|
+
# `@playfast/reform-better-hub-ui-primitives`
|
|
4
4
|
|
|
5
5
|
**React UI primitives — buttons, badges, dialogs, inputs, popovers, a command palette, and friends — adapted from better-hub by Better Auth.**
|
|
6
6
|
|
|
@@ -36,7 +36,7 @@ Docs: [playbook/api.doc.md](./playbook/api.doc.md).
|
|
|
36
36
|
## Install
|
|
37
37
|
|
|
38
38
|
```sh
|
|
39
|
-
bun add @playfast/better-hub-ui-primitives
|
|
39
|
+
bun add @playfast/reform-better-hub-ui-primitives
|
|
40
40
|
# peers: react, react-dom (^19)
|
|
41
41
|
```
|
|
42
42
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-better-hub-ui-primitives",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "React UI primitives adapted from better-hub by Better Auth (https://github.com/better-auth/better-hub). Full credit to the Better Auth team.",
|
|
7
7
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"exports": {
|
|
31
31
|
"./package.json": "./package.json",
|
|
32
|
-
".": "./src/index.
|
|
32
|
+
".": "./src/index.ts",
|
|
33
33
|
"./styles.css": "./src/foundation/styles.css",
|
|
34
34
|
"./*": "./src/*.tsx"
|
|
35
35
|
},
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import type * as React from "react";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
interface AgentIconPropsExternalApi {
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function AgentIcon({ className }: AgentIconPropsExternalApi): React.JSX.Element {
|
|
7
11
|
return (
|
|
8
12
|
<svg
|
|
9
13
|
xmlns="http://www.w3.org/2000/svg"
|
package/src/components/badge.tsx
CHANGED
|
@@ -7,13 +7,15 @@ import { Slot } from "radix-ui";
|
|
|
7
7
|
|
|
8
8
|
import { cn } from "../foundation/cn";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
type BadgeVariantsExternalApi = (
|
|
11
11
|
props?:
|
|
12
12
|
| ({
|
|
13
|
-
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" |
|
|
13
|
+
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | undefined;
|
|
14
14
|
} & ClassProp)
|
|
15
15
|
| undefined,
|
|
16
|
-
) => string
|
|
16
|
+
) => string;
|
|
17
|
+
|
|
18
|
+
const badgeVariants: BadgeVariantsExternalApi = cva(
|
|
17
19
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
|
18
20
|
{
|
|
19
21
|
variants: {
|
|
@@ -33,12 +35,15 @@ const badgeVariants: (
|
|
|
33
35
|
},
|
|
34
36
|
);
|
|
35
37
|
|
|
38
|
+
type BadgePropsExternalApi = React.ComponentProps<"span"> &
|
|
39
|
+
VariantProps<typeof badgeVariants> & { asChild?: boolean };
|
|
40
|
+
|
|
36
41
|
function Badge({
|
|
37
42
|
className,
|
|
38
43
|
variant = "default",
|
|
39
44
|
asChild = false,
|
|
40
45
|
...props
|
|
41
|
-
}:
|
|
46
|
+
}: BadgePropsExternalApi): React.JSX.Element {
|
|
42
47
|
const Comp = asChild ? Slot.Root : "span";
|
|
43
48
|
|
|
44
49
|
return (
|
|
@@ -7,7 +7,7 @@ import type { ClassProp } from "class-variance-authority/types";
|
|
|
7
7
|
import type * as React from "react";
|
|
8
8
|
import { cn } from "../foundation/cn";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
type ButtonVariantsExternalApi = (
|
|
11
11
|
props?:
|
|
12
12
|
| ({
|
|
13
13
|
variant?:
|
|
@@ -17,12 +17,13 @@ const buttonVariants: (
|
|
|
17
17
|
| "secondary"
|
|
18
18
|
| "ghost"
|
|
19
19
|
| "link"
|
|
20
|
-
| null
|
|
21
20
|
| undefined;
|
|
22
|
-
size?: "default" | "sm" | "lg" | "icon" |
|
|
21
|
+
size?: "default" | "sm" | "lg" | "icon" | undefined;
|
|
23
22
|
} & ClassProp)
|
|
24
23
|
| undefined,
|
|
25
|
-
) => string
|
|
24
|
+
) => string;
|
|
25
|
+
|
|
26
|
+
const buttonVariants: ButtonVariantsExternalApi = cva(
|
|
26
27
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
27
28
|
{
|
|
28
29
|
variants: {
|
|
@@ -49,16 +50,18 @@ const buttonVariants: (
|
|
|
49
50
|
},
|
|
50
51
|
);
|
|
51
52
|
|
|
53
|
+
type ButtonPropsExternalApi = React.ComponentProps<"button"> &
|
|
54
|
+
VariantProps<typeof buttonVariants> & {
|
|
55
|
+
asChild?: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
52
58
|
function Button({
|
|
53
59
|
className,
|
|
54
60
|
variant,
|
|
55
61
|
size,
|
|
56
62
|
asChild = false,
|
|
57
63
|
...props
|
|
58
|
-
}: React.
|
|
59
|
-
VariantProps<typeof buttonVariants> & {
|
|
60
|
-
asChild?: boolean;
|
|
61
|
-
}): React.JSX.Element {
|
|
64
|
+
}: ButtonPropsExternalApi): React.JSX.Element {
|
|
62
65
|
const Comp = asChild ? Slot : "button";
|
|
63
66
|
return (
|
|
64
67
|
<Comp
|
|
@@ -49,6 +49,11 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props):
|
|
|
49
49
|
)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
type ComboboxInputPropsExternalApi = ComboboxPrimitive.Input.Props & {
|
|
53
|
+
showTrigger?: boolean
|
|
54
|
+
showClear?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
function ComboboxInput({
|
|
53
58
|
className,
|
|
54
59
|
children,
|
|
@@ -56,10 +61,7 @@ function ComboboxInput({
|
|
|
56
61
|
showTrigger = true,
|
|
57
62
|
showClear = false,
|
|
58
63
|
...props
|
|
59
|
-
}:
|
|
60
|
-
showTrigger?: boolean
|
|
61
|
-
showClear?: boolean
|
|
62
|
-
}): React.JSX.Element {
|
|
64
|
+
}: ComboboxInputPropsExternalApi): React.JSX.Element {
|
|
63
65
|
return (
|
|
64
66
|
<InputGroup className={cn('w-auto', className)}>
|
|
65
67
|
<ComboboxPrimitive.Input
|
|
@@ -225,14 +227,16 @@ function ComboboxChips({
|
|
|
225
227
|
)
|
|
226
228
|
}
|
|
227
229
|
|
|
230
|
+
type ComboboxChipPropsExternalApi = ComboboxPrimitive.Chip.Props & {
|
|
231
|
+
showRemove?: boolean
|
|
232
|
+
}
|
|
233
|
+
|
|
228
234
|
function ComboboxChip({
|
|
229
235
|
className,
|
|
230
236
|
children,
|
|
231
237
|
showRemove = true,
|
|
232
238
|
...props
|
|
233
|
-
}:
|
|
234
|
-
showRemove?: boolean
|
|
235
|
-
}): React.JSX.Element {
|
|
239
|
+
}: ComboboxChipPropsExternalApi): React.JSX.Element {
|
|
236
240
|
return (
|
|
237
241
|
<ComboboxPrimitive.Chip
|
|
238
242
|
data-slot="combobox-chip"
|
|
@@ -266,8 +270,10 @@ function ComboboxChipsInput({ className, children, ...props }: ComboboxPrimitive
|
|
|
266
270
|
)
|
|
267
271
|
}
|
|
268
272
|
|
|
269
|
-
|
|
270
|
-
|
|
273
|
+
type ComboboxAnchorElementExternalApi = HTMLDivElement | null
|
|
274
|
+
|
|
275
|
+
function useComboboxAnchor(): React.RefObject<ComboboxAnchorElementExternalApi> {
|
|
276
|
+
return React.useRef<ComboboxAnchorElementExternalApi>(null)
|
|
271
277
|
}
|
|
272
278
|
|
|
273
279
|
export {
|
|
@@ -26,6 +26,13 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
|
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
type CommandDialogPropsExternalApi = React.ComponentProps<typeof Dialog> & {
|
|
30
|
+
title?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
showCloseButton?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
29
36
|
function CommandDialog({
|
|
30
37
|
title = "Command Palette",
|
|
31
38
|
description = "Search for a command to run...",
|
|
@@ -33,12 +40,7 @@ function CommandDialog({
|
|
|
33
40
|
className,
|
|
34
41
|
showCloseButton = true,
|
|
35
42
|
...props
|
|
36
|
-
}: React.
|
|
37
|
-
title?: string;
|
|
38
|
-
description?: string;
|
|
39
|
-
className?: string;
|
|
40
|
-
showCloseButton?: boolean;
|
|
41
|
-
}): React.JSX.Element {
|
|
43
|
+
}: CommandDialogPropsExternalApi): React.JSX.Element {
|
|
42
44
|
return (
|
|
43
45
|
<Dialog {...props}>
|
|
44
46
|
<DialogContent
|
|
@@ -38,14 +38,16 @@ function DialogOverlay({
|
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
type DialogContentPropsExternalApi = React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
42
|
+
showCloseButton?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
41
45
|
function DialogContent({
|
|
42
46
|
className,
|
|
43
47
|
children,
|
|
44
48
|
showCloseButton = true,
|
|
45
49
|
...props
|
|
46
|
-
}: React.
|
|
47
|
-
showCloseButton?: boolean;
|
|
48
|
-
}): React.JSX.Element {
|
|
50
|
+
}: DialogContentPropsExternalApi): React.JSX.Element {
|
|
49
51
|
return (
|
|
50
52
|
<DialogPortal>
|
|
51
53
|
<DialogOverlay />
|
|
@@ -46,13 +46,15 @@ function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMen
|
|
|
46
46
|
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
type DropdownMenuItemPropsExternalApi = React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
50
|
+
inset?: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
49
53
|
function DropdownMenuItem({
|
|
50
54
|
className,
|
|
51
55
|
inset,
|
|
52
56
|
...props
|
|
53
|
-
}: React.
|
|
54
|
-
inset?: boolean;
|
|
55
|
-
}): React.JSX.Element {
|
|
57
|
+
}: DropdownMenuItemPropsExternalApi): React.JSX.Element {
|
|
56
58
|
return (
|
|
57
59
|
<DropdownMenuPrimitive.Item
|
|
58
60
|
data-slot="dropdown-menu-item"
|
|
@@ -90,13 +92,15 @@ function DropdownMenuCheckboxItem({
|
|
|
90
92
|
);
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
type DropdownMenuLabelPropsExternalApi = React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
96
|
+
inset?: boolean;
|
|
97
|
+
};
|
|
98
|
+
|
|
93
99
|
function DropdownMenuLabel({
|
|
94
100
|
className,
|
|
95
101
|
inset,
|
|
96
102
|
...props
|
|
97
|
-
}: React.
|
|
98
|
-
inset?: boolean;
|
|
99
|
-
}): React.JSX.Element {
|
|
103
|
+
}: DropdownMenuLabelPropsExternalApi): React.JSX.Element {
|
|
100
104
|
return (
|
|
101
105
|
<DropdownMenuPrimitive.Label
|
|
102
106
|
data-slot="dropdown-menu-label"
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
import type * as React from "react";
|
|
5
5
|
import { useMemo } from "react";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
const RNG_MULTIPLIER = 16807;
|
|
8
|
+
const RNG_MODULUS = 2147483647;
|
|
9
|
+
const RNG_DIVISOR = 2147483646;
|
|
10
|
+
const RNG_SEED = 42;
|
|
11
|
+
const WEEKS = 52;
|
|
12
|
+
const DAYS_PER_WEEK = 7;
|
|
13
|
+
const CELL = 15;
|
|
14
|
+
const CELL_RECT_SIZE = 12;
|
|
15
|
+
const CELL_ANIM_WEEK_DELAY = 0.04;
|
|
16
|
+
const CELL_ANIM_DAY_DELAY = 0.015;
|
|
14
17
|
|
|
15
18
|
const LEVELS = [
|
|
16
19
|
"rgba(255,255,255,0.02)",
|
|
@@ -20,22 +23,81 @@ const LEVELS = [
|
|
|
20
23
|
"rgba(255,255,255,0.40)",
|
|
21
24
|
];
|
|
22
25
|
|
|
23
|
-
const
|
|
26
|
+
const LEVEL_THRESHOLDS = [0.3, 0.5, 0.7, 0.85];
|
|
27
|
+
|
|
28
|
+
const COMMIT_GROUPS = [
|
|
29
|
+
{
|
|
30
|
+
prefix: "m",
|
|
31
|
+
cx: 200,
|
|
32
|
+
radius: 4,
|
|
33
|
+
fill: "rgba(255,255,255,0.25)",
|
|
34
|
+
ys: [80, 150, 230, 300, 400, 480, 550, 620, 690, 770, 850],
|
|
35
|
+
animBase: 0.5,
|
|
36
|
+
animStep: 0.15,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
prefix: "f1",
|
|
40
|
+
cx: 280,
|
|
41
|
+
radius: 3.5,
|
|
42
|
+
fill: "rgba(255,255,255,0.20)",
|
|
43
|
+
ys: [230, 280, 330],
|
|
44
|
+
animBase: 1.0,
|
|
45
|
+
animStep: 0.2,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
prefix: "f2",
|
|
49
|
+
cx: 130,
|
|
50
|
+
radius: 3.5,
|
|
51
|
+
fill: "rgba(255,255,255,0.18)",
|
|
52
|
+
ys: [360, 420, 470],
|
|
53
|
+
animBase: 1.5,
|
|
54
|
+
animStep: 0.2,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
prefix: "hf",
|
|
58
|
+
cx: 260,
|
|
59
|
+
radius: 3,
|
|
60
|
+
fill: "rgba(255,255,255,0.15)",
|
|
61
|
+
ys: [600, 630, 660],
|
|
62
|
+
animBase: 2.0,
|
|
63
|
+
animStep: 0.2,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
prefix: "f3",
|
|
67
|
+
cx: 110,
|
|
68
|
+
radius: 3,
|
|
69
|
+
fill: "rgba(255,255,255,0.12)",
|
|
70
|
+
ys: [680, 730, 780],
|
|
71
|
+
animBase: 2.4,
|
|
72
|
+
animStep: 0.2,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const nextSeed = (seed: number) => (seed * RNG_MULTIPLIER) % RNG_MODULUS;
|
|
77
|
+
const seedToUnit = (seed: number) => (seed - 1) / RNG_DIVISOR;
|
|
78
|
+
const levelForValue = (sample: number) =>
|
|
79
|
+
LEVEL_THRESHOLDS.filter((threshold) => sample >= threshold).length;
|
|
80
|
+
|
|
81
|
+
type Cell = { week: number; day: number; level: number };
|
|
24
82
|
|
|
25
83
|
export function GitHubBackground(): React.JSX.Element {
|
|
26
|
-
const grid = useMemo(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
84
|
+
const grid = useMemo<Cell[]>(
|
|
85
|
+
() =>
|
|
86
|
+
Array.from({ length: WEEKS * DAYS_PER_WEEK }).reduce<{
|
|
87
|
+
seed: number;
|
|
88
|
+
cells: Cell[];
|
|
89
|
+
}>(
|
|
90
|
+
(acc, _slot, flatIndex) => {
|
|
91
|
+
const seed = nextSeed(acc.seed);
|
|
92
|
+
const level = levelForValue(seedToUnit(seed));
|
|
93
|
+
const week = Math.floor(flatIndex / DAYS_PER_WEEK);
|
|
94
|
+
const day = flatIndex % DAYS_PER_WEEK;
|
|
95
|
+
return { seed, cells: [...acc.cells, { week, day, level }] };
|
|
96
|
+
},
|
|
97
|
+
{ seed: RNG_SEED, cells: [] },
|
|
98
|
+
).cells,
|
|
99
|
+
[],
|
|
100
|
+
);
|
|
39
101
|
|
|
40
102
|
return (
|
|
41
103
|
<div
|
|
@@ -63,19 +125,19 @@ export function GitHubBackground(): React.JSX.Element {
|
|
|
63
125
|
}}
|
|
64
126
|
>
|
|
65
127
|
<svg
|
|
66
|
-
width={
|
|
67
|
-
height={
|
|
68
|
-
viewBox={`0 0 ${
|
|
128
|
+
width={WEEKS * CELL}
|
|
129
|
+
height={DAYS_PER_WEEK * CELL}
|
|
130
|
+
viewBox={`0 0 ${WEEKS * CELL} ${DAYS_PER_WEEK * CELL}`}
|
|
69
131
|
>
|
|
70
|
-
{grid.map((
|
|
132
|
+
{grid.map((cell, index) => (
|
|
71
133
|
<rect
|
|
72
|
-
key={
|
|
73
|
-
x={
|
|
74
|
-
y={
|
|
75
|
-
width={
|
|
76
|
-
height={
|
|
134
|
+
key={index}
|
|
135
|
+
x={cell.week * CELL}
|
|
136
|
+
y={cell.day * CELL}
|
|
137
|
+
width={CELL_RECT_SIZE}
|
|
138
|
+
height={CELL_RECT_SIZE}
|
|
77
139
|
rx={2}
|
|
78
|
-
fill={LEVELS[level]}
|
|
140
|
+
fill={LEVELS[cell.level]}
|
|
79
141
|
opacity={0}
|
|
80
142
|
>
|
|
81
143
|
<animate
|
|
@@ -83,7 +145,7 @@ export function GitHubBackground(): React.JSX.Element {
|
|
|
83
145
|
from="0"
|
|
84
146
|
to="1"
|
|
85
147
|
dur="0.5s"
|
|
86
|
-
begin={`${
|
|
148
|
+
begin={`${cell.week * CELL_ANIM_WEEK_DELAY + cell.day * CELL_ANIM_DAY_DELAY}s`}
|
|
87
149
|
fill="freeze"
|
|
88
150
|
/>
|
|
89
151
|
</rect>
|
|
@@ -155,15 +217,15 @@ export function GitHubBackground(): React.JSX.Element {
|
|
|
155
217
|
style={{ animationDelay: "2.2s" }}
|
|
156
218
|
/>
|
|
157
219
|
|
|
158
|
-
{/* Commit nodes
|
|
159
|
-
{
|
|
160
|
-
(
|
|
220
|
+
{/* Commit nodes */}
|
|
221
|
+
{COMMIT_GROUPS.map((group) =>
|
|
222
|
+
group.ys.map((nodeY, index) => (
|
|
161
223
|
<circle
|
|
162
|
-
key={
|
|
163
|
-
cx={
|
|
164
|
-
cy={
|
|
165
|
-
r={
|
|
166
|
-
fill=
|
|
224
|
+
key={`${group.prefix}${index}`}
|
|
225
|
+
cx={group.cx}
|
|
226
|
+
cy={nodeY}
|
|
227
|
+
r={group.radius}
|
|
228
|
+
fill={group.fill}
|
|
167
229
|
opacity={0}
|
|
168
230
|
>
|
|
169
231
|
<animate
|
|
@@ -171,96 +233,12 @@ export function GitHubBackground(): React.JSX.Element {
|
|
|
171
233
|
from="0"
|
|
172
234
|
to="1"
|
|
173
235
|
dur="0.3s"
|
|
174
|
-
begin={`${
|
|
236
|
+
begin={`${group.animBase + index * group.animStep}s`}
|
|
175
237
|
fill="freeze"
|
|
176
238
|
/>
|
|
177
239
|
</circle>
|
|
178
|
-
),
|
|
240
|
+
)),
|
|
179
241
|
)}
|
|
180
|
-
|
|
181
|
-
{/* Commit nodes — feature 1 */}
|
|
182
|
-
{[230, 280, 330].map((y, i) => (
|
|
183
|
-
<circle
|
|
184
|
-
key={`f1${i}`}
|
|
185
|
-
cx={280}
|
|
186
|
-
cy={y}
|
|
187
|
-
r={3.5}
|
|
188
|
-
fill="rgba(255,255,255,0.20)"
|
|
189
|
-
opacity={0}
|
|
190
|
-
>
|
|
191
|
-
<animate
|
|
192
|
-
attributeName="opacity"
|
|
193
|
-
from="0"
|
|
194
|
-
to="1"
|
|
195
|
-
dur="0.3s"
|
|
196
|
-
begin={`${1.0 + i * 0.2}s`}
|
|
197
|
-
fill="freeze"
|
|
198
|
-
/>
|
|
199
|
-
</circle>
|
|
200
|
-
))}
|
|
201
|
-
|
|
202
|
-
{/* Commit nodes — feature 2 */}
|
|
203
|
-
{[360, 420, 470].map((y, i) => (
|
|
204
|
-
<circle
|
|
205
|
-
key={`f2${i}`}
|
|
206
|
-
cx={130}
|
|
207
|
-
cy={y}
|
|
208
|
-
r={3.5}
|
|
209
|
-
fill="rgba(255,255,255,0.18)"
|
|
210
|
-
opacity={0}
|
|
211
|
-
>
|
|
212
|
-
<animate
|
|
213
|
-
attributeName="opacity"
|
|
214
|
-
from="0"
|
|
215
|
-
to="1"
|
|
216
|
-
dur="0.3s"
|
|
217
|
-
begin={`${1.5 + i * 0.2}s`}
|
|
218
|
-
fill="freeze"
|
|
219
|
-
/>
|
|
220
|
-
</circle>
|
|
221
|
-
))}
|
|
222
|
-
|
|
223
|
-
{/* Commit nodes — hotfix */}
|
|
224
|
-
{[600, 630, 660].map((y, i) => (
|
|
225
|
-
<circle
|
|
226
|
-
key={`hf${i}`}
|
|
227
|
-
cx={260}
|
|
228
|
-
cy={y}
|
|
229
|
-
r={3}
|
|
230
|
-
fill="rgba(255,255,255,0.15)"
|
|
231
|
-
opacity={0}
|
|
232
|
-
>
|
|
233
|
-
<animate
|
|
234
|
-
attributeName="opacity"
|
|
235
|
-
from="0"
|
|
236
|
-
to="1"
|
|
237
|
-
dur="0.3s"
|
|
238
|
-
begin={`${2.0 + i * 0.2}s`}
|
|
239
|
-
fill="freeze"
|
|
240
|
-
/>
|
|
241
|
-
</circle>
|
|
242
|
-
))}
|
|
243
|
-
|
|
244
|
-
{/* Commit nodes — feature 3 */}
|
|
245
|
-
{[680, 730, 780].map((y, i) => (
|
|
246
|
-
<circle
|
|
247
|
-
key={`f3${i}`}
|
|
248
|
-
cx={110}
|
|
249
|
-
cy={y}
|
|
250
|
-
r={3}
|
|
251
|
-
fill="rgba(255,255,255,0.12)"
|
|
252
|
-
opacity={0}
|
|
253
|
-
>
|
|
254
|
-
<animate
|
|
255
|
-
attributeName="opacity"
|
|
256
|
-
from="0"
|
|
257
|
-
to="1"
|
|
258
|
-
dur="0.3s"
|
|
259
|
-
begin={`${2.4 + i * 0.2}s`}
|
|
260
|
-
fill="freeze"
|
|
261
|
-
/>
|
|
262
|
-
</circle>
|
|
263
|
-
))}
|
|
264
242
|
</svg>
|
|
265
243
|
</div>
|
|
266
244
|
|
|
@@ -143,6 +143,15 @@ void main() {
|
|
|
143
143
|
gl_FragColor = vec4(vec3(col), 1.0);
|
|
144
144
|
}`;
|
|
145
145
|
|
|
146
|
+
const RENDER_SCALE = 0.5;
|
|
147
|
+
const MS_PER_SECOND = 1000;
|
|
148
|
+
const MOUSE_LERP = 0.08;
|
|
149
|
+
const MOUSE_LERP_IDLE = 0.02;
|
|
150
|
+
const CLICK_DECAY = 0.96;
|
|
151
|
+
const FULLSCREEN_VERTEX_COUNT = 6;
|
|
152
|
+
|
|
153
|
+
type ShaderSpec = { readonly type: number; readonly source: string };
|
|
154
|
+
|
|
146
155
|
export function HalftoneBackground(): React.JSX.Element {
|
|
147
156
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
148
157
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
@@ -154,66 +163,76 @@ export function HalftoneBackground(): React.JSX.Element {
|
|
|
154
163
|
useEffect(() => {
|
|
155
164
|
const canvas = canvasRef.current;
|
|
156
165
|
const wrapper = wrapperRef.current;
|
|
157
|
-
if (!canvas || !wrapper)
|
|
166
|
+
if (!canvas || !wrapper) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
158
169
|
|
|
159
|
-
const
|
|
170
|
+
const glContext = canvas.getContext("webgl", {
|
|
160
171
|
alpha: false,
|
|
161
172
|
antialias: false,
|
|
162
173
|
preserveDrawingBuffer: false,
|
|
163
174
|
});
|
|
164
|
-
if (!
|
|
175
|
+
if (!glContext) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
165
178
|
|
|
166
|
-
|
|
167
|
-
const shader =
|
|
168
|
-
|
|
169
|
-
|
|
179
|
+
const createShader = ({ type, source }: ShaderSpec) => {
|
|
180
|
+
const shader = glContext!.createShader(type)!;
|
|
181
|
+
glContext!.shaderSource(shader, source);
|
|
182
|
+
glContext!.compileShader(shader);
|
|
170
183
|
return shader;
|
|
171
|
-
}
|
|
184
|
+
};
|
|
172
185
|
|
|
173
|
-
const
|
|
174
|
-
|
|
186
|
+
const vertexShader = createShader({
|
|
187
|
+
type: glContext.VERTEX_SHADER,
|
|
188
|
+
source: VERTEX_SHADER,
|
|
189
|
+
});
|
|
190
|
+
const fragmentShader = createShader({
|
|
191
|
+
type: glContext.FRAGMENT_SHADER,
|
|
192
|
+
source: FRAGMENT_SHADER,
|
|
193
|
+
});
|
|
175
194
|
|
|
176
|
-
const program =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
195
|
+
const program = glContext.createProgram()!;
|
|
196
|
+
glContext.attachShader(program, vertexShader);
|
|
197
|
+
glContext.attachShader(program, fragmentShader);
|
|
198
|
+
glContext.linkProgram(program);
|
|
199
|
+
glContext.useProgram(program);
|
|
181
200
|
|
|
182
|
-
const buffer =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
201
|
+
const buffer = glContext.createBuffer();
|
|
202
|
+
glContext.bindBuffer(glContext.ARRAY_BUFFER, buffer);
|
|
203
|
+
glContext.bufferData(
|
|
204
|
+
glContext.ARRAY_BUFFER,
|
|
186
205
|
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
|
|
187
|
-
|
|
206
|
+
glContext.STATIC_DRAW,
|
|
188
207
|
);
|
|
189
208
|
|
|
190
|
-
const aPosition =
|
|
191
|
-
|
|
192
|
-
|
|
209
|
+
const aPosition = glContext.getAttribLocation(program, "a_position");
|
|
210
|
+
glContext.enableVertexAttribArray(aPosition);
|
|
211
|
+
glContext.vertexAttribPointer(aPosition, 2, glContext.FLOAT, false, 0, 0);
|
|
193
212
|
|
|
194
|
-
const uTime =
|
|
195
|
-
const uResolution =
|
|
196
|
-
const uMouse =
|
|
197
|
-
const uClick =
|
|
198
|
-
const uClickPos =
|
|
213
|
+
const uTime = glContext.getUniformLocation(program, "u_time");
|
|
214
|
+
const uResolution = glContext.getUniformLocation(program, "u_resolution");
|
|
215
|
+
const uMouse = glContext.getUniformLocation(program, "u_mouse");
|
|
216
|
+
const uClick = glContext.getUniformLocation(program, "u_click");
|
|
217
|
+
const uClickPos = glContext.getUniformLocation(program, "u_clickPos");
|
|
199
218
|
|
|
200
219
|
const startTime = performance.now();
|
|
201
220
|
|
|
202
221
|
const resize = () => {
|
|
203
222
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
204
|
-
const scale =
|
|
223
|
+
const scale = RENDER_SCALE * dpr;
|
|
205
224
|
canvas.width = canvas.offsetWidth * scale;
|
|
206
225
|
canvas.height = canvas.offsetHeight * scale;
|
|
207
|
-
|
|
226
|
+
glContext.viewport(0, 0, canvas.width, canvas.height);
|
|
208
227
|
};
|
|
209
228
|
|
|
210
229
|
resize();
|
|
211
230
|
window.addEventListener("resize", resize);
|
|
212
231
|
|
|
213
|
-
const onMouseMove = (
|
|
232
|
+
const onMouseMove = (event: MouseEvent) => {
|
|
214
233
|
const rect = wrapper.getBoundingClientRect();
|
|
215
|
-
mouseRef.current.x = (
|
|
216
|
-
mouseRef.current.y = 1.0 - (
|
|
234
|
+
mouseRef.current.x = (event.clientX - rect.left) / rect.width;
|
|
235
|
+
mouseRef.current.y = 1.0 - (event.clientY - rect.top) / rect.height;
|
|
217
236
|
};
|
|
218
237
|
|
|
219
238
|
const onMouseLeave = () => {
|
|
@@ -221,10 +240,10 @@ export function HalftoneBackground(): React.JSX.Element {
|
|
|
221
240
|
mouseRef.current.y = -1;
|
|
222
241
|
};
|
|
223
242
|
|
|
224
|
-
const onClick = (
|
|
243
|
+
const onClick = (event: MouseEvent) => {
|
|
225
244
|
const rect = wrapper.getBoundingClientRect();
|
|
226
|
-
clickRef.current.x = (
|
|
227
|
-
clickRef.current.y = 1.0 - (
|
|
245
|
+
clickRef.current.x = (event.clientX - rect.left) / rect.width;
|
|
246
|
+
clickRef.current.y = 1.0 - (event.clientY - rect.top) / rect.height;
|
|
228
247
|
clickRef.current.strength = 1.0;
|
|
229
248
|
};
|
|
230
249
|
|
|
@@ -233,27 +252,26 @@ export function HalftoneBackground(): React.JSX.Element {
|
|
|
233
252
|
wrapper.addEventListener("click", onClick);
|
|
234
253
|
|
|
235
254
|
const draw = () => {
|
|
236
|
-
const elapsed = (performance.now() - startTime) /
|
|
255
|
+
const elapsed = (performance.now() - startTime) / MS_PER_SECOND;
|
|
237
256
|
|
|
238
|
-
const lerp = 0.08;
|
|
239
257
|
const target = mouseRef.current;
|
|
240
258
|
const smooth = smoothMouseRef.current;
|
|
241
259
|
if (target.x < 0) {
|
|
242
|
-
smooth.x += (target.x - smooth.x) *
|
|
243
|
-
smooth.y += (target.y - smooth.y) *
|
|
260
|
+
smooth.x += (target.x - smooth.x) * MOUSE_LERP_IDLE;
|
|
261
|
+
smooth.y += (target.y - smooth.y) * MOUSE_LERP_IDLE;
|
|
244
262
|
} else {
|
|
245
|
-
smooth.x += (target.x - smooth.x) *
|
|
246
|
-
smooth.y += (target.y - smooth.y) *
|
|
263
|
+
smooth.x += (target.x - smooth.x) * MOUSE_LERP;
|
|
264
|
+
smooth.y += (target.y - smooth.y) * MOUSE_LERP;
|
|
247
265
|
}
|
|
248
266
|
|
|
249
|
-
clickRef.current.strength *=
|
|
267
|
+
clickRef.current.strength *= CLICK_DECAY;
|
|
250
268
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
269
|
+
glContext.uniform1f(uTime, elapsed);
|
|
270
|
+
glContext.uniform2f(uResolution, canvas.width, canvas.height);
|
|
271
|
+
glContext.uniform2f(uMouse, smooth.x, smooth.y);
|
|
272
|
+
glContext.uniform1f(uClick, clickRef.current.strength);
|
|
273
|
+
glContext.uniform2f(uClickPos, clickRef.current.x, clickRef.current.y);
|
|
274
|
+
glContext.drawArrays(glContext.TRIANGLES, 0, FULLSCREEN_VERTEX_COUNT);
|
|
257
275
|
frameRef.current = requestAnimationFrame(draw);
|
|
258
276
|
};
|
|
259
277
|
|
|
@@ -265,10 +283,10 @@ export function HalftoneBackground(): React.JSX.Element {
|
|
|
265
283
|
window.removeEventListener("mousemove", onMouseMove);
|
|
266
284
|
wrapper.removeEventListener("mouseleave", onMouseLeave);
|
|
267
285
|
wrapper.removeEventListener("click", onClick);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
286
|
+
glContext.deleteProgram(program);
|
|
287
|
+
glContext.deleteShader(vertexShader);
|
|
288
|
+
glContext.deleteShader(fragmentShader);
|
|
289
|
+
glContext.deleteBuffer(buffer);
|
|
272
290
|
};
|
|
273
291
|
}, []);
|
|
274
292
|
|
|
@@ -39,13 +39,15 @@ function InputGroup({ className, ...props }: ComponentProps<"div">): React.JSX.E
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
type InputGroupAddonVariantsExternalApi = (
|
|
43
43
|
props?:
|
|
44
44
|
| ({
|
|
45
|
-
align?: "inline-start" | "inline-end" | "block-start" | "block-end" |
|
|
45
|
+
align?: "inline-start" | "inline-end" | "block-start" | "block-end" | undefined;
|
|
46
46
|
} & ClassProp)
|
|
47
47
|
| undefined,
|
|
48
|
-
) => string
|
|
48
|
+
) => string;
|
|
49
|
+
|
|
50
|
+
const inputGroupAddonVariants: InputGroupAddonVariantsExternalApi = cva(
|
|
49
51
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
|
50
52
|
{
|
|
51
53
|
variants: {
|
|
@@ -77,24 +79,29 @@ function InputGroupAddon({
|
|
|
77
79
|
data-slot="input-group-addon"
|
|
78
80
|
data-align={align}
|
|
79
81
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
|
80
|
-
onClick={(
|
|
81
|
-
|
|
82
|
+
onClick={(event) => {
|
|
83
|
+
const target = event.target;
|
|
84
|
+
if (target instanceof HTMLElement && target.closest("button")) {
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
|
-
|
|
87
|
+
event.currentTarget.parentElement?.querySelector("input")?.focus();
|
|
85
88
|
}}
|
|
86
89
|
{...props}
|
|
87
90
|
/>
|
|
88
91
|
);
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
type InputGroupButtonVariantsExternalApi = (
|
|
92
95
|
props?:
|
|
93
96
|
| ({
|
|
94
|
-
size?: "xs" | "sm" | "icon-xs" | "icon-sm" |
|
|
97
|
+
size?: "xs" | "sm" | "icon-xs" | "icon-sm" | undefined;
|
|
95
98
|
} & ClassProp)
|
|
96
99
|
| undefined,
|
|
97
|
-
) => string
|
|
100
|
+
) => string;
|
|
101
|
+
|
|
102
|
+
const inputGroupButtonVariants: InputGroupButtonVariantsExternalApi = cva(
|
|
103
|
+
"flex items-center gap-2 text-sm shadow-none",
|
|
104
|
+
{
|
|
98
105
|
variants: {
|
|
99
106
|
size: {
|
|
100
107
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
// Adapted from better-hub by Better Auth — https://github.com/better-auth/better-hub
|
|
2
2
|
// (apps/web/src/components/ui/live-duration.tsx). Full credit to the Better Auth team.
|
|
3
3
|
|
|
4
|
+
import { DateTime, Option } from "effect";
|
|
4
5
|
import type * as React from "react";
|
|
5
6
|
import { useState, useEffect } from "react";
|
|
6
7
|
|
|
8
|
+
const MS_PER_SECOND = 1000;
|
|
9
|
+
const SECONDS_PER_HOUR = 3600;
|
|
10
|
+
const SECONDS_PER_MINUTE = 60;
|
|
11
|
+
const TICK_INTERVAL_MS = 1000;
|
|
12
|
+
|
|
13
|
+
interface FormatDurationInputExternalApi {
|
|
14
|
+
startedAt: string | null;
|
|
15
|
+
completedAt: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
// Inlined from "@/lib/utils" (formatDuration) — only this helper is used here.
|
|
8
|
-
function formatDuration(startedAt
|
|
9
|
-
if (!startedAt)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
function formatDuration({ startedAt, completedAt }: FormatDurationInputExternalApi): string {
|
|
20
|
+
if (!startedAt) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
const start = DateTime.unsafeMake(startedAt);
|
|
24
|
+
const end = completedAt ? DateTime.unsafeMake(completedAt) : DateTime.unsafeNow();
|
|
25
|
+
const elapsedMs = DateTime.toEpochMillis(end) - DateTime.toEpochMillis(start);
|
|
26
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / MS_PER_SECOND));
|
|
27
|
+
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
|
28
|
+
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
|
|
29
|
+
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
|
30
|
+
if (hours > 0) {
|
|
31
|
+
return `${hours}h ${minutes}m`;
|
|
32
|
+
}
|
|
33
|
+
if (minutes > 0) {
|
|
34
|
+
return `${minutes}m ${seconds}s`;
|
|
35
|
+
}
|
|
18
36
|
return `${seconds}s`;
|
|
19
37
|
}
|
|
20
38
|
|
|
@@ -22,45 +40,50 @@ function formatDuration(startedAt: string | null, completedAt: string | null): s
|
|
|
22
40
|
// Shared tick for LiveDuration components. One interval drives all subscribers
|
|
23
41
|
// instead of each component running its own 1s interval.
|
|
24
42
|
const callbacks = new Set<() => void>();
|
|
25
|
-
const tickState: { intervalId: ReturnType<typeof setInterval
|
|
26
|
-
intervalId:
|
|
43
|
+
const tickState: { intervalId: Option.Option<ReturnType<typeof setInterval>> } = {
|
|
44
|
+
intervalId: Option.none(),
|
|
27
45
|
};
|
|
28
46
|
|
|
29
47
|
function subscribeLiveTick(callback: () => void): () => void {
|
|
30
48
|
callbacks.add(callback);
|
|
31
|
-
if (tickState.intervalId
|
|
32
|
-
|
|
49
|
+
if (Option.isNone(tickState.intervalId)) {
|
|
50
|
+
// oxlint-disable-next-line reform-rules/no-set-timeout-interval -- browser-timer seam: shared render ticker for React, no Effect runtime here
|
|
51
|
+
tickState.intervalId = Option.some(setInterval(() => {
|
|
33
52
|
callbacks.forEach((cb) => cb());
|
|
34
|
-
},
|
|
53
|
+
}, TICK_INTERVAL_MS));
|
|
35
54
|
}
|
|
36
55
|
return () => {
|
|
37
56
|
callbacks.delete(callback);
|
|
38
|
-
if (callbacks.size === 0 && tickState.intervalId
|
|
39
|
-
clearInterval(tickState.intervalId);
|
|
40
|
-
tickState.intervalId =
|
|
57
|
+
if (callbacks.size === 0 && Option.isSome(tickState.intervalId)) {
|
|
58
|
+
clearInterval(tickState.intervalId.value);
|
|
59
|
+
tickState.intervalId = Option.none();
|
|
41
60
|
}
|
|
42
61
|
};
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
interface
|
|
64
|
+
interface LiveDurationPropsExternalApi {
|
|
46
65
|
startedAt: string | null;
|
|
47
66
|
completedAt?: string | null;
|
|
48
67
|
className?: string;
|
|
49
68
|
}
|
|
50
69
|
|
|
51
|
-
export function LiveDuration({ startedAt, completedAt, className }:
|
|
70
|
+
export function LiveDuration({ startedAt, completedAt, className }: LiveDurationPropsExternalApi): React.ReactNode {
|
|
52
71
|
// tick forces re-renders when subscribeLiveTick fires; value is unused in render
|
|
53
72
|
const [, setTick] = useState(0);
|
|
54
73
|
|
|
55
74
|
useEffect(() => {
|
|
56
|
-
if (!startedAt || completedAt != null)
|
|
75
|
+
if (!startedAt || completedAt != null) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
57
78
|
setTick(0);
|
|
58
|
-
return subscribeLiveTick(() => setTick((
|
|
79
|
+
return subscribeLiveTick(() => setTick((prev) => prev + 1));
|
|
59
80
|
}, [startedAt, completedAt]);
|
|
60
81
|
|
|
61
|
-
if (!startedAt)
|
|
82
|
+
if (!startedAt) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
62
85
|
|
|
63
|
-
const formatted = formatDuration(startedAt, completedAt ?? null);
|
|
86
|
+
const formatted = formatDuration({ startedAt, completedAt: completedAt ?? null });
|
|
64
87
|
|
|
65
88
|
return (
|
|
66
89
|
<span className={className} suppressHydrationWarning>
|
package/src/components/logo.tsx
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import type * as React from "react";
|
|
4
4
|
import { cn } from '../foundation/cn';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
interface LogoPropsExternalApi {
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Logo({ className }: LogoPropsExternalApi): React.JSX.Element {
|
|
7
11
|
return (
|
|
8
12
|
<span className={cn("font-mono text-sm font-medium tracking-tight", className)}>
|
|
9
13
|
BETTER-HUB.
|
|
@@ -11,7 +15,7 @@ export function Logo({ className }: { className?: string }): React.JSX.Element {
|
|
|
11
15
|
);
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
export function LogoMark({ className }:
|
|
18
|
+
export function LogoMark({ className }: LogoPropsExternalApi): React.JSX.Element {
|
|
15
19
|
return (
|
|
16
20
|
<span className={cn("font-mono text-sm font-bold tracking-tight", className)}>
|
|
17
21
|
b.
|
|
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
6
6
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
|
7
7
|
import { cn } from "../foundation/cn";
|
|
8
8
|
|
|
9
|
-
interface
|
|
9
|
+
interface ResizeHandlePropsExternalApi {
|
|
10
10
|
/** Called continuously during drag with the pointer position on the resize axis
|
|
11
11
|
* (clientX for `axis: "x"`, clientY for `axis: "y"`). */
|
|
12
12
|
onResize: (clientPos: number) => void;
|
|
@@ -29,13 +29,13 @@ export function ResizeHandle({
|
|
|
29
29
|
onDoubleClick,
|
|
30
30
|
axis = "x",
|
|
31
31
|
className,
|
|
32
|
-
}:
|
|
32
|
+
}: ResizeHandlePropsExternalApi): React.JSX.Element {
|
|
33
33
|
const [isDragging, setIsDragging] = useState(false);
|
|
34
34
|
const vertical = axis === "y";
|
|
35
35
|
|
|
36
36
|
const handleMouseDown = useCallback(
|
|
37
|
-
(
|
|
38
|
-
|
|
37
|
+
(event: ReactMouseEvent) => {
|
|
38
|
+
event.preventDefault();
|
|
39
39
|
setIsDragging(true);
|
|
40
40
|
onDragStart?.();
|
|
41
41
|
},
|
|
@@ -43,10 +43,12 @@ export function ResizeHandle({
|
|
|
43
43
|
);
|
|
44
44
|
|
|
45
45
|
useEffect(() => {
|
|
46
|
-
if (!isDragging)
|
|
46
|
+
if (!isDragging) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
47
49
|
|
|
48
|
-
const handleMouseMove = (
|
|
49
|
-
onResize(vertical ?
|
|
50
|
+
const handleMouseMove = (event: MouseEvent) => {
|
|
51
|
+
onResize(vertical ? event.clientY : event.clientX);
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
const handleMouseUp = () => {
|
package/src/components/sheet.tsx
CHANGED
|
@@ -38,7 +38,7 @@ function SheetOverlay({
|
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
interface
|
|
41
|
+
interface SheetContentPropsExternalApi extends React.ComponentProps<typeof DialogPrimitive.Content> {
|
|
42
42
|
side?: "left" | "right" | "top" | "bottom";
|
|
43
43
|
showCloseButton?: boolean;
|
|
44
44
|
}
|
|
@@ -49,7 +49,7 @@ function SheetContent({
|
|
|
49
49
|
side = "right",
|
|
50
50
|
showCloseButton = true,
|
|
51
51
|
...props
|
|
52
|
-
}:
|
|
52
|
+
}: SheetContentPropsExternalApi): React.JSX.Element {
|
|
53
53
|
const sideStyles = {
|
|
54
54
|
right: "inset-y-0 right-0 h-full w-3/4 sm:max-w-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
|
55
55
|
left: "inset-y-0 left-0 h-full w-3/4 sm:max-w-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
|
@@ -1,53 +1,77 @@
|
|
|
1
1
|
// Adapted from better-hub by Better Auth — https://github.com/better-auth/better-hub
|
|
2
2
|
// (apps/web/src/components/ui/time-ago.tsx). Full credit to the Better Auth team.
|
|
3
3
|
|
|
4
|
+
import { DateTime } from "effect";
|
|
4
5
|
import type * as React from "react";
|
|
5
6
|
import { useState, useEffect } from 'react';
|
|
6
7
|
|
|
8
|
+
const MS_PER_SECOND = 1000;
|
|
9
|
+
const SECONDS_PER_MINUTE = 60;
|
|
10
|
+
const SECONDS_PER_HOUR = 3600;
|
|
11
|
+
const SECONDS_PER_DAY = 86400;
|
|
12
|
+
const SECONDS_PER_WEEK = 604800;
|
|
13
|
+
const SECONDS_PER_MONTH = 2592000;
|
|
14
|
+
const MONTHS_PER_YEAR = 12;
|
|
15
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
16
|
+
|
|
7
17
|
// Inlined from better-hub's "@/lib/utils" (timeAgo) — only the piece used here.
|
|
8
18
|
function timeAgo(date: string | Date): string {
|
|
9
|
-
const now =
|
|
10
|
-
const then =
|
|
11
|
-
const seconds = Math.floor((
|
|
19
|
+
const now = DateTime.unsafeNow();
|
|
20
|
+
const then = DateTime.unsafeMake(date);
|
|
21
|
+
const seconds = Math.floor((DateTime.toEpochMillis(now) - DateTime.toEpochMillis(then)) / MS_PER_SECOND);
|
|
12
22
|
|
|
13
|
-
if (seconds <
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (seconds <
|
|
17
|
-
|
|
23
|
+
if (seconds < SECONDS_PER_MINUTE) {
|
|
24
|
+
return 'just now';
|
|
25
|
+
}
|
|
26
|
+
if (seconds < SECONDS_PER_HOUR) {
|
|
27
|
+
return `${Math.floor(seconds / SECONDS_PER_MINUTE)}m ago`;
|
|
28
|
+
}
|
|
29
|
+
if (seconds < SECONDS_PER_DAY) {
|
|
30
|
+
return `${Math.floor(seconds / SECONDS_PER_HOUR)}h ago`;
|
|
31
|
+
}
|
|
32
|
+
if (seconds < SECONDS_PER_WEEK) {
|
|
33
|
+
return `${Math.floor(seconds / SECONDS_PER_DAY)}d ago`;
|
|
34
|
+
}
|
|
35
|
+
if (seconds < SECONDS_PER_MONTH) {
|
|
36
|
+
return `${Math.floor(seconds / SECONDS_PER_WEEK)}w ago`;
|
|
37
|
+
}
|
|
18
38
|
|
|
19
|
-
const months = Math.floor(seconds /
|
|
20
|
-
if (months <
|
|
39
|
+
const months = Math.floor(seconds / SECONDS_PER_MONTH);
|
|
40
|
+
if (months < MONTHS_PER_YEAR) {
|
|
41
|
+
return `${months}mo ago`;
|
|
42
|
+
}
|
|
21
43
|
|
|
22
|
-
const
|
|
44
|
+
const thenDate = DateTime.toDate(then);
|
|
45
|
+
const sameYear = thenDate.getFullYear() === DateTime.toDate(now).getFullYear();
|
|
23
46
|
if (sameYear) {
|
|
24
|
-
return
|
|
47
|
+
return thenDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
25
48
|
}
|
|
26
|
-
return
|
|
49
|
+
return thenDate.toLocaleDateString('en-US', {
|
|
27
50
|
month: 'short',
|
|
28
51
|
day: 'numeric',
|
|
29
52
|
year: 'numeric',
|
|
30
53
|
});
|
|
31
54
|
}
|
|
32
55
|
|
|
33
|
-
interface
|
|
56
|
+
interface TimeAgoPropsExternalApi {
|
|
34
57
|
date: string | Date;
|
|
35
58
|
className?: string;
|
|
36
59
|
}
|
|
37
60
|
|
|
38
|
-
export function TimeAgo({ date, className }:
|
|
61
|
+
export function TimeAgo({ date, className }: TimeAgoPropsExternalApi): React.JSX.Element {
|
|
39
62
|
const [text, setText] = useState(() => timeAgo(date));
|
|
40
63
|
|
|
41
64
|
useEffect(() => {
|
|
42
65
|
setText(timeAgo(date));
|
|
43
|
-
|
|
66
|
+
// oxlint-disable-next-line reform-rules/no-set-timeout-interval -- browser-timer seam: periodic relative-time refresh for React, no Effect runtime here
|
|
67
|
+
const interval = setInterval(() => setText(timeAgo(date)), REFRESH_INTERVAL_MS);
|
|
44
68
|
return () => clearInterval(interval);
|
|
45
69
|
}, [date]);
|
|
46
70
|
|
|
47
71
|
return (
|
|
48
72
|
<time
|
|
49
73
|
dateTime={typeof date === 'string' ? date : date.toISOString()}
|
|
50
|
-
title={
|
|
74
|
+
title={DateTime.toDate(DateTime.unsafeMake(date)).toLocaleString()}
|
|
51
75
|
className={className}
|
|
52
76
|
suppressHydrationWarning
|
|
53
77
|
>
|
package/src/foundation/cn.ts
CHANGED
|
@@ -5,4 +5,5 @@ import { twMerge } from 'tailwind-merge'
|
|
|
5
5
|
* Merge class names with Tailwind-aware de-duplication.
|
|
6
6
|
* Adapted from better-hub (apps/web/src/lib/utils.ts). See ../../README.md.
|
|
7
7
|
*/
|
|
8
|
+
// oxlint-disable-next-line reform-rules/min-var-name -- `cn` is the canonical shadcn/Tailwind class-merge helper, a public export consumed across the monorepo
|
|
8
9
|
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs))
|
|
File without changes
|