@prenta/admin 1.17.0 → 1.17.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/__tests__/lib/seo-service-errors.test.js +2 -6
  3. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  4. package/dist/lib/seo-service.d.ts +0 -5
  5. package/dist/lib/seo-service.d.ts.map +1 -1
  6. package/dist/lib/seo-service.js +0 -11
  7. package/dist/lib/seo-service.js.map +1 -1
  8. package/dist/prenta-admin.css +1 -1
  9. package/package.json +5 -5
  10. package/src/__tests__/lib/seo-service-errors.test.ts +1 -9
  11. package/src/lib/seo-service.ts +0 -14
  12. package/dist/components/ContentOverviewChart.d.ts +0 -8
  13. package/dist/components/ContentOverviewChart.d.ts.map +0 -1
  14. package/dist/components/ContentOverviewChart.js +0 -20
  15. package/dist/components/ContentOverviewChart.js.map +0 -1
  16. package/dist/components/SEOPerformance.d.ts +0 -5
  17. package/dist/components/SEOPerformance.d.ts.map +0 -1
  18. package/dist/components/SEOPerformance.js +0 -29
  19. package/dist/components/SEOPerformance.js.map +0 -1
  20. package/dist/components/ui/Avatar.d.ts +0 -9
  21. package/dist/components/ui/Avatar.d.ts.map +0 -1
  22. package/dist/components/ui/Avatar.js +0 -21
  23. package/dist/components/ui/Avatar.js.map +0 -1
  24. package/dist/components/ui/CommandPalette.d.ts +0 -14
  25. package/dist/components/ui/CommandPalette.d.ts.map +0 -1
  26. package/dist/components/ui/CommandPalette.js +0 -58
  27. package/dist/components/ui/CommandPalette.js.map +0 -1
  28. package/dist/components/ui/DataTable.d.ts +0 -30
  29. package/dist/components/ui/DataTable.d.ts.map +0 -1
  30. package/dist/components/ui/DataTable.js +0 -44
  31. package/dist/components/ui/DataTable.js.map +0 -1
  32. package/dist/components/ui/Pagination.d.ts +0 -9
  33. package/dist/components/ui/Pagination.d.ts.map +0 -1
  34. package/dist/components/ui/Pagination.js +0 -26
  35. package/dist/components/ui/Pagination.js.map +0 -1
  36. package/dist/components/ui/Select.d.ts +0 -16
  37. package/dist/components/ui/Select.d.ts.map +0 -1
  38. package/dist/components/ui/Select.js +0 -24
  39. package/dist/components/ui/Select.js.map +0 -1
  40. package/dist/components/ui/Toast.d.ts +0 -18
  41. package/dist/components/ui/Toast.d.ts.map +0 -1
  42. package/dist/components/ui/Toast.js +0 -29
  43. package/dist/components/ui/Toast.js.map +0 -1
  44. package/dist/components/ui/index.d.ts +0 -46
  45. package/dist/components/ui/index.d.ts.map +0 -1
  46. package/dist/components/ui/index.js +0 -24
  47. package/dist/components/ui/index.js.map +0 -1
  48. package/dist/hooks/useContentLock.d.ts +0 -12
  49. package/dist/hooks/useContentLock.d.ts.map +0 -1
  50. package/dist/hooks/useContentLock.js +0 -38
  51. package/dist/hooks/useContentLock.js.map +0 -1
  52. package/dist/hooks/useDebounce.d.ts +0 -2
  53. package/dist/hooks/useDebounce.d.ts.map +0 -1
  54. package/dist/hooks/useDebounce.js +0 -11
  55. package/dist/hooks/useDebounce.js.map +0 -1
  56. package/src/components/ContentOverviewChart.tsx +0 -75
  57. package/src/components/SEOPerformance.tsx +0 -163
  58. package/src/components/ui/Avatar.tsx +0 -42
  59. package/src/components/ui/CommandPalette.tsx +0 -133
  60. package/src/components/ui/DataTable.tsx +0 -204
  61. package/src/components/ui/Pagination.tsx +0 -94
  62. package/src/components/ui/Select.tsx +0 -53
  63. package/src/components/ui/Toast.tsx +0 -72
  64. package/src/components/ui/index.ts +0 -61
  65. package/src/hooks/useContentLock.ts +0 -55
  66. package/src/hooks/useDebounce.ts +0 -14
@@ -1,12 +0,0 @@
1
- interface ContentLock {
2
- lockedBy: string | null;
3
- lockedAt: Date | null;
4
- isLocked: boolean;
5
- isLockedByMe: boolean;
6
- }
7
- export declare function useContentLock(documentId: string | undefined, userId: string): ContentLock & {
8
- acquireLock: () => Promise<boolean>;
9
- releaseLock: () => Promise<void>;
10
- };
11
- export {};
12
- //# sourceMappingURL=useContentLock.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useContentLock.d.ts","sourceRoot":"","sources":["../../src/hooks/useContentLock.ts"],"names":[],"mappings":"AAIA,UAAU,WAAW;IACnB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAA;IACrB,QAAQ,EAAE,OAAO,CAAA;IACjB,YAAY,EAAE,OAAO,CAAA;CACtB;AAED,wBAAgB,cAAc,CAC5B,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,GACb,WAAW,GAAG;IACf,WAAW,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;IACnC,WAAW,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CACjC,CAqCA"}
@@ -1,38 +0,0 @@
1
- 'use client';
2
- import { useState, useEffect, useCallback } from 'react';
3
- export function useContentLock(documentId, userId) {
4
- const [lock, setLock] = useState({
5
- lockedBy: null,
6
- lockedAt: null,
7
- isLocked: false,
8
- isLockedByMe: false,
9
- });
10
- const acquireLock = useCallback(async () => {
11
- if (!documentId)
12
- return false;
13
- setLock({
14
- lockedBy: userId,
15
- lockedAt: new Date(),
16
- isLocked: true,
17
- isLockedByMe: true,
18
- });
19
- return true;
20
- }, [documentId, userId]);
21
- const releaseLock = useCallback(async () => {
22
- setLock({
23
- lockedBy: null,
24
- lockedAt: null,
25
- isLocked: false,
26
- isLockedByMe: false,
27
- });
28
- }, []);
29
- useEffect(() => {
30
- return () => {
31
- if (lock.isLockedByMe) {
32
- releaseLock();
33
- }
34
- };
35
- }, [lock.isLockedByMe, releaseLock]);
36
- return { ...lock, acquireLock, releaseLock };
37
- }
38
- //# sourceMappingURL=useContentLock.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useContentLock.js","sourceRoot":"","sources":["../../src/hooks/useContentLock.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;AASxD,MAAM,UAAU,cAAc,CAC5B,UAA8B,EAC9B,MAAc;IAKd,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAc;QAC5C,QAAQ,EAAE,IAAI;QACd,QAAQ,EAAE,IAAI;QACd,QAAQ,EAAE,KAAK;QACf,YAAY,EAAE,KAAK;KACpB,CAAC,CAAA;IAEF,MAAM,WAAW,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QACzC,IAAI,CAAC,UAAU;YAAE,OAAO,KAAK,CAAA;QAC7B,OAAO,CAAC;YACN,QAAQ,EAAE,MAAM;YAChB,QAAQ,EAAE,IAAI,IAAI,EAAE;YACpB,QAAQ,EAAE,IAAI;YACd,YAAY,EAAE,IAAI;SACnB,CAAC,CAAA;QACF,OAAO,IAAI,CAAA;IACb,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAA;IAExB,MAAM,WAAW,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QACzC,OAAO,CAAC;YACN,QAAQ,EAAE,IAAI;YACd,QAAQ,EAAE,IAAI;YACd,QAAQ,EAAE,KAAK;YACf,YAAY,EAAE,KAAK;SACpB,CAAC,CAAA;IACJ,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,SAAS,CAAC,GAAG,EAAE;QACb,OAAO,GAAG,EAAE;YACV,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;gBACtB,WAAW,EAAE,CAAA;YACf,CAAC;QACH,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,IAAI,CAAC,YAAY,EAAE,WAAW,CAAC,CAAC,CAAA;IAEpC,OAAO,EAAE,GAAG,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,CAAA;AAC9C,CAAC"}
@@ -1,2 +0,0 @@
1
- export declare function useDebounce<T>(value: T, delayMs: number): T;
2
- //# sourceMappingURL=useDebounce.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useDebounce.d.ts","sourceRoot":"","sources":["../../src/hooks/useDebounce.ts"],"names":[],"mappings":"AAIA,wBAAgB,WAAW,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,CAAC,CAS3D"}
@@ -1,11 +0,0 @@
1
- 'use client';
2
- import { useState, useEffect } from 'react';
3
- export function useDebounce(value, delayMs) {
4
- const [debouncedValue, setDebouncedValue] = useState(value);
5
- useEffect(() => {
6
- const timer = setTimeout(() => setDebouncedValue(value), delayMs);
7
- return () => clearTimeout(timer);
8
- }, [value, delayMs]);
9
- return debouncedValue;
10
- }
11
- //# sourceMappingURL=useDebounce.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useDebounce.js","sourceRoot":"","sources":["../../src/hooks/useDebounce.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAE3C,MAAM,UAAU,WAAW,CAAI,KAAQ,EAAE,OAAe;IACtD,MAAM,CAAC,cAAc,EAAE,iBAAiB,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAE3D,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,iBAAiB,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,CAAA;QACjE,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAA;IAClC,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,CAAA;IAEpB,OAAO,cAAc,CAAA;AACvB,CAAC"}
@@ -1,75 +0,0 @@
1
- 'use client'
2
-
3
- import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
4
-
5
- interface ContentOverviewChartProps {
6
- published: number
7
- drafts: number
8
- scheduled: number
9
- }
10
-
11
- const COLORS = ['#22c55e', '#f59e0b', '#a855f7']
12
-
13
- export function ContentOverviewChart({ published, drafts, scheduled }: ContentOverviewChartProps) {
14
- const total = published + drafts + scheduled
15
- const data = [
16
- { name: 'Published', value: published },
17
- { name: 'Drafts', value: drafts },
18
- { name: 'Scheduled', value: scheduled },
19
- ]
20
-
21
- if (total === 0) {
22
- return (
23
- <div className="text-muted-foreground flex h-full items-center justify-center text-sm">
24
- No content yet
25
- </div>
26
- )
27
- }
28
-
29
- return (
30
- <div className="flex items-center gap-6">
31
- <div className="h-36 w-36 shrink-0">
32
- <ResponsiveContainer width="100%" height="100%">
33
- <PieChart>
34
- <Pie
35
- data={data}
36
- cx="50%"
37
- cy="50%"
38
- innerRadius={38}
39
- outerRadius={65}
40
- dataKey="value"
41
- strokeWidth={2}
42
- stroke="#fff"
43
- >
44
- {data.map((_, idx) => (
45
- <Cell key={idx} fill={COLORS[idx]} />
46
- ))}
47
- </Pie>
48
- </PieChart>
49
- </ResponsiveContainer>
50
- </div>
51
- <div className="space-y-3">
52
- {data.map((entry, idx) => {
53
- const pct = total > 0 ? Math.round((entry.value / total) * 100) : 0
54
- return (
55
- <div key={entry.name} className="flex items-center gap-3">
56
- <span
57
- className="h-2.5 w-2.5 shrink-0 rounded-full"
58
- style={{ backgroundColor: COLORS[idx] }}
59
- />
60
- <div className="min-w-0">
61
- <span className="text-foreground text-sm">{entry.name}</span>
62
- <div className="flex items-baseline gap-2">
63
- <span className="text-foreground text-lg font-medium">
64
- {entry.value.toLocaleString()}
65
- </span>
66
- <span className="text-muted-foreground text-xs">{pct}%</span>
67
- </div>
68
- </div>
69
- </div>
70
- )
71
- })}
72
- </div>
73
- </div>
74
- )
75
- }
@@ -1,163 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- Search,
5
- AlertTriangle,
6
- FileWarning,
7
- ImageOff,
8
- LinkIcon,
9
- ChevronLeft,
10
- ChevronRight,
11
- } from 'lucide-react'
12
- import { useState } from 'react'
13
- import { gradeFromScore, gradeMeta } from './seo/primitives.js'
14
- import { useApiData } from '../lib/useApiData.js'
15
-
16
- interface SEOSummary {
17
- totalPages: number
18
- issuesSummary: {
19
- missingMetaDescriptions: number
20
- brokenInternalLinks: number
21
- missingAltText: number
22
- }
23
- topContent: { id: string; title: string; collection: string; score: number }[]
24
- }
25
-
26
- /**
27
- * The bands and their colours both come from the SEO primitives — this used to
28
- * restate 80/60 twice on adjacent lines, and `gradeMeta` already returns
29
- * exactly the token classes it was hand-writing.
30
- */
31
- function ScoreBadge({ score }: { score: number }) {
32
- const { text: color, bg } = gradeMeta(gradeFromScore(score))
33
- return (
34
- <span
35
- className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium ${color} ${bg}`}
36
- >
37
- {score}
38
- </span>
39
- )
40
- }
41
-
42
- export interface SEOPerformanceProps {
43
- onNavigate?: (path: string) => void
44
- }
45
-
46
- export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
47
- const { data, loading, error } = useApiData<SEOSummary>('/seo/summary')
48
- const [page, setPage] = useState(0)
49
- const perPage = 5
50
-
51
- if (loading || error || !data) return null
52
-
53
- const issues = data.issuesSummary
54
- const totalIssues =
55
- issues.missingMetaDescriptions + issues.brokenInternalLinks + issues.missingAltText
56
- const topContent = data.topContent ?? []
57
- const totalPages = Math.max(1, Math.ceil(topContent.length / perPage))
58
- const visible = topContent.slice(page * perPage, (page + 1) * perPage)
59
-
60
- return (
61
- <div className="border-border bg-card rounded-lg border">
62
- <div className="border-border flex items-center justify-between border-b p-4">
63
- <div className="flex items-center gap-2">
64
- <Search className="text-muted-foreground h-4 w-4" />
65
- <h2 className="text-foreground text-sm font-medium">SEO Performance</h2>
66
- </div>
67
- {totalIssues > 0 && (
68
- <span className="text-muted-foreground text-xs">
69
- {totalIssues} issue{totalIssues !== 1 ? 's' : ''} found
70
- </span>
71
- )}
72
- </div>
73
-
74
- <div className="divide-border grid grid-cols-1 divide-y lg:grid-cols-12 lg:divide-x lg:divide-y-0">
75
- <div className="p-4 lg:col-span-7">
76
- <h3 className="text-foreground mb-3 text-sm font-medium">Top Performing Content</h3>
77
- <div className="space-y-2">
78
- {visible.map((item) => (
79
- <div key={item.id} className="flex items-center justify-between py-2">
80
- <div className="mr-3 min-w-0 flex-1">
81
- <p className="text-foreground truncate text-sm font-medium">{item.title}</p>
82
- <p className="text-muted-foreground text-xs capitalize">{item.collection}</p>
83
- </div>
84
- <ScoreBadge score={item.score} />
85
- </div>
86
- ))}
87
- {visible.length === 0 && (
88
- <p className="text-muted-foreground py-4 text-center text-sm">
89
- No published content yet
90
- </p>
91
- )}
92
- </div>
93
- {topContent.length > perPage && (
94
- <div className="text-muted-foreground mt-3 flex items-center justify-between border-t border-gray-100 pt-3 text-xs">
95
- <span>
96
- Showing {page * perPage + 1}-{Math.min((page + 1) * perPage, topContent.length)} of{' '}
97
- {topContent.length}
98
- </span>
99
- <div className="flex items-center gap-1">
100
- <button
101
- onClick={() => setPage(Math.max(0, page - 1))}
102
- disabled={page === 0}
103
- className="hover:bg-accent rounded p-1 disabled:opacity-30"
104
- >
105
- <ChevronLeft className="h-3.5 w-3.5" />
106
- </button>
107
- <span>
108
- Page {page + 1} of {totalPages}
109
- </span>
110
- <button
111
- onClick={() => setPage(Math.min(totalPages - 1, page + 1))}
112
- disabled={page >= totalPages - 1}
113
- className="hover:bg-accent rounded p-1 disabled:opacity-30"
114
- >
115
- <ChevronRight className="h-3.5 w-3.5" />
116
- </button>
117
- </div>
118
- </div>
119
- )}
120
- </div>
121
-
122
- <div className="p-4 lg:col-span-5">
123
- <h3 className="text-foreground mb-3 text-sm font-medium">SEO Issues</h3>
124
- <div className="space-y-3">
125
- <div className="flex items-center justify-between">
126
- <div className="flex items-center gap-2">
127
- <FileWarning className="h-4 w-4 text-red-400" />
128
- <span className="text-foreground text-sm">Missing meta descriptions</span>
129
- </div>
130
- <span className="text-foreground text-sm font-medium">
131
- {issues.missingMetaDescriptions}
132
- </span>
133
- </div>
134
- <div className="flex items-center justify-between">
135
- <div className="flex items-center gap-2">
136
- <LinkIcon className="h-4 w-4 text-red-400" />
137
- <span className="text-foreground text-sm">Broken internal links</span>
138
- </div>
139
- <span className="text-foreground text-sm font-medium">
140
- {issues.brokenInternalLinks}
141
- </span>
142
- </div>
143
- <div className="flex items-center justify-between">
144
- <div className="flex items-center gap-2">
145
- <ImageOff className="h-4 w-4 text-red-400" />
146
- <span className="text-foreground text-sm">Missing alt text</span>
147
- </div>
148
- <span className="text-foreground text-sm font-medium">{issues.missingAltText}</span>
149
- </div>
150
- </div>
151
- {totalIssues > 0 && (
152
- <button
153
- onClick={() => onNavigate?.('/seo')}
154
- className="text-primary hover:text-primary/80 mt-4 text-sm font-medium"
155
- >
156
- View All Issues
157
- </button>
158
- )}
159
- </div>
160
- </div>
161
- </div>
162
- )
163
- }
@@ -1,42 +0,0 @@
1
- type AvatarSize = 'sm' | 'md' | 'lg'
2
-
3
- export interface AvatarProps {
4
- src?: string | null
5
- name?: string
6
- size?: AvatarSize
7
- }
8
-
9
- const sizeClasses: Record<AvatarSize, string> = {
10
- sm: 'h-6 w-6 text-[10px]',
11
- md: 'h-9 w-9 text-xs',
12
- lg: 'h-12 w-12 text-sm',
13
- }
14
-
15
- function getInitials(name: string): string {
16
- return name
17
- .split(' ')
18
- .map((part) => part.charAt(0))
19
- .join('')
20
- .toUpperCase()
21
- .slice(0, 2)
22
- }
23
-
24
- export function Avatar({ src, name = '', size = 'md' }: AvatarProps) {
25
- if (src) {
26
- return (
27
- <img
28
- src={src}
29
- alt={name}
30
- className={`inline-block shrink-0 rounded-full object-cover ${sizeClasses[size]}`}
31
- />
32
- )
33
- }
34
-
35
- return (
36
- <span
37
- className={`inline-flex shrink-0 items-center justify-center rounded-full bg-[var(--primary)] font-medium text-[var(--primary-foreground)] ${sizeClasses[size]}`}
38
- >
39
- {getInitials(name) || '?'}
40
- </span>
41
- )
42
- }
@@ -1,133 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useEffect, useRef } from 'react'
4
-
5
- interface CommandItem {
6
- id: string
7
- label: string
8
- group: string
9
- onSelect: () => void
10
- }
11
-
12
- export interface CommandPaletteProps {
13
- open: boolean
14
- onClose: () => void
15
- items: CommandItem[]
16
- }
17
-
18
- export function CommandPalette({ open, onClose, items }: CommandPaletteProps) {
19
- const [query, setQuery] = useState('')
20
- const [activeIndex, setActiveIndex] = useState(0)
21
- const inputRef = useRef<HTMLInputElement>(null)
22
-
23
- const filtered = items.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()))
24
-
25
- const groups = Array.from(new Set(filtered.map((i) => i.group)))
26
-
27
- useEffect(() => {
28
- if (open) {
29
- setQuery('')
30
- setActiveIndex(0)
31
- requestAnimationFrame(() => inputRef.current?.focus())
32
- }
33
- }, [open])
34
-
35
- useEffect(() => {
36
- if (!open) return
37
-
38
- function handleKey(e: KeyboardEvent) {
39
- if (e.key === 'Escape') {
40
- onClose()
41
- return
42
- }
43
- if (e.key === 'ArrowDown') {
44
- e.preventDefault()
45
- setActiveIndex((i) => Math.min(i + 1, filtered.length - 1))
46
- }
47
- if (e.key === 'ArrowUp') {
48
- e.preventDefault()
49
- setActiveIndex((i) => Math.max(i - 1, 0))
50
- }
51
- if (e.key === 'Enter' && filtered[activeIndex]) {
52
- filtered[activeIndex].onSelect()
53
- onClose()
54
- }
55
- }
56
-
57
- document.addEventListener('keydown', handleKey)
58
- return () => document.removeEventListener('keydown', handleKey)
59
- }, [open, onClose, filtered, activeIndex])
60
-
61
- if (!open) return null
62
-
63
- let flatIndex = -1
64
-
65
- return (
66
- <div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh]">
67
- <div className="fixed inset-0 bg-black/50" onClick={onClose} role="presentation" />
68
- <div className="relative z-10 w-full max-w-lg overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--popover)] shadow-2xl">
69
- <div className="flex items-center border-b border-[var(--border)] px-4">
70
- <svg
71
- className="mr-2 h-4 w-4 text-[var(--muted-foreground)]"
72
- fill="none"
73
- viewBox="0 0 24 24"
74
- stroke="currentColor"
75
- strokeWidth={2}
76
- >
77
- <path
78
- strokeLinecap="round"
79
- strokeLinejoin="round"
80
- d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
81
- />
82
- </svg>
83
- <input
84
- ref={inputRef}
85
- type="text"
86
- value={query}
87
- onChange={(e) => {
88
- setQuery(e.target.value)
89
- setActiveIndex(0)
90
- }}
91
- placeholder="Type a command or search..."
92
- className="flex-1 bg-transparent py-3 text-sm outline-none"
93
- />
94
- </div>
95
-
96
- <div className="max-h-72 overflow-y-auto py-2">
97
- {groups.map((group) => (
98
- <div key={group}>
99
- <div className="px-4 py-1.5 text-xs font-medium text-[var(--muted-foreground)]">
100
- {group}
101
- </div>
102
- {filtered
103
- .filter((i) => i.group === group)
104
- .map((item) => {
105
- flatIndex++
106
- const idx = flatIndex
107
- return (
108
- <button
109
- key={item.id}
110
- onClick={() => {
111
- item.onSelect()
112
- onClose()
113
- }}
114
- className={`flex w-full px-4 py-2 text-sm ${
115
- idx === activeIndex ? 'bg-[var(--accent)]' : ''
116
- }`}
117
- >
118
- {item.label}
119
- </button>
120
- )
121
- })}
122
- </div>
123
- ))}
124
- {filtered.length === 0 && (
125
- <p className="px-4 py-6 text-center text-sm text-[var(--muted-foreground)]">
126
- No results found.
127
- </p>
128
- )}
129
- </div>
130
- </div>
131
- </div>
132
- )
133
- }