@olwiba/ui 0.1.13 → 0.1.15
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 +239 -5
- package/dist/index.js +786 -119
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AuthSection.tsx +155 -54
- package/src/app/BillingPanel.tsx +149 -0
- package/src/app/OnboardingWizard.tsx +115 -0
- package/src/app/SettingsSection.tsx +29 -0
- package/src/app/TeamMembersPanel.tsx +174 -0
- package/src/components/CommandMenu.tsx +100 -0
- package/src/components/DataTable.tsx +202 -0
- package/src/components/FileUpload.tsx +195 -0
- package/src/components/Notify.tsx +94 -0
- package/src/index.ts +34 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
5
|
+
import { MoreHorizontal, UserPlus } from 'lucide-react';
|
|
6
|
+
import {
|
|
7
|
+
Avatar,
|
|
8
|
+
AvatarFallback,
|
|
9
|
+
AvatarImage,
|
|
10
|
+
Badge,
|
|
11
|
+
DialogClose,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogDescription,
|
|
14
|
+
DialogFooter,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
DialogTrigger,
|
|
18
|
+
DropdownMenu,
|
|
19
|
+
DropdownMenuContent,
|
|
20
|
+
DropdownMenuItem,
|
|
21
|
+
DropdownMenuTrigger,
|
|
22
|
+
Label,
|
|
23
|
+
Select,
|
|
24
|
+
SelectContent,
|
|
25
|
+
SelectItem,
|
|
26
|
+
SelectTrigger,
|
|
27
|
+
SelectValue,
|
|
28
|
+
Dialog,
|
|
29
|
+
} from '@olwiba/cn';
|
|
30
|
+
import { Button } from '../primitives/Button';
|
|
31
|
+
import { Input } from '../primitives/Input';
|
|
32
|
+
import { DataTable } from '../components/DataTable';
|
|
33
|
+
|
|
34
|
+
export interface TeamMemberRecord {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
email: string;
|
|
38
|
+
avatar?: string;
|
|
39
|
+
role: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TeamMembersPanelProps {
|
|
43
|
+
members: TeamMemberRecord[];
|
|
44
|
+
/** Roles offered in the invite dialog and the per-row role menu. @default ['Owner', 'Admin', 'Member'] */
|
|
45
|
+
roles?: string[];
|
|
46
|
+
onInvite?: (email: string, role: string) => void;
|
|
47
|
+
onRoleChange?: (memberId: string, role: string) => void;
|
|
48
|
+
onRemove?: (memberId: string) => void;
|
|
49
|
+
title?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function InviteDialog({ roles, onInvite }: { roles: string[]; onInvite?: (email: string, role: string) => void }) {
|
|
54
|
+
const [email, setEmail] = React.useState('');
|
|
55
|
+
const [role, setRole] = React.useState(roles[roles.length - 1] ?? roles[0]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<DialogContent>
|
|
59
|
+
<DialogHeader>
|
|
60
|
+
<DialogTitle>Invite a team member</DialogTitle>
|
|
61
|
+
<DialogDescription>They’ll get an email invite to join this workspace.</DialogDescription>
|
|
62
|
+
</DialogHeader>
|
|
63
|
+
<div className="space-y-4 py-2">
|
|
64
|
+
<div className="space-y-2">
|
|
65
|
+
<Label htmlFor="invite-email">Email address</Label>
|
|
66
|
+
<Input id="invite-email" type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
67
|
+
</div>
|
|
68
|
+
<div className="space-y-2">
|
|
69
|
+
<Label>Role</Label>
|
|
70
|
+
<Select value={role} onValueChange={setRole}>
|
|
71
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
72
|
+
<SelectContent>
|
|
73
|
+
{roles.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}
|
|
74
|
+
</SelectContent>
|
|
75
|
+
</Select>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<DialogFooter>
|
|
79
|
+
<DialogClose asChild>
|
|
80
|
+
<Button variant="outline">Cancel</Button>
|
|
81
|
+
</DialogClose>
|
|
82
|
+
<DialogClose asChild>
|
|
83
|
+
<Button disabled={!email} onClick={() => onInvite?.(email, role)}>Send invite</Button>
|
|
84
|
+
</DialogClose>
|
|
85
|
+
</DialogFooter>
|
|
86
|
+
</DialogContent>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Member list with role management and an invite dialog. One block for
|
|
92
|
+
* team/org administration — built on `DataTable` rather than a bespoke list.
|
|
93
|
+
*/
|
|
94
|
+
export function TeamMembersPanel({
|
|
95
|
+
members,
|
|
96
|
+
roles = ['Owner', 'Admin', 'Member'],
|
|
97
|
+
onInvite,
|
|
98
|
+
onRoleChange,
|
|
99
|
+
onRemove,
|
|
100
|
+
title = 'Team members',
|
|
101
|
+
description = 'Manage who has access to this workspace.',
|
|
102
|
+
}: TeamMembersPanelProps) {
|
|
103
|
+
const columns = React.useMemo<ColumnDef<TeamMemberRecord>[]>(() => [
|
|
104
|
+
{
|
|
105
|
+
accessorKey: 'name',
|
|
106
|
+
header: 'Name',
|
|
107
|
+
cell: ({ row }) => (
|
|
108
|
+
<div className="flex items-center gap-3">
|
|
109
|
+
<Avatar className="size-8">
|
|
110
|
+
<AvatarImage src={row.original.avatar} alt="" />
|
|
111
|
+
<AvatarFallback>{row.original.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
112
|
+
</Avatar>
|
|
113
|
+
<div>
|
|
114
|
+
<p className="text-sm font-medium leading-none">{row.original.name}</p>
|
|
115
|
+
<p className="mt-1 text-xs text-muted-foreground">{row.original.email}</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
accessorKey: 'role',
|
|
122
|
+
header: 'Role',
|
|
123
|
+
cell: ({ row }) =>
|
|
124
|
+
onRoleChange ? (
|
|
125
|
+
<Select value={row.original.role} onValueChange={(value) => onRoleChange(row.original.id, value)}>
|
|
126
|
+
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
|
|
127
|
+
<SelectContent>
|
|
128
|
+
{roles.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}
|
|
129
|
+
</SelectContent>
|
|
130
|
+
</Select>
|
|
131
|
+
) : (
|
|
132
|
+
<Badge variant="secondary">{row.original.role}</Badge>
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'actions',
|
|
137
|
+
header: '',
|
|
138
|
+
cell: ({ row }) => (
|
|
139
|
+
<DropdownMenu>
|
|
140
|
+
<DropdownMenuTrigger asChild>
|
|
141
|
+
<Button variant="ghost" size="icon" className="size-8">
|
|
142
|
+
<MoreHorizontal className="size-4" />
|
|
143
|
+
<span className="sr-only">Row actions</span>
|
|
144
|
+
</Button>
|
|
145
|
+
</DropdownMenuTrigger>
|
|
146
|
+
<DropdownMenuContent align="end">
|
|
147
|
+
<DropdownMenuItem className="text-destructive" onClick={() => onRemove?.(row.original.id)}>
|
|
148
|
+
Remove member
|
|
149
|
+
</DropdownMenuItem>
|
|
150
|
+
</DropdownMenuContent>
|
|
151
|
+
</DropdownMenu>
|
|
152
|
+
),
|
|
153
|
+
enableSorting: false,
|
|
154
|
+
},
|
|
155
|
+
], [roles, onRoleChange, onRemove]);
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="space-y-4">
|
|
159
|
+
<div className="flex items-center justify-between gap-4">
|
|
160
|
+
<div>
|
|
161
|
+
<h2 className="text-base font-semibold">{title}</h2>
|
|
162
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
163
|
+
</div>
|
|
164
|
+
<Dialog>
|
|
165
|
+
<DialogTrigger asChild>
|
|
166
|
+
<Button><UserPlus className="size-4" /> Invite member</Button>
|
|
167
|
+
</DialogTrigger>
|
|
168
|
+
<InviteDialog roles={roles} onInvite={onInvite} />
|
|
169
|
+
</Dialog>
|
|
170
|
+
</div>
|
|
171
|
+
<DataTable columns={columns} data={members} searchKey="name" searchPlaceholder="Search members…" pageSize={0} />
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import type { LucideIcon } from 'lucide-react';
|
|
5
|
+
import {
|
|
6
|
+
CommandDialog,
|
|
7
|
+
CommandEmpty,
|
|
8
|
+
CommandGroup,
|
|
9
|
+
CommandInput,
|
|
10
|
+
CommandItem,
|
|
11
|
+
CommandList,
|
|
12
|
+
CommandSeparator,
|
|
13
|
+
CommandShortcut,
|
|
14
|
+
} from '@olwiba/cn';
|
|
15
|
+
import { RegisterHotkeys } from './RegisterHotkeys';
|
|
16
|
+
import { useControlledOpen } from '../hooks/use-controlled-open';
|
|
17
|
+
|
|
18
|
+
export interface CommandMenuItem {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
icon?: LucideIcon;
|
|
22
|
+
shortcut?: string;
|
|
23
|
+
keywords?: string[];
|
|
24
|
+
onSelect: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CommandMenuGroup {
|
|
28
|
+
heading: string;
|
|
29
|
+
items: CommandMenuItem[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CommandMenuProps {
|
|
33
|
+
groups: CommandMenuGroup[];
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
emptyMessage?: string;
|
|
36
|
+
/** Controlled open state — omit to let the component manage it internally. */
|
|
37
|
+
open?: boolean;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
/** Registers Cmd+K (mac) / Ctrl+K (win) to toggle the palette. @default true */
|
|
40
|
+
hotkey?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Global search / Cmd+K command palette. One component — pass different
|
|
45
|
+
* `groups` per surface rather than building a bespoke dialog each time.
|
|
46
|
+
*/
|
|
47
|
+
export function CommandMenu({
|
|
48
|
+
groups,
|
|
49
|
+
placeholder = 'Type a command or search…',
|
|
50
|
+
emptyMessage = 'No results found.',
|
|
51
|
+
open: openProp,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
hotkey = true,
|
|
54
|
+
}: CommandMenuProps) {
|
|
55
|
+
const internal = useControlledOpen(false);
|
|
56
|
+
const isOpen = openProp ?? internal.isOpen;
|
|
57
|
+
const setOpen = onOpenChange ?? internal.setIsOpen;
|
|
58
|
+
|
|
59
|
+
const runItem = (item: CommandMenuItem) => {
|
|
60
|
+
setOpen(false);
|
|
61
|
+
item.onSelect();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
{hotkey && (
|
|
67
|
+
<RegisterHotkeys
|
|
68
|
+
hotkeys={[
|
|
69
|
+
{ key: 'k', meta: true, handler: () => setOpen(!isOpen) },
|
|
70
|
+
{ key: 'k', ctrl: true, handler: () => setOpen(!isOpen) },
|
|
71
|
+
]}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
<CommandDialog open={isOpen} onOpenChange={setOpen}>
|
|
75
|
+
<CommandInput placeholder={placeholder} />
|
|
76
|
+
<CommandList>
|
|
77
|
+
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
|
78
|
+
{groups.map((group, i) => (
|
|
79
|
+
<React.Fragment key={group.heading}>
|
|
80
|
+
{i > 0 && <CommandSeparator />}
|
|
81
|
+
<CommandGroup heading={group.heading}>
|
|
82
|
+
{group.items.map((item) => (
|
|
83
|
+
<CommandItem
|
|
84
|
+
key={item.id}
|
|
85
|
+
value={[item.label, ...(item.keywords ?? [])].join(' ')}
|
|
86
|
+
onSelect={() => runItem(item)}
|
|
87
|
+
>
|
|
88
|
+
{item.icon && <item.icon />}
|
|
89
|
+
<span>{item.label}</span>
|
|
90
|
+
{item.shortcut && <CommandShortcut>{item.shortcut}</CommandShortcut>}
|
|
91
|
+
</CommandItem>
|
|
92
|
+
))}
|
|
93
|
+
</CommandGroup>
|
|
94
|
+
</React.Fragment>
|
|
95
|
+
))}
|
|
96
|
+
</CommandList>
|
|
97
|
+
</CommandDialog>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import {
|
|
5
|
+
type ColumnDef,
|
|
6
|
+
type SortingState,
|
|
7
|
+
flexRender,
|
|
8
|
+
getCoreRowModel,
|
|
9
|
+
getPaginationRowModel,
|
|
10
|
+
getSortedRowModel,
|
|
11
|
+
useReactTable,
|
|
12
|
+
} from '@tanstack/react-table';
|
|
13
|
+
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
|
14
|
+
import { Checkbox, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn } from '@olwiba/cn';
|
|
15
|
+
import { Button } from '../primitives/Button';
|
|
16
|
+
import { Input } from '../primitives/Input';
|
|
17
|
+
|
|
18
|
+
export interface DataTableProps<TData> {
|
|
19
|
+
columns: ColumnDef<TData>[];
|
|
20
|
+
data: TData[];
|
|
21
|
+
/** Shows a quick-filter input above the table, matching against `searchKey`. */
|
|
22
|
+
searchKey?: string;
|
|
23
|
+
searchPlaceholder?: string;
|
|
24
|
+
/** Adds a checkbox column and reports the selected rows. */
|
|
25
|
+
selectable?: boolean;
|
|
26
|
+
onSelectionChange?: (rows: TData[]) => void;
|
|
27
|
+
/** Rows per page. Set to `0` to disable pagination entirely. @default 10 */
|
|
28
|
+
pageSize?: number;
|
|
29
|
+
/** Slot rendered top-right of the toolbar — e.g. an "Add" button. */
|
|
30
|
+
toolbar?: React.ReactNode;
|
|
31
|
+
onRowClick?: (row: TData) => void;
|
|
32
|
+
emptyMessage?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const selectColumn: ColumnDef<Record<string, unknown>> = {
|
|
37
|
+
id: '__select',
|
|
38
|
+
header: ({ table }) => (
|
|
39
|
+
<Checkbox
|
|
40
|
+
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
|
|
41
|
+
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
42
|
+
aria-label="Select all"
|
|
43
|
+
/>
|
|
44
|
+
),
|
|
45
|
+
cell: ({ row }) => (
|
|
46
|
+
<Checkbox
|
|
47
|
+
checked={row.getIsSelected()}
|
|
48
|
+
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
49
|
+
onClick={(e) => e.stopPropagation()}
|
|
50
|
+
aria-label="Select row"
|
|
51
|
+
/>
|
|
52
|
+
),
|
|
53
|
+
enableSorting: false,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sortable, paginated, optionally-selectable data table. One component —
|
|
58
|
+
* toggle `searchKey`/`selectable`/`pageSize` rather than reaching for a
|
|
59
|
+
* different table component per use case.
|
|
60
|
+
*/
|
|
61
|
+
export function DataTable<TData>({
|
|
62
|
+
columns,
|
|
63
|
+
data,
|
|
64
|
+
searchKey,
|
|
65
|
+
searchPlaceholder = 'Search…',
|
|
66
|
+
selectable = false,
|
|
67
|
+
onSelectionChange,
|
|
68
|
+
pageSize = 10,
|
|
69
|
+
toolbar,
|
|
70
|
+
onRowClick,
|
|
71
|
+
emptyMessage = 'No results.',
|
|
72
|
+
className,
|
|
73
|
+
}: DataTableProps<TData>) {
|
|
74
|
+
const [sorting, setSorting] = React.useState<SortingState>([]);
|
|
75
|
+
const [rowSelection, setRowSelection] = React.useState({});
|
|
76
|
+
const [globalFilter, setGlobalFilter] = React.useState('');
|
|
77
|
+
|
|
78
|
+
const allColumns = React.useMemo(
|
|
79
|
+
() => (selectable ? [selectColumn as ColumnDef<TData>, ...columns] : columns),
|
|
80
|
+
[selectable, columns],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const table = useReactTable({
|
|
84
|
+
data,
|
|
85
|
+
columns: allColumns,
|
|
86
|
+
state: { sorting, rowSelection, globalFilter },
|
|
87
|
+
onSortingChange: setSorting,
|
|
88
|
+
onRowSelectionChange: setRowSelection,
|
|
89
|
+
onGlobalFilterChange: setGlobalFilter,
|
|
90
|
+
globalFilterFn: (row, _columnId, filterValue) => {
|
|
91
|
+
if (!searchKey) return true;
|
|
92
|
+
const value = row.getValue(searchKey);
|
|
93
|
+
return String(value ?? '').toLowerCase().includes(String(filterValue).toLowerCase());
|
|
94
|
+
},
|
|
95
|
+
getCoreRowModel: getCoreRowModel(),
|
|
96
|
+
getSortedRowModel: getSortedRowModel(),
|
|
97
|
+
...(pageSize > 0 ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
|
98
|
+
initialState: pageSize > 0 ? { pagination: { pageSize } } : undefined,
|
|
99
|
+
enableRowSelection: selectable,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
if (!onSelectionChange) return;
|
|
104
|
+
onSelectionChange(table.getSelectedRowModel().rows.map((r) => r.original));
|
|
105
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
|
+
}, [rowSelection]);
|
|
107
|
+
|
|
108
|
+
const rows = table.getRowModel().rows;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn('space-y-4', className)}>
|
|
112
|
+
{(searchKey || toolbar) && (
|
|
113
|
+
<div className="flex items-center justify-between gap-4">
|
|
114
|
+
{searchKey ? (
|
|
115
|
+
<div className="relative max-w-sm flex-1">
|
|
116
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
117
|
+
<Input
|
|
118
|
+
value={globalFilter}
|
|
119
|
+
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
120
|
+
placeholder={searchPlaceholder}
|
|
121
|
+
className="pl-8"
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
) : <div />}
|
|
125
|
+
{toolbar}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
<div className="overflow-hidden rounded-lg border">
|
|
130
|
+
<Table>
|
|
131
|
+
<TableHeader>
|
|
132
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
133
|
+
<TableRow key={headerGroup.id}>
|
|
134
|
+
{headerGroup.headers.map((header) => {
|
|
135
|
+
const canSort = header.column.getCanSort();
|
|
136
|
+
const sortDir = header.column.getIsSorted();
|
|
137
|
+
return (
|
|
138
|
+
<TableHead key={header.id}>
|
|
139
|
+
{header.isPlaceholder ? null : canSort ? (
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
className="flex items-center gap-1.5 font-medium hover:text-foreground"
|
|
143
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
144
|
+
>
|
|
145
|
+
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
146
|
+
{sortDir === 'asc' ? <ArrowUp className="size-3.5" /> : sortDir === 'desc' ? <ArrowDown className="size-3.5" /> : <ArrowUpDown className="size-3.5 text-muted-foreground/50" />}
|
|
147
|
+
</button>
|
|
148
|
+
) : (
|
|
149
|
+
flexRender(header.column.columnDef.header, header.getContext())
|
|
150
|
+
)}
|
|
151
|
+
</TableHead>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</TableRow>
|
|
155
|
+
))}
|
|
156
|
+
</TableHeader>
|
|
157
|
+
<TableBody>
|
|
158
|
+
{rows.length ? (
|
|
159
|
+
rows.map((row) => (
|
|
160
|
+
<TableRow
|
|
161
|
+
key={row.id}
|
|
162
|
+
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
163
|
+
onClick={() => onRowClick?.(row.original)}
|
|
164
|
+
className={cn(onRowClick && 'cursor-pointer')}
|
|
165
|
+
>
|
|
166
|
+
{row.getVisibleCells().map((cell) => (
|
|
167
|
+
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
|
168
|
+
))}
|
|
169
|
+
</TableRow>
|
|
170
|
+
))
|
|
171
|
+
) : (
|
|
172
|
+
<TableRow>
|
|
173
|
+
<TableCell colSpan={allColumns.length} className="h-24 text-center text-muted-foreground">
|
|
174
|
+
{emptyMessage}
|
|
175
|
+
</TableCell>
|
|
176
|
+
</TableRow>
|
|
177
|
+
)}
|
|
178
|
+
</TableBody>
|
|
179
|
+
</Table>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{pageSize > 0 && table.getPageCount() > 1 && (
|
|
183
|
+
<div className="flex items-center justify-between">
|
|
184
|
+
<p className="text-sm text-muted-foreground">
|
|
185
|
+
{selectable && `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected · `}
|
|
186
|
+
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
|
187
|
+
</p>
|
|
188
|
+
<div className="flex gap-2">
|
|
189
|
+
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
|
190
|
+
<ChevronLeft className="size-4" /> Previous
|
|
191
|
+
</Button>
|
|
192
|
+
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
|
193
|
+
Next <ChevronRight className="size-4" />
|
|
194
|
+
</Button>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type { ColumnDef as DataTableColumn };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { AlertCircle, CheckCircle2, File as FileIcon, Loader2, UploadCloud, X } from 'lucide-react';
|
|
5
|
+
import { cn } from '@olwiba/cn';
|
|
6
|
+
import { Button } from '../primitives/Button';
|
|
7
|
+
|
|
8
|
+
export interface FileUploadEntry {
|
|
9
|
+
id: string;
|
|
10
|
+
file: File;
|
|
11
|
+
/** 0–100. Omit while pending, or when not tracking progress. */
|
|
12
|
+
progress?: number;
|
|
13
|
+
status?: 'pending' | 'uploading' | 'done' | 'error';
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FileUploadProps {
|
|
18
|
+
/** Comma-separated MIME types / extensions, e.g. `"image/png,image/jpeg"`. */
|
|
19
|
+
accept?: string;
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
maxSizeMb?: number;
|
|
22
|
+
maxFiles?: number;
|
|
23
|
+
/** Controlled file list — pass this (with `onFilesChange`) to drive upload progress from your own network layer. */
|
|
24
|
+
files?: FileUploadEntry[];
|
|
25
|
+
/** Uncontrolled default list. */
|
|
26
|
+
defaultFiles?: FileUploadEntry[];
|
|
27
|
+
onFilesChange?: (files: FileUploadEntry[]) => void;
|
|
28
|
+
/** Fired with the raw, already-validated `File` objects a user just added. */
|
|
29
|
+
onFilesAdded?: (files: File[]) => void;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Helper text under the drop zone, e.g. "PNG or JPG, up to 5MB". */
|
|
32
|
+
hint?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatBytes(bytes: number) {
|
|
37
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
38
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
39
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeId() {
|
|
43
|
+
return Math.random().toString(36).slice(2, 10);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drag-and-drop file picker with a validated queue list. One component —
|
|
48
|
+
* toggle `multiple`/`accept`/`maxSizeMb`/`maxFiles` rather than reaching for
|
|
49
|
+
* a separate dropzone per use case. Progress/status is presentation-only;
|
|
50
|
+
* wire `files`/`onFilesChange` to your own upload layer to drive it.
|
|
51
|
+
*/
|
|
52
|
+
export function FileUpload({
|
|
53
|
+
accept,
|
|
54
|
+
multiple = false,
|
|
55
|
+
maxSizeMb,
|
|
56
|
+
maxFiles,
|
|
57
|
+
files: filesProp,
|
|
58
|
+
defaultFiles,
|
|
59
|
+
onFilesChange,
|
|
60
|
+
onFilesAdded,
|
|
61
|
+
disabled,
|
|
62
|
+
hint,
|
|
63
|
+
className,
|
|
64
|
+
}: FileUploadProps) {
|
|
65
|
+
const [internalFiles, setInternalFiles] = React.useState<FileUploadEntry[]>(defaultFiles ?? []);
|
|
66
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
67
|
+
const [validationError, setValidationError] = React.useState<string | null>(null);
|
|
68
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
69
|
+
const files = filesProp ?? internalFiles;
|
|
70
|
+
|
|
71
|
+
const setFiles = React.useCallback(
|
|
72
|
+
(next: FileUploadEntry[]) => {
|
|
73
|
+
if (!filesProp) setInternalFiles(next);
|
|
74
|
+
onFilesChange?.(next);
|
|
75
|
+
},
|
|
76
|
+
[filesProp, onFilesChange],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const acceptList = React.useMemo(
|
|
80
|
+
() => accept?.split(',').map((a) => a.trim().toLowerCase()).filter(Boolean) ?? [],
|
|
81
|
+
[accept],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const matchesAccept = (file: File) => {
|
|
85
|
+
if (!acceptList.length) return true;
|
|
86
|
+
const name = file.name.toLowerCase();
|
|
87
|
+
return acceptList.some((pattern) =>
|
|
88
|
+
pattern.startsWith('.') ? name.endsWith(pattern) : file.type === pattern || file.type.startsWith(pattern.replace('/*', '/')),
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleFiles = (fileList: FileList | null) => {
|
|
93
|
+
if (!fileList || disabled) return;
|
|
94
|
+
const incoming = Array.from(fileList);
|
|
95
|
+
const room = maxFiles ? Math.max(0, maxFiles - files.length) : Infinity;
|
|
96
|
+
if (maxFiles && room <= 0) {
|
|
97
|
+
setValidationError(`You can only add up to ${maxFiles} file${maxFiles === 1 ? '' : 's'}.`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const accepted: File[] = [];
|
|
102
|
+
let rejected = false;
|
|
103
|
+
for (const file of incoming.slice(0, room)) {
|
|
104
|
+
if (!matchesAccept(file)) { rejected = true; continue; }
|
|
105
|
+
if (maxSizeMb && file.size > maxSizeMb * 1024 * 1024) { rejected = true; continue; }
|
|
106
|
+
accepted.push(file);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setValidationError(rejected ? `Some files were skipped — check the file type and size limit.` : null);
|
|
110
|
+
if (!accepted.length) return;
|
|
111
|
+
|
|
112
|
+
const entries: FileUploadEntry[] = accepted.map((file) => ({ id: makeId(), file, status: 'pending' }));
|
|
113
|
+
setFiles(multiple ? [...files, ...entries] : entries);
|
|
114
|
+
onFilesAdded?.(accepted);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const removeFile = (id: string) => {
|
|
118
|
+
setFiles(files.filter((f) => f.id !== id));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className={cn('space-y-3', className)}>
|
|
123
|
+
<div
|
|
124
|
+
role="button"
|
|
125
|
+
tabIndex={disabled ? -1 : 0}
|
|
126
|
+
onClick={() => !disabled && inputRef.current?.click()}
|
|
127
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click(); }}
|
|
128
|
+
onDragOver={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
|
|
129
|
+
onDragLeave={() => setIsDragging(false)}
|
|
130
|
+
onDrop={(e) => {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
setIsDragging(false);
|
|
133
|
+
handleFiles(e.dataTransfer.files);
|
|
134
|
+
}}
|
|
135
|
+
className={cn(
|
|
136
|
+
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-10 text-center transition-colors',
|
|
137
|
+
isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-foreground/30',
|
|
138
|
+
disabled && 'pointer-events-none opacity-50',
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
<UploadCloud className="size-8 text-muted-foreground" />
|
|
142
|
+
<p className="text-sm font-medium">
|
|
143
|
+
<span className="text-primary underline underline-offset-4">Click to upload</span> or drag and drop
|
|
144
|
+
</p>
|
|
145
|
+
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
|
146
|
+
<input
|
|
147
|
+
ref={inputRef}
|
|
148
|
+
type="file"
|
|
149
|
+
accept={accept}
|
|
150
|
+
multiple={multiple}
|
|
151
|
+
disabled={disabled}
|
|
152
|
+
className="sr-only"
|
|
153
|
+
onChange={(e) => { handleFiles(e.target.files); e.target.value = ''; }}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{validationError && <p className="text-sm font-medium text-destructive">{validationError}</p>}
|
|
158
|
+
|
|
159
|
+
{files.length > 0 && (
|
|
160
|
+
<ul className="space-y-2">
|
|
161
|
+
{files.map(({ id, file, progress, status = 'pending', error }) => (
|
|
162
|
+
<li key={id} className="flex items-center gap-3 rounded-lg border bg-card/60 px-3 py-2.5">
|
|
163
|
+
<FileIcon className="size-5 shrink-0 text-muted-foreground" />
|
|
164
|
+
<div className="min-w-0 flex-1">
|
|
165
|
+
<div className="flex items-center justify-between gap-2">
|
|
166
|
+
<p className="truncate text-sm font-medium">{file.name}</p>
|
|
167
|
+
<span className="shrink-0 text-xs text-muted-foreground">{formatBytes(file.size)}</span>
|
|
168
|
+
</div>
|
|
169
|
+
{status === 'uploading' && (
|
|
170
|
+
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
171
|
+
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${progress ?? 0}%` }} />
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
{status === 'error' && error && <p className="mt-1 text-xs text-destructive">{error}</p>}
|
|
175
|
+
</div>
|
|
176
|
+
{status === 'uploading' && <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />}
|
|
177
|
+
{status === 'done' && <CheckCircle2 className="size-4 shrink-0 text-primary" />}
|
|
178
|
+
{status === 'error' && <AlertCircle className="size-4 shrink-0 text-destructive" />}
|
|
179
|
+
<Button
|
|
180
|
+
type="button"
|
|
181
|
+
variant="ghost"
|
|
182
|
+
size="icon"
|
|
183
|
+
className="size-7 shrink-0"
|
|
184
|
+
onClick={() => removeFile(id)}
|
|
185
|
+
>
|
|
186
|
+
<X className="size-3.5" />
|
|
187
|
+
<span className="sr-only">Remove {file.name}</span>
|
|
188
|
+
</Button>
|
|
189
|
+
</li>
|
|
190
|
+
))}
|
|
191
|
+
</ul>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|