meyi-vault-client-dev 1.0.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 +157 -0
- package/package.json +69 -0
- package/src/VaultApp.tsx +82 -0
- package/src/components/datetime-picker.tsx +262 -0
- package/src/components/dialogs.tsx +467 -0
- package/src/components/searchable-multi-select.tsx +275 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/calendar.tsx +210 -0
- package/src/components/ui/command.tsx +181 -0
- package/src/components/ui/dialog.tsx +140 -0
- package/src/components/ui/input.tsx +27 -0
- package/src/components/ui/label.tsx +21 -0
- package/src/components/ui/popover.tsx +45 -0
- package/src/components/ui/scroll-area.tsx +64 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/table.tsx +122 -0
- package/src/components/vault/entries-table.tsx +283 -0
- package/src/components/vault/password-cell.tsx +26 -0
- package/src/context/VaultProvider.tsx +49 -0
- package/src/hooks/useVault.ts +177 -0
- package/src/index.tsx +99 -0
- package/src/lib/utils.ts +6 -0
- package/src/pages/AuditPage.tsx +408 -0
- package/src/pages/EntriesPage.tsx +111 -0
- package/src/pages/VaultPage.tsx +184 -0
- package/src/pages/meyi-connect-password.code-workspace +11 -0
- package/src/services/api.ts +139 -0
- package/src/style.css +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# vault-client
|
|
2
|
+
|
|
3
|
+
> Self-hosted AES-256-GCM encrypted password manager — React UI plugin for MeyiConnect
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install vault-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## MeyiConnect integration (3 steps)
|
|
12
|
+
|
|
13
|
+
### Step 1 — Add the route file
|
|
14
|
+
|
|
15
|
+
Copy to `frontend/src/routes/_authenticated/vault/$.tsx`:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
19
|
+
import { VaultApp, VaultProvider } from "vault-client";
|
|
20
|
+
import "vault-client/style.css";
|
|
21
|
+
import api from "@/services/api";
|
|
22
|
+
import { users } from "@/services/users";
|
|
23
|
+
|
|
24
|
+
function VaultRoute() {
|
|
25
|
+
return (
|
|
26
|
+
<VaultProvider
|
|
27
|
+
api={api}
|
|
28
|
+
getUsers={() => users.getAll().then((r) => r.data ?? r)}
|
|
29
|
+
>
|
|
30
|
+
<VaultApp />
|
|
31
|
+
</VaultProvider>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const Route = createFileRoute("/_authenticated/vault/$")({
|
|
36
|
+
component: VaultRoute,
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Step 2 — Add to sidebar
|
|
41
|
+
|
|
42
|
+
In `frontend/src/components/layout/data/sidebar-data.ts`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { vaultNavItems } from 'vault-client'
|
|
46
|
+
import { FolderLock } from 'lucide-react'
|
|
47
|
+
|
|
48
|
+
// Add inside navGroups array:
|
|
49
|
+
{
|
|
50
|
+
title: 'Vault',
|
|
51
|
+
items: vaultNavItems.map(item => ({ ...item, icon: FolderLock })),
|
|
52
|
+
},
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Step 3 — Done
|
|
56
|
+
|
|
57
|
+
TanStack Router auto-discovers the new route file. Run `npm run dev` and
|
|
58
|
+
"Vaults" appears in the sidebar.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Standalone usage (any React app)
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { VaultProvider, VaultApp } from "vault-client";
|
|
66
|
+
import "vault-client/style.css";
|
|
67
|
+
import axios from "axios";
|
|
68
|
+
|
|
69
|
+
const api = axios.create({ baseURL: "https://your-api.example.com" });
|
|
70
|
+
|
|
71
|
+
function App() {
|
|
72
|
+
return (
|
|
73
|
+
<VaultProvider
|
|
74
|
+
api={api}
|
|
75
|
+
getUsers={async () => {
|
|
76
|
+
const res = await api.get("/api/v1/users");
|
|
77
|
+
return res.data.users;
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<VaultApp />
|
|
81
|
+
</VaultProvider>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Composable exports
|
|
89
|
+
|
|
90
|
+
You can use individual pieces instead of the full `VaultApp`:
|
|
91
|
+
|
|
92
|
+
### Pages (you manage routing)
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
import { VaultPage, GroupsPage, EntriesPage } from 'vault-client'
|
|
96
|
+
|
|
97
|
+
// VaultPage — lists all vaults
|
|
98
|
+
<VaultPage onNavigate={(to, vaultId) => router.push(`/vault/${vaultId}`)} />
|
|
99
|
+
|
|
100
|
+
// GroupsPage — lists domain groups inside a vault
|
|
101
|
+
<GroupsPage vaultId={vaultId} vaultName="Production" onNavigate={...} />
|
|
102
|
+
|
|
103
|
+
// EntriesPage — lists encrypted credentials inside a group
|
|
104
|
+
<EntriesPage vaultId={vaultId} groupId={groupId} onNavigate={...} />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Hooks (bring your own UI)
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
import { useVaults, useEntries, useCreateGrant } from "vault-client";
|
|
111
|
+
|
|
112
|
+
function MyCustomVaultUI() {
|
|
113
|
+
const { data: vaults } = useVaults();
|
|
114
|
+
const { data: entries } = useEntries(groupId);
|
|
115
|
+
const createGrant = useCreateGrant();
|
|
116
|
+
|
|
117
|
+
// build your own UI
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Dialogs
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { EntryFormDialog, GrantAccessDialog } from 'vault-client'
|
|
125
|
+
|
|
126
|
+
<EntryFormDialog
|
|
127
|
+
open={open}
|
|
128
|
+
onOpenChange={setOpen}
|
|
129
|
+
groupId={groupId}
|
|
130
|
+
/>
|
|
131
|
+
|
|
132
|
+
<GrantAccessDialog
|
|
133
|
+
open={open}
|
|
134
|
+
onOpenChange={setOpen}
|
|
135
|
+
scope="vault"
|
|
136
|
+
scopeId={vaultId}
|
|
137
|
+
scopeLabel="Production"
|
|
138
|
+
/>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## VaultProvider props
|
|
144
|
+
|
|
145
|
+
| Prop | Type | Required | Default | Description |
|
|
146
|
+
| ---------- | ----------------------- | -------- | -------- | ---------------------------------------------- |
|
|
147
|
+
| `api` | `AxiosInstance` | ✅ | — | Host's axios instance (shares auth + base URL) |
|
|
148
|
+
| `getUsers` | `() => Promise<User[]>` | ✅ | — | Fetches users for grant picker |
|
|
149
|
+
| `basePath` | `string` | — | `/vault` | Base path for internal navigation |
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## What the package does NOT include
|
|
154
|
+
|
|
155
|
+
- **Auth** — uses the host's axios instance and req.user from the server
|
|
156
|
+
- **Toast provider** — uses `sonner` which must be mounted by the host
|
|
157
|
+
- **shadcn components** — uses inline primitives; compatible with any Tailwind setup
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "meyi-vault-client-dev",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Self-hosted encrypted password manager — React UI plugin for MeyiConnect",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/vault-client.js",
|
|
10
|
+
"require": "./dist/vault-client.cjs",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"source": "./src/index.tsx"
|
|
13
|
+
},
|
|
14
|
+
"./style.css": "./dist/style.css"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "vite build --watch",
|
|
22
|
+
"build": "tsc && vite build",
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"password-manager",
|
|
27
|
+
"vault",
|
|
28
|
+
"meyiconnect",
|
|
29
|
+
"plugin",
|
|
30
|
+
"react"
|
|
31
|
+
],
|
|
32
|
+
"author": "Meyi Technologies",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@tanstack/react-query": "^5.x",
|
|
36
|
+
"@tanstack/react-router": "^1.x",
|
|
37
|
+
"react": "^18.x || ^19.x",
|
|
38
|
+
"react-dom": "^18.x || ^19.x",
|
|
39
|
+
"sonner": "^1.x || ^2.x"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@hookform/resolvers": "^3.9.0",
|
|
43
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
44
|
+
"@radix-ui/react-label": "^2.1.8",
|
|
45
|
+
"@radix-ui/react-popover": "^1.1.15",
|
|
46
|
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
47
|
+
"@radix-ui/react-separator": "^1.1.8",
|
|
48
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
49
|
+
"@tanstack/react-table": "^8.21.3",
|
|
50
|
+
"axios": "^1.7.2",
|
|
51
|
+
"class-variance-authority": "^0.7.1",
|
|
52
|
+
"clsx": "^2.1.1",
|
|
53
|
+
"cmdk": "^1.1.1",
|
|
54
|
+
"date-fns": "^4.1.0",
|
|
55
|
+
"lucide-react": "^0.469.0",
|
|
56
|
+
"react-day-picker": "^9.11.1",
|
|
57
|
+
"react-hook-form": "^7.52.0",
|
|
58
|
+
"tailwind-merge": "^2.5.4",
|
|
59
|
+
"zod": "^3.23.8"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/react": "^18.3.3",
|
|
63
|
+
"@types/react-dom": "^18.3.0",
|
|
64
|
+
"@vitejs/plugin-react-swc": "^3.7.2",
|
|
65
|
+
"typescript": "^5.6.3",
|
|
66
|
+
"vite": "^6.0.5",
|
|
67
|
+
"vite-plugin-dts": "^4.3.0"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/VaultApp.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useVaults } from '@/hooks/useVault'
|
|
3
|
+
import { VaultPage } from '@/pages/VaultPage'
|
|
4
|
+
import { EntriesPage } from '@/pages/EntriesPage'
|
|
5
|
+
import { AuditPage } from '@/pages/AuditPage'
|
|
6
|
+
import { useVaultContext } from '@/context/VaultProvider'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* VaultApp — the complete self-contained vault UI.
|
|
10
|
+
* Now uses real URL-based navigation for better UX and breadcrumb support.
|
|
11
|
+
*/
|
|
12
|
+
export function VaultApp({ path: externalPath }: { path?: string } = {}) {
|
|
13
|
+
const { basePath } = useVaultContext()
|
|
14
|
+
const [path, setPath] = useState(externalPath || window.location.pathname)
|
|
15
|
+
|
|
16
|
+
// Sync with external path prop if provided (e.g. from host router)
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (externalPath) setPath(externalPath)
|
|
19
|
+
}, [externalPath])
|
|
20
|
+
|
|
21
|
+
// Sync state with browser navigation
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const handlePopState = () => setPath(window.location.pathname)
|
|
24
|
+
window.addEventListener('popstate', handlePopState)
|
|
25
|
+
return () => window.removeEventListener('popstate', handlePopState)
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
const navigate = (to: string) => {
|
|
29
|
+
// Ensure 'to' starts with a slash
|
|
30
|
+
const target = to.startsWith('/') ? to : `/${to}`
|
|
31
|
+
// Only prepend basePath if the target doesn't already start with it
|
|
32
|
+
const newPath = target.startsWith(basePath) ? target : `${basePath}${target}`
|
|
33
|
+
|
|
34
|
+
window.history.pushState({}, '', newPath)
|
|
35
|
+
setPath(newPath)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { data: vaults = [] } = useVaults()
|
|
39
|
+
|
|
40
|
+
// Parse current route relative to basePath
|
|
41
|
+
const relativePath = path.startsWith(basePath)
|
|
42
|
+
? path.slice(basePath.length).replace(/\/$/, '') || '/'
|
|
43
|
+
: '/'
|
|
44
|
+
|
|
45
|
+
// Route: /vault (Root)
|
|
46
|
+
if (relativePath === '/' || relativePath === '') {
|
|
47
|
+
return (
|
|
48
|
+
<VaultPage
|
|
49
|
+
onNavigate={(to, vaultId) => {
|
|
50
|
+
if (to === 'audit') navigate('/audit')
|
|
51
|
+
else if (vaultId) navigate(`/${vaultId}`)
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Route: /audit
|
|
58
|
+
if (relativePath === '/audit') {
|
|
59
|
+
return (
|
|
60
|
+
<AuditPage />
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Route: /vault/:vaultId
|
|
65
|
+
const vaultMatch = relativePath.match(/^\/([^/]+)$/)
|
|
66
|
+
if (vaultMatch) {
|
|
67
|
+
const [, vaultId] = vaultMatch
|
|
68
|
+
const vault = vaults.find(v => v.id === vaultId)
|
|
69
|
+
return (
|
|
70
|
+
<EntriesPage
|
|
71
|
+
vaultId={vaultId}
|
|
72
|
+
isOwner={!!vault?.is_owner}
|
|
73
|
+
onNavigate={(to) => {
|
|
74
|
+
if (to === 'vaults') navigate('/')
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Fallback to root
|
|
81
|
+
return <VaultPage onNavigate={(_to, vaultId) => navigate(`/${vaultId}`)} />
|
|
82
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// components/datetime-picker.tsx
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { Calendar as CalendarIcon, Clock } from 'lucide-react'
|
|
4
|
+
import { format } from 'date-fns'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { Calendar } from '@/components/ui/calendar'
|
|
8
|
+
import {
|
|
9
|
+
Popover,
|
|
10
|
+
PopoverContent,
|
|
11
|
+
PopoverTrigger,
|
|
12
|
+
} from '@/components/ui/popover'
|
|
13
|
+
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
14
|
+
|
|
15
|
+
interface DateTimePickerProps {
|
|
16
|
+
value?: Date
|
|
17
|
+
onChange: (date: Date | undefined) => void
|
|
18
|
+
disabled?: boolean
|
|
19
|
+
className?: string
|
|
20
|
+
placeholder?: string
|
|
21
|
+
minDate?: Date
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function DateTimePicker({
|
|
25
|
+
value,
|
|
26
|
+
onChange,
|
|
27
|
+
disabled,
|
|
28
|
+
className,
|
|
29
|
+
placeholder = 'Pick a date and time',
|
|
30
|
+
minDate,
|
|
31
|
+
}: DateTimePickerProps) {
|
|
32
|
+
const [open, setOpen] = useState(false)
|
|
33
|
+
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value)
|
|
34
|
+
const [selectedTime, setSelectedTime] = useState<{
|
|
35
|
+
hour: string
|
|
36
|
+
minute: string
|
|
37
|
+
period: 'AM' | 'PM'
|
|
38
|
+
}>({
|
|
39
|
+
hour: '12',
|
|
40
|
+
minute: '00',
|
|
41
|
+
period: 'AM',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (value) {
|
|
46
|
+
setSelectedDate(value)
|
|
47
|
+
const hours = value.getHours()
|
|
48
|
+
const minutes = value.getMinutes()
|
|
49
|
+
|
|
50
|
+
setSelectedTime({
|
|
51
|
+
hour: hours > 12 ? String(hours - 12).padStart(2, '0') : hours === 0 ? '12' : String(hours).padStart(2, '0'),
|
|
52
|
+
minute: String(minutes).padStart(2, '0'),
|
|
53
|
+
period: hours >= 12 ? 'PM' : 'AM',
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}, [value])
|
|
57
|
+
|
|
58
|
+
const hours = Array.from({ length: 12 }, (_, i) =>
|
|
59
|
+
String(i + 1).padStart(2, '0')
|
|
60
|
+
)
|
|
61
|
+
const minutes = Array.from({ length: 60 }, (_, i) =>
|
|
62
|
+
String(i).padStart(2, '0')
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const handleDateSelect = (date: Date | undefined) => {
|
|
66
|
+
setSelectedDate(date)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isDateDisabled = (date: Date) => {
|
|
70
|
+
if (!minDate) return false
|
|
71
|
+
const compareDate = new Date(date)
|
|
72
|
+
const compareMinDate = new Date(minDate)
|
|
73
|
+
compareDate.setHours(0, 0, 0, 0)
|
|
74
|
+
compareMinDate.setHours(0, 0, 0, 0)
|
|
75
|
+
return compareDate < compareMinDate
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const handleTimeChange = (
|
|
79
|
+
type: 'hour' | 'minute' | 'period',
|
|
80
|
+
newValue: string
|
|
81
|
+
) => {
|
|
82
|
+
setSelectedTime((prev) => ({
|
|
83
|
+
...prev,
|
|
84
|
+
[type]: newValue,
|
|
85
|
+
}))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const handleApply = () => {
|
|
89
|
+
if (selectedDate) {
|
|
90
|
+
const newDate = new Date(selectedDate)
|
|
91
|
+
let hour = parseInt(selectedTime.hour)
|
|
92
|
+
|
|
93
|
+
if (selectedTime.period === 'PM' && hour !== 12) {
|
|
94
|
+
hour += 12
|
|
95
|
+
} else if (selectedTime.period === 'AM' && hour === 12) {
|
|
96
|
+
hour = 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
newDate.setHours(hour)
|
|
100
|
+
newDate.setMinutes(parseInt(selectedTime.minute))
|
|
101
|
+
newDate.setSeconds(0)
|
|
102
|
+
newDate.setMilliseconds(0)
|
|
103
|
+
|
|
104
|
+
onChange(newDate)
|
|
105
|
+
setOpen(false)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const handleClear = () => {
|
|
110
|
+
setSelectedDate(undefined)
|
|
111
|
+
onChange(undefined)
|
|
112
|
+
setSelectedTime({
|
|
113
|
+
hour: '12',
|
|
114
|
+
minute: '00',
|
|
115
|
+
period: 'AM',
|
|
116
|
+
})
|
|
117
|
+
setOpen(false)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
122
|
+
<PopoverTrigger asChild>
|
|
123
|
+
<Button
|
|
124
|
+
variant="outline"
|
|
125
|
+
disabled={disabled}
|
|
126
|
+
className={cn(
|
|
127
|
+
'w-full justify-start text-left font-normal h-11',
|
|
128
|
+
!value && 'text-muted-foreground',
|
|
129
|
+
className
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
133
|
+
{value ? (
|
|
134
|
+
format(value, 'PPP p')
|
|
135
|
+
) : (
|
|
136
|
+
<span>{placeholder}</span>
|
|
137
|
+
)}
|
|
138
|
+
</Button>
|
|
139
|
+
</PopoverTrigger>
|
|
140
|
+
<PopoverContent className="w-auto p-0" align="start" side="bottom" sideOffset={8}>
|
|
141
|
+
<div className="flex flex-col md:flex-row overflow-hidden rounded-md border shadow-xl bg-card">
|
|
142
|
+
{/* Calendar Section */}
|
|
143
|
+
<div className="border-r bg-card">
|
|
144
|
+
<Calendar
|
|
145
|
+
mode="single"
|
|
146
|
+
selected={selectedDate}
|
|
147
|
+
onSelect={handleDateSelect}
|
|
148
|
+
initialFocus
|
|
149
|
+
disabled={isDateDisabled}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Time Section */}
|
|
154
|
+
<div className="flex flex-col p-4 gap-4 bg-card min-w-[240px]">
|
|
155
|
+
<div className="flex items-center gap-2 pb-2 border-b">
|
|
156
|
+
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
157
|
+
<span className="text-sm font-medium">Select Time</span>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div className="flex gap-3 justify-center">
|
|
161
|
+
{/* Hour Picker */}
|
|
162
|
+
<div className="flex flex-col items-center gap-2">
|
|
163
|
+
<label className="text-[10px] font-medium text-muted-foreground">
|
|
164
|
+
Hour
|
|
165
|
+
</label>
|
|
166
|
+
<ScrollArea className="h-40 w-14 rounded-md border bg-muted/5">
|
|
167
|
+
<div className="p-1">
|
|
168
|
+
{hours.map((h) => (
|
|
169
|
+
<button
|
|
170
|
+
key={h}
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={() => handleTimeChange('hour', h)}
|
|
173
|
+
className={cn(
|
|
174
|
+
'w-full rounded px-2 py-1.5 text-xs font-medium hover:bg-primary/10 transition-colors',
|
|
175
|
+
selectedTime.hour === h &&
|
|
176
|
+
'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
{h}
|
|
180
|
+
</button>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
</ScrollArea>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* Minute Picker */}
|
|
187
|
+
<div className="flex flex-col items-center gap-2">
|
|
188
|
+
<label className="text-[10px] font-medium text-muted-foreground ">
|
|
189
|
+
Min
|
|
190
|
+
</label>
|
|
191
|
+
<ScrollArea className="h-40 w-14 rounded-md border bg-muted/5">
|
|
192
|
+
<div className="p-1">
|
|
193
|
+
{minutes.filter(m => parseInt(m) % 5 === 0).map((m) => (
|
|
194
|
+
<button
|
|
195
|
+
key={m}
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={() => handleTimeChange('minute', m)}
|
|
198
|
+
className={cn(
|
|
199
|
+
'w-full rounded px-2 py-1.5 text-xs font-medium hover:bg-primary/10 transition-colors',
|
|
200
|
+
selectedTime.minute === m &&
|
|
201
|
+
'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
{m}
|
|
205
|
+
</button>
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
</ScrollArea>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Period Picker */}
|
|
212
|
+
<div className="flex flex-col items-center gap-2">
|
|
213
|
+
<label className="text-[10px] font-medium text-muted-foreground ">
|
|
214
|
+
|
|
215
|
+
</label>
|
|
216
|
+
<div className="flex flex-col gap-2 pt-1">
|
|
217
|
+
{(['AM', 'PM'] as const).map((p) => (
|
|
218
|
+
<button
|
|
219
|
+
key={p}
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={() => handleTimeChange('period', p)}
|
|
222
|
+
className={cn(
|
|
223
|
+
'w-12 rounded px-2 py-2 text-xs font-mediumhover:bg-primary/10 transition-colors border',
|
|
224
|
+
selectedTime.period === p
|
|
225
|
+
? 'bg-primary text-primary-foreground border-primary shadow-lg shadow-primary/20'
|
|
226
|
+
: 'border-transparent'
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
{p}
|
|
230
|
+
</button>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* Action Buttons */}
|
|
237
|
+
<div className="flex gap-2 pt-4 border-t mt-auto">
|
|
238
|
+
<Button
|
|
239
|
+
type="button"
|
|
240
|
+
variant="outline"
|
|
241
|
+
size="sm"
|
|
242
|
+
onClick={handleClear}
|
|
243
|
+
className="flex-1 rounded h-9 font-semibold"
|
|
244
|
+
>
|
|
245
|
+
Clear
|
|
246
|
+
</Button>
|
|
247
|
+
<Button
|
|
248
|
+
type="button"
|
|
249
|
+
size="sm"
|
|
250
|
+
onClick={handleApply}
|
|
251
|
+
disabled={!selectedDate}
|
|
252
|
+
className="flex-1 rounded h-9 font-mediumbg-primary"
|
|
253
|
+
>
|
|
254
|
+
Apply
|
|
255
|
+
</Button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
</PopoverContent>
|
|
260
|
+
</Popover>
|
|
261
|
+
)
|
|
262
|
+
}
|