@withmata/blueprints 0.3.5 → 0.5.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/.claude/skills/audit/SKILL.md +4 -4
- package/.claude/skills/blueprint-catalog/SKILL.md +17 -7
- package/.claude/skills/copywrite/SKILL.md +187 -0
- package/.claude/skills/copywrite-landing/SKILL.md +489 -0
- package/.claude/skills/design-system/SKILL.md +970 -0
- package/.claude/skills/new-project/SKILL.md +168 -112
- package/.claude/skills/scaffold-auth/SKILL.md +9 -9
- package/.claude/skills/scaffold-db/SKILL.md +14 -14
- package/.claude/skills/scaffold-env/SKILL.md +4 -4
- package/.claude/skills/scaffold-foundation/SKILL.md +15 -15
- package/.claude/skills/scaffold-tailwind/SKILL.md +17 -3
- package/.claude/skills/scaffold-ui/SKILL.md +155 -36
- package/ENGINEERING.md +2 -2
- package/blueprints/discovery/design-system/BLUEPRINT.md +1479 -0
- package/blueprints/discovery/marketing-copywriting/BLUEPRINT.md +664 -0
- package/blueprints/features/auth-better-auth/BLUEPRINT.md +20 -22
- package/blueprints/features/db-drizzle-postgres/BLUEPRINT.md +12 -12
- package/blueprints/features/db-drizzle-postgres/files/db/src/example-entity.ts +1 -1
- package/blueprints/features/db-drizzle-postgres/files/db/src/scripts/seed.ts +1 -1
- package/blueprints/features/env-t3/BLUEPRINT.md +1 -1
- package/blueprints/features/tailwind-v4/BLUEPRINT.md +9 -2
- package/blueprints/features/tailwind-v4/files/tailwind-config/shared-styles.css +80 -1
- package/blueprints/features/ui-shared-components/BLUEPRINT.md +411 -78
- package/blueprints/features/ui-shared-components/files/ui/components/ui/alert-dialog.tsx +192 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/avatar.tsx +71 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/badge.tsx +52 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/breadcrumb.tsx +122 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/button.tsx +56 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/card-select.tsx +72 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/card.tsx +100 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/collapsible.tsx +34 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/combobox.tsx +301 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/dropdown-menu.tsx +264 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/empty-state.tsx +43 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/entity-select.tsx +110 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/field.tsx +237 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/form-field.tsx +217 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/input-group.tsx +161 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/input.tsx +20 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/label.tsx +20 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/org-switcher.tsx +114 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/page-header.tsx +45 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/pagination.tsx +52 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/pill-select.tsx +151 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/popover.tsx +41 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/search-input.tsx +49 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/select.tsx +205 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/selected-entity-card.tsx +47 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/separator.tsx +25 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/sidebar.tsx +389 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/status-filter.tsx +43 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/tag-input.tsx +131 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/textarea.tsx +18 -0
- package/blueprints/features/ui-shared-components/files/ui/components/ui/user-menu.tsx +149 -0
- package/blueprints/features/ui-shared-components/files/ui/components.json +11 -8
- package/blueprints/features/ui-shared-components/files/ui/package.json +20 -11
- package/blueprints/foundation/monorepo-turbo/BLUEPRINT.md +19 -20
- package/blueprints/foundation/monorepo-turbo/files/root/package.json +1 -1
- package/dist/index.js +27 -10
- package/package.json +1 -1
- package/blueprints/features/tailwind-v4/files/tailwind-config/package.json +0 -20
- package/blueprints/foundation/monorepo-turbo/files/root/pnpm-workspace.yaml +0 -5
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import type * as React from "react";
|
|
5
|
+
import { Button } from "#components/ui/button";
|
|
6
|
+
import { Input } from "#components/ui/input";
|
|
7
|
+
import { Textarea } from "#components/ui/textarea";
|
|
8
|
+
import { cn } from "#utils/cn";
|
|
9
|
+
|
|
10
|
+
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
data-slot="input-group"
|
|
14
|
+
role="group"
|
|
15
|
+
className={cn(
|
|
16
|
+
"border-input bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-9 rounded-4xl border transition-colors has-data-[align=block-end]:rounded-2xl has-data-[align=block-start]:rounded-2xl has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[textarea]:rounded-xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const inputGroupAddonVariants = cva(
|
|
25
|
+
"text-muted-foreground **:data-[slot=kbd]:bg-muted-foreground/10 h-auto gap-2 py-2 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-4xl **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
|
|
26
|
+
{
|
|
27
|
+
variants: {
|
|
28
|
+
align: {
|
|
29
|
+
"inline-start":
|
|
30
|
+
"pl-3 has-[>button]:ml-[-0.25rem] has-[>kbd]:ml-[-0.15rem] order-first",
|
|
31
|
+
"inline-end":
|
|
32
|
+
"pr-3 has-[>button]:mr-[-0.25rem] has-[>kbd]:mr-[-0.15rem] order-last",
|
|
33
|
+
"block-start":
|
|
34
|
+
"px-3 pt-3 group-has-[>input]/input-group:pt-3 [.border-b]:pb-3 order-first w-full justify-start",
|
|
35
|
+
"block-end":
|
|
36
|
+
"px-3 pb-3 group-has-[>input]/input-group:pb-3 [.border-t]:pt-3 order-last w-full justify-start",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
defaultVariants: {
|
|
40
|
+
align: "inline-start",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
function InputGroupAddon({
|
|
46
|
+
className,
|
|
47
|
+
align = "inline-start",
|
|
48
|
+
...props
|
|
49
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
role="group"
|
|
53
|
+
data-slot="input-group-addon"
|
|
54
|
+
data-align={align}
|
|
55
|
+
className={cn(inputGroupAddonVariants({ align }), className)}
|
|
56
|
+
onKeyDown={(e) => {
|
|
57
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
58
|
+
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
|
59
|
+
}
|
|
60
|
+
}}
|
|
61
|
+
onClick={(e) => {
|
|
62
|
+
if ((e.target as HTMLElement).closest("button")) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
|
66
|
+
}}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const inputGroupButtonVariants = cva(
|
|
73
|
+
"gap-2 rounded-4xl text-sm shadow-none flex items-center",
|
|
74
|
+
{
|
|
75
|
+
variants: {
|
|
76
|
+
size: {
|
|
77
|
+
xs: "h-6 gap-1 px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
|
78
|
+
sm: "",
|
|
79
|
+
"icon-xs": "size-6 p-0 has-[>svg]:p-0",
|
|
80
|
+
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
defaultVariants: {
|
|
84
|
+
size: "xs",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
function InputGroupButton({
|
|
90
|
+
className,
|
|
91
|
+
type = "button",
|
|
92
|
+
variant = "ghost",
|
|
93
|
+
size = "xs",
|
|
94
|
+
...props
|
|
95
|
+
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
|
96
|
+
VariantProps<typeof inputGroupButtonVariants> & {
|
|
97
|
+
type?: "button" | "submit" | "reset";
|
|
98
|
+
}) {
|
|
99
|
+
return (
|
|
100
|
+
<Button
|
|
101
|
+
type={type}
|
|
102
|
+
data-size={size}
|
|
103
|
+
variant={variant}
|
|
104
|
+
className={cn(inputGroupButtonVariants({ size }), className)}
|
|
105
|
+
{...props}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
|
111
|
+
return (
|
|
112
|
+
<span
|
|
113
|
+
className={cn(
|
|
114
|
+
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
|
115
|
+
className,
|
|
116
|
+
)}
|
|
117
|
+
{...props}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function InputGroupInput({
|
|
123
|
+
className,
|
|
124
|
+
...props
|
|
125
|
+
}: React.ComponentProps<"input">) {
|
|
126
|
+
return (
|
|
127
|
+
<Input
|
|
128
|
+
data-slot="input-group-control"
|
|
129
|
+
className={cn(
|
|
130
|
+
"rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1",
|
|
131
|
+
className,
|
|
132
|
+
)}
|
|
133
|
+
{...props}
|
|
134
|
+
/>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function InputGroupTextarea({
|
|
139
|
+
className,
|
|
140
|
+
...props
|
|
141
|
+
}: React.ComponentProps<"textarea">) {
|
|
142
|
+
return (
|
|
143
|
+
<Textarea
|
|
144
|
+
data-slot="input-group-control"
|
|
145
|
+
className={cn(
|
|
146
|
+
"rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1 resize-none",
|
|
147
|
+
className,
|
|
148
|
+
)}
|
|
149
|
+
{...props}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export {
|
|
155
|
+
InputGroup,
|
|
156
|
+
InputGroupAddon,
|
|
157
|
+
InputGroupButton,
|
|
158
|
+
InputGroupText,
|
|
159
|
+
InputGroupInput,
|
|
160
|
+
InputGroupTextarea,
|
|
161
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Input as InputPrimitive } from "@base-ui/react/input";
|
|
2
|
+
import type * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "#utils/cn";
|
|
5
|
+
|
|
6
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
7
|
+
return (
|
|
8
|
+
<InputPrimitive
|
|
9
|
+
type={type}
|
|
10
|
+
data-slot="input"
|
|
11
|
+
className={cn(
|
|
12
|
+
"bg-input/30 border-input focus-visible:border-primary/90 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-4xl border px-3 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
13
|
+
className,
|
|
14
|
+
)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { Input };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "#utils/cn";
|
|
6
|
+
|
|
7
|
+
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
|
8
|
+
return (
|
|
9
|
+
<label
|
|
10
|
+
data-slot="label"
|
|
11
|
+
className={cn(
|
|
12
|
+
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
|
13
|
+
className,
|
|
14
|
+
)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { Label };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { buttonVariants } from "#components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuGroup,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuLabel,
|
|
10
|
+
DropdownMenuSeparator,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from "#components/ui/dropdown-menu";
|
|
13
|
+
import { cn } from "#utils/cn";
|
|
14
|
+
import { BuildingsIcon } from "@phosphor-icons/react/Buildings";
|
|
15
|
+
import { CaretUpDownIcon } from "@phosphor-icons/react/CaretUpDown";
|
|
16
|
+
import { CheckIcon } from "@phosphor-icons/react/Check";
|
|
17
|
+
import { PlusIcon } from "@phosphor-icons/react/Plus";
|
|
18
|
+
|
|
19
|
+
export interface OrgSwitcherOrganization {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OrgSwitcherProps {
|
|
25
|
+
currentOrg: OrgSwitcherOrganization | null;
|
|
26
|
+
organizations: OrgSwitcherOrganization[];
|
|
27
|
+
onSwitch: (orgId: string) => void;
|
|
28
|
+
onCreateNew?: () => void;
|
|
29
|
+
isLoading?: boolean;
|
|
30
|
+
isSwitching?: boolean;
|
|
31
|
+
createLabel?: string;
|
|
32
|
+
label?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function OrgSwitcher({
|
|
37
|
+
currentOrg,
|
|
38
|
+
organizations,
|
|
39
|
+
onSwitch,
|
|
40
|
+
onCreateNew,
|
|
41
|
+
isLoading = false,
|
|
42
|
+
isSwitching = false,
|
|
43
|
+
createLabel = "Create Organization",
|
|
44
|
+
label = "Organizations",
|
|
45
|
+
className,
|
|
46
|
+
}: OrgSwitcherProps) {
|
|
47
|
+
return (
|
|
48
|
+
<DropdownMenu>
|
|
49
|
+
<DropdownMenuTrigger
|
|
50
|
+
nativeButton={false}
|
|
51
|
+
render={
|
|
52
|
+
<div
|
|
53
|
+
data-slot="org-switcher"
|
|
54
|
+
className={cn(
|
|
55
|
+
buttonVariants({
|
|
56
|
+
variant: "ghost",
|
|
57
|
+
className:
|
|
58
|
+
"bg-transparent rounded-none group hover:cursor-pointer hover:bg-transparent focus:bg-transparent p-0",
|
|
59
|
+
}),
|
|
60
|
+
"relative flex gap-2.5 items-center size-8 w-full",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<BuildingsIcon className="size-4 shrink-0" />
|
|
65
|
+
<div className="flex gap-2 w-full items-center overflow-hidden">
|
|
66
|
+
<span className="max-w-32 truncate text-sm">
|
|
67
|
+
{currentOrg?.name ?? "Organization"}
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
<CaretUpDownIcon className="size-3.5 group-hover:text-foreground transition-all opacity-50 shrink-0" />
|
|
71
|
+
{(isSwitching || isLoading) && (
|
|
72
|
+
<div className="absolute inset-0 rounded-none overflow-hidden">
|
|
73
|
+
<div
|
|
74
|
+
className="h-full w-full animate-pulse bg-muted/60"
|
|
75
|
+
style={{ animationDuration: "1s" }}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
}
|
|
81
|
+
/>
|
|
82
|
+
<DropdownMenuContent align="start" className="w-64">
|
|
83
|
+
<DropdownMenuGroup>
|
|
84
|
+
<DropdownMenuLabel>{label}</DropdownMenuLabel>
|
|
85
|
+
{organizations.map((org) => {
|
|
86
|
+
const isActive = org.id === currentOrg?.id;
|
|
87
|
+
return (
|
|
88
|
+
<DropdownMenuItem
|
|
89
|
+
key={org.id}
|
|
90
|
+
onClick={() => onSwitch(org.id)}
|
|
91
|
+
className="justify-between"
|
|
92
|
+
>
|
|
93
|
+
<div className="flex items-center gap-2">
|
|
94
|
+
<BuildingsIcon className="size-4 text-muted-foreground" />
|
|
95
|
+
<span className="truncate">{org.name}</span>
|
|
96
|
+
</div>
|
|
97
|
+
{isActive && <CheckIcon className="size-4" />}
|
|
98
|
+
</DropdownMenuItem>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</DropdownMenuGroup>
|
|
102
|
+
{onCreateNew && (
|
|
103
|
+
<>
|
|
104
|
+
<DropdownMenuSeparator />
|
|
105
|
+
<DropdownMenuItem onClick={onCreateNew}>
|
|
106
|
+
<PlusIcon className="size-4" />
|
|
107
|
+
<span>{createLabel}</span>
|
|
108
|
+
</DropdownMenuItem>
|
|
109
|
+
</>
|
|
110
|
+
)}
|
|
111
|
+
</DropdownMenuContent>
|
|
112
|
+
</DropdownMenu>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Button } from "#components/ui/button";
|
|
2
|
+
import { cn } from "#utils/cn";
|
|
3
|
+
import { PlusIcon } from "@phosphor-icons/react/Plus";
|
|
4
|
+
import type * as React from "react";
|
|
5
|
+
|
|
6
|
+
export interface PageHeaderProps {
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
actionLabel?: string;
|
|
10
|
+
onAction?: () => void;
|
|
11
|
+
actionIcon?: React.ReactNode;
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function PageHeader({
|
|
17
|
+
title,
|
|
18
|
+
description,
|
|
19
|
+
actionLabel,
|
|
20
|
+
onAction,
|
|
21
|
+
actionIcon,
|
|
22
|
+
children,
|
|
23
|
+
className,
|
|
24
|
+
}: PageHeaderProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
data-slot="page-header"
|
|
28
|
+
className={cn("flex items-center justify-between mb-6", className)}
|
|
29
|
+
>
|
|
30
|
+
<div>
|
|
31
|
+
<h1 className="text-2xl font-bold">{title}</h1>
|
|
32
|
+
{description && (
|
|
33
|
+
<p className="text-muted-foreground mt-2">{description}</p>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
{children}
|
|
37
|
+
{actionLabel && onAction && (
|
|
38
|
+
<Button onClick={onAction}>
|
|
39
|
+
{actionIcon ?? <PlusIcon className="size-4" />}
|
|
40
|
+
{actionLabel}
|
|
41
|
+
</Button>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Button } from "#components/ui/button";
|
|
2
|
+
import { CaretLeftIcon } from "@phosphor-icons/react/CaretLeft";
|
|
3
|
+
import { CaretRightIcon } from "@phosphor-icons/react/CaretRight";
|
|
4
|
+
|
|
5
|
+
export interface PaginationProps {
|
|
6
|
+
total: number;
|
|
7
|
+
limit: number;
|
|
8
|
+
offset: number;
|
|
9
|
+
onPageChange: (offset: number) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Pagination({
|
|
13
|
+
total,
|
|
14
|
+
limit,
|
|
15
|
+
offset,
|
|
16
|
+
onPageChange,
|
|
17
|
+
}: PaginationProps) {
|
|
18
|
+
if (total <= limit) return null;
|
|
19
|
+
|
|
20
|
+
const currentPage = Math.floor(offset / limit) + 1;
|
|
21
|
+
const totalPages = Math.ceil(total / limit);
|
|
22
|
+
const start = offset + 1;
|
|
23
|
+
const end = Math.min(offset + limit, total);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div data-slot="pagination" className="flex items-center justify-between">
|
|
27
|
+
<p className="text-sm text-muted-foreground">
|
|
28
|
+
Showing {start}-{end} of {total}
|
|
29
|
+
</p>
|
|
30
|
+
<div className="flex gap-2">
|
|
31
|
+
<Button
|
|
32
|
+
variant="outline"
|
|
33
|
+
size="sm"
|
|
34
|
+
disabled={currentPage <= 1}
|
|
35
|
+
onClick={() => onPageChange(offset - limit)}
|
|
36
|
+
>
|
|
37
|
+
<CaretLeftIcon className="size-4" />
|
|
38
|
+
Previous
|
|
39
|
+
</Button>
|
|
40
|
+
<Button
|
|
41
|
+
variant="outline"
|
|
42
|
+
size="sm"
|
|
43
|
+
disabled={currentPage >= totalPages}
|
|
44
|
+
onClick={() => onPageChange(offset + limit)}
|
|
45
|
+
>
|
|
46
|
+
Next
|
|
47
|
+
<CaretRightIcon className="size-4" />
|
|
48
|
+
</Button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "#components/ui/button";
|
|
4
|
+
import { Input } from "#components/ui/input";
|
|
5
|
+
import { cn } from "#utils/cn";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
|
|
8
|
+
export interface PillSelectOption<T extends string = string> {
|
|
9
|
+
value: T;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PillSelectProps<T extends string = string> {
|
|
14
|
+
options: PillSelectOption<T>[];
|
|
15
|
+
value?: T;
|
|
16
|
+
onChange: (value: T | undefined) => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
showSearch?: boolean;
|
|
19
|
+
searchPlaceholder?: string;
|
|
20
|
+
allowDeselect?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Max number of pills to show before collapsing. When set, only `defaultVisible` options are shown initially. */
|
|
23
|
+
maxVisible?: number;
|
|
24
|
+
/** Which option values to show by default when collapsed. Falls back to first `maxVisible` options. */
|
|
25
|
+
defaultVisible?: T[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PillSelect<T extends string = string>({
|
|
29
|
+
options,
|
|
30
|
+
value,
|
|
31
|
+
onChange,
|
|
32
|
+
disabled = false,
|
|
33
|
+
showSearch = false,
|
|
34
|
+
searchPlaceholder = "Search...",
|
|
35
|
+
allowDeselect = true,
|
|
36
|
+
className,
|
|
37
|
+
maxVisible,
|
|
38
|
+
defaultVisible,
|
|
39
|
+
}: PillSelectProps<T>) {
|
|
40
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
41
|
+
const [showAll, setShowAll] = useState(false);
|
|
42
|
+
|
|
43
|
+
// Filter options based on search term
|
|
44
|
+
const filteredOptions = showSearch
|
|
45
|
+
? options.filter((opt) =>
|
|
46
|
+
opt.label.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
47
|
+
)
|
|
48
|
+
: options;
|
|
49
|
+
|
|
50
|
+
// Determine visible options when collapsed
|
|
51
|
+
const isSearching = showSearch && searchTerm.length > 0;
|
|
52
|
+
const shouldCollapse = maxVisible && !showAll && !isSearching && filteredOptions.length > maxVisible;
|
|
53
|
+
|
|
54
|
+
const visibleOptions = shouldCollapse
|
|
55
|
+
? (() => {
|
|
56
|
+
// Always include the selected value in visible options
|
|
57
|
+
const defaultSet = defaultVisible
|
|
58
|
+
? filteredOptions.filter((opt) => defaultVisible.includes(opt.value))
|
|
59
|
+
: filteredOptions.slice(0, maxVisible);
|
|
60
|
+
|
|
61
|
+
if (value && !defaultSet.some((opt) => opt.value === value)) {
|
|
62
|
+
const selectedOpt = filteredOptions.find((opt) => opt.value === value);
|
|
63
|
+
if (selectedOpt) {
|
|
64
|
+
return [...defaultSet, selectedOpt];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return defaultSet;
|
|
68
|
+
})()
|
|
69
|
+
: filteredOptions;
|
|
70
|
+
|
|
71
|
+
const hiddenCount = shouldCollapse ? filteredOptions.length - visibleOptions.length : 0;
|
|
72
|
+
|
|
73
|
+
// Handle option click
|
|
74
|
+
const handleClick = (optionValue: T) => {
|
|
75
|
+
if (disabled) return;
|
|
76
|
+
|
|
77
|
+
// Allow deselect if enabled and clicking the already selected option
|
|
78
|
+
if (allowDeselect && value === optionValue) {
|
|
79
|
+
onChange(undefined);
|
|
80
|
+
} else {
|
|
81
|
+
onChange(optionValue);
|
|
82
|
+
// Clear search after selection
|
|
83
|
+
if (showSearch) {
|
|
84
|
+
setSearchTerm("");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className={cn("space-y-3", className)}>
|
|
91
|
+
{/* Search input */}
|
|
92
|
+
{showSearch && (
|
|
93
|
+
<Input
|
|
94
|
+
type="text"
|
|
95
|
+
placeholder={searchPlaceholder}
|
|
96
|
+
value={searchTerm}
|
|
97
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
className="w-full"
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Pills grid */}
|
|
104
|
+
<div className="flex flex-wrap gap-2">
|
|
105
|
+
{visibleOptions.length > 0 ? (
|
|
106
|
+
<>
|
|
107
|
+
{visibleOptions.map((option) => (
|
|
108
|
+
<Button
|
|
109
|
+
key={option.value}
|
|
110
|
+
type="button"
|
|
111
|
+
variant={value === option.value ? "default" : "outline"}
|
|
112
|
+
size="sm"
|
|
113
|
+
onClick={() => handleClick(option.value)}
|
|
114
|
+
disabled={disabled}
|
|
115
|
+
className="transition-all focus-visible:ring-0 focus-visible:border-primary/90 focus-visible:ring-offset-0 focus-visible:outline-none"
|
|
116
|
+
>
|
|
117
|
+
{option.label}
|
|
118
|
+
</Button>
|
|
119
|
+
))}
|
|
120
|
+
{shouldCollapse && hiddenCount > 0 && (
|
|
121
|
+
<Button
|
|
122
|
+
type="button"
|
|
123
|
+
variant="ghost"
|
|
124
|
+
size="sm"
|
|
125
|
+
onClick={() => setShowAll(true)}
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
className="text-muted-foreground"
|
|
128
|
+
>
|
|
129
|
+
+{hiddenCount} more
|
|
130
|
+
</Button>
|
|
131
|
+
)}
|
|
132
|
+
{showAll && maxVisible && !isSearching && (
|
|
133
|
+
<Button
|
|
134
|
+
type="button"
|
|
135
|
+
variant="ghost"
|
|
136
|
+
size="sm"
|
|
137
|
+
onClick={() => setShowAll(false)}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
className="text-muted-foreground"
|
|
140
|
+
>
|
|
141
|
+
Show less
|
|
142
|
+
</Button>
|
|
143
|
+
)}
|
|
144
|
+
</>
|
|
145
|
+
) : (
|
|
146
|
+
<p className="text-sm text-muted-foreground py-2">No options found</p>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "#utils/cn";
|
|
6
|
+
|
|
7
|
+
const Popover = PopoverPrimitive.Root;
|
|
8
|
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
9
|
+
const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
10
|
+
const PopoverClose = PopoverPrimitive.Close;
|
|
11
|
+
|
|
12
|
+
const PopoverContent = React.forwardRef<
|
|
13
|
+
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
|
14
|
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
15
|
+
>(
|
|
16
|
+
(
|
|
17
|
+
{ className, align = "center", sideOffset = 8, children, ...props },
|
|
18
|
+
ref,
|
|
19
|
+
) => (
|
|
20
|
+
<PopoverPrimitive.Portal>
|
|
21
|
+
<PopoverPrimitive.Content
|
|
22
|
+
ref={ref}
|
|
23
|
+
align={align}
|
|
24
|
+
sideOffset={sideOffset}
|
|
25
|
+
className={cn(
|
|
26
|
+
"z-50 w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
|
27
|
+
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
|
28
|
+
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
29
|
+
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
{...props}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</PopoverPrimitive.Content>
|
|
36
|
+
</PopoverPrimitive.Portal>
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
|
40
|
+
|
|
41
|
+
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Input } from "#components/ui/input";
|
|
4
|
+
import { cn } from "#utils/cn";
|
|
5
|
+
import { MagnifyingGlassIcon } from "@phosphor-icons/react/MagnifyingGlass";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
|
|
8
|
+
export interface SearchInputProps {
|
|
9
|
+
value: string;
|
|
10
|
+
onChange: (value: string) => void;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
debounceMs?: number;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SearchInput({
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
placeholder = "Search...",
|
|
20
|
+
debounceMs = 300,
|
|
21
|
+
className,
|
|
22
|
+
}: SearchInputProps) {
|
|
23
|
+
const [localValue, setLocalValue] = useState(value);
|
|
24
|
+
|
|
25
|
+
// Sync external value changes
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
setLocalValue(value);
|
|
28
|
+
}, [value]);
|
|
29
|
+
|
|
30
|
+
// Debounce local changes
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const timeout = setTimeout(() => {
|
|
33
|
+
onChange(localValue);
|
|
34
|
+
}, debounceMs);
|
|
35
|
+
return () => clearTimeout(timeout);
|
|
36
|
+
}, [localValue, onChange, debounceMs]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div data-slot="search-input" className={cn("relative", className)}>
|
|
40
|
+
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
41
|
+
<Input
|
|
42
|
+
placeholder={placeholder}
|
|
43
|
+
value={localValue}
|
|
44
|
+
onChange={(e) => setLocalValue(e.target.value)}
|
|
45
|
+
className="pl-9"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|