@prenta/admin 1.16.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.
- package/CHANGELOG.md +18 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +5 -6
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
- package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
- package/dist/components/seo/FaqProposalPreview.js +62 -0
- package/dist/components/seo/FaqProposalPreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +7 -2
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/lib/seo-service.d.ts +61 -7
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +9 -13
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +160 -7
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.js +6 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
- package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
- package/src/__tests__/lib/seo-service-errors.test.ts +5 -9
- package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
- package/src/components/seo/FaqProposalPreview.tsx +134 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
- package/src/lib/seo-service.ts +57 -15
- package/src/views/seo/ProposalsTab.tsx +279 -13
- package/src/views/settings/SeoAutopilotCard.tsx +6 -0
- package/dist/components/ContentOverviewChart.d.ts +0 -8
- package/dist/components/ContentOverviewChart.d.ts.map +0 -1
- package/dist/components/ContentOverviewChart.js +0 -20
- package/dist/components/ContentOverviewChart.js.map +0 -1
- package/dist/components/SEOPerformance.d.ts +0 -5
- package/dist/components/SEOPerformance.d.ts.map +0 -1
- package/dist/components/SEOPerformance.js +0 -29
- package/dist/components/SEOPerformance.js.map +0 -1
- package/dist/components/ui/Avatar.d.ts +0 -9
- package/dist/components/ui/Avatar.d.ts.map +0 -1
- package/dist/components/ui/Avatar.js +0 -21
- package/dist/components/ui/Avatar.js.map +0 -1
- package/dist/components/ui/CommandPalette.d.ts +0 -14
- package/dist/components/ui/CommandPalette.d.ts.map +0 -1
- package/dist/components/ui/CommandPalette.js +0 -58
- package/dist/components/ui/CommandPalette.js.map +0 -1
- package/dist/components/ui/DataTable.d.ts +0 -30
- package/dist/components/ui/DataTable.d.ts.map +0 -1
- package/dist/components/ui/DataTable.js +0 -44
- package/dist/components/ui/DataTable.js.map +0 -1
- package/dist/components/ui/Pagination.d.ts +0 -9
- package/dist/components/ui/Pagination.d.ts.map +0 -1
- package/dist/components/ui/Pagination.js +0 -26
- package/dist/components/ui/Pagination.js.map +0 -1
- package/dist/components/ui/Select.d.ts +0 -16
- package/dist/components/ui/Select.d.ts.map +0 -1
- package/dist/components/ui/Select.js +0 -24
- package/dist/components/ui/Select.js.map +0 -1
- package/dist/components/ui/Toast.d.ts +0 -18
- package/dist/components/ui/Toast.d.ts.map +0 -1
- package/dist/components/ui/Toast.js +0 -29
- package/dist/components/ui/Toast.js.map +0 -1
- package/dist/components/ui/index.d.ts +0 -46
- package/dist/components/ui/index.d.ts.map +0 -1
- package/dist/components/ui/index.js +0 -24
- package/dist/components/ui/index.js.map +0 -1
- package/dist/hooks/useContentLock.d.ts +0 -12
- package/dist/hooks/useContentLock.d.ts.map +0 -1
- package/dist/hooks/useContentLock.js +0 -38
- package/dist/hooks/useContentLock.js.map +0 -1
- package/dist/hooks/useDebounce.d.ts +0 -2
- package/dist/hooks/useDebounce.d.ts.map +0 -1
- package/dist/hooks/useDebounce.js +0 -11
- package/dist/hooks/useDebounce.js.map +0 -1
- package/src/components/ContentOverviewChart.tsx +0 -75
- package/src/components/SEOPerformance.tsx +0 -163
- package/src/components/ui/Avatar.tsx +0 -42
- package/src/components/ui/CommandPalette.tsx +0 -133
- package/src/components/ui/DataTable.tsx +0 -204
- package/src/components/ui/Pagination.tsx +0 -94
- package/src/components/ui/Select.tsx +0 -53
- package/src/components/ui/Toast.tsx +0 -72
- package/src/components/ui/index.ts +0 -61
- package/src/hooks/useContentLock.ts +0 -55
- package/src/hooks/useDebounce.ts +0 -14
|
@@ -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
|
-
}
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { useState, useRef, useEffect } from 'react'
|
|
4
|
-
|
|
5
|
-
interface Column {
|
|
6
|
-
key: string
|
|
7
|
-
label: string
|
|
8
|
-
sortable?: boolean
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface Row {
|
|
12
|
-
id: string
|
|
13
|
-
[key: string]: any
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface RowAction {
|
|
17
|
-
key: string
|
|
18
|
-
label: string
|
|
19
|
-
icon?: React.ReactNode
|
|
20
|
-
destructive?: boolean
|
|
21
|
-
onClick: (row: Row) => void
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface DataTableProps {
|
|
25
|
-
columns: Column[]
|
|
26
|
-
rows: Row[]
|
|
27
|
-
selectedIds: string[]
|
|
28
|
-
onSelectionChange: (ids: string[]) => void
|
|
29
|
-
sortField?: string
|
|
30
|
-
sortDir?: 'asc' | 'desc'
|
|
31
|
-
onSort?: (field: string) => void
|
|
32
|
-
rowActions?: RowAction[]
|
|
33
|
-
onRowClick?: (row: Row) => void
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function DataTable({
|
|
37
|
-
columns,
|
|
38
|
-
rows,
|
|
39
|
-
selectedIds,
|
|
40
|
-
onSelectionChange,
|
|
41
|
-
sortField,
|
|
42
|
-
sortDir,
|
|
43
|
-
onSort,
|
|
44
|
-
rowActions,
|
|
45
|
-
onRowClick,
|
|
46
|
-
}: DataTableProps) {
|
|
47
|
-
const allSelected = rows.length > 0 && selectedIds.length === rows.length
|
|
48
|
-
|
|
49
|
-
function toggleAll() {
|
|
50
|
-
onSelectionChange(allSelected ? [] : rows.map((r) => r.id))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function toggleRow(id: string) {
|
|
54
|
-
onSelectionChange(
|
|
55
|
-
selectedIds.includes(id) ? selectedIds.filter((s) => s !== id) : [...selectedIds, id],
|
|
56
|
-
)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
|
61
|
-
<table className="w-full text-left text-sm">
|
|
62
|
-
<thead className="bg-muted border-b border-[var(--border)]">
|
|
63
|
-
<tr>
|
|
64
|
-
<th className="w-10 px-4 py-3">
|
|
65
|
-
<input
|
|
66
|
-
type="checkbox"
|
|
67
|
-
checked={allSelected}
|
|
68
|
-
onChange={toggleAll}
|
|
69
|
-
className="rounded border-[var(--border)]"
|
|
70
|
-
/>
|
|
71
|
-
</th>
|
|
72
|
-
{columns.map((col) => (
|
|
73
|
-
<th
|
|
74
|
-
key={col.key}
|
|
75
|
-
className="px-4 py-3 font-medium"
|
|
76
|
-
aria-sort={
|
|
77
|
-
col.sortable && onSort
|
|
78
|
-
? sortField === col.key
|
|
79
|
-
? sortDir === 'asc'
|
|
80
|
-
? 'ascending'
|
|
81
|
-
: 'descending'
|
|
82
|
-
: 'none'
|
|
83
|
-
: undefined
|
|
84
|
-
}
|
|
85
|
-
>
|
|
86
|
-
{col.sortable && onSort ? (
|
|
87
|
-
<button
|
|
88
|
-
onClick={() => onSort(col.key)}
|
|
89
|
-
className="inline-flex items-center gap-1 hover:text-[var(--foreground)]"
|
|
90
|
-
>
|
|
91
|
-
{col.label}
|
|
92
|
-
{sortField === col.key && (
|
|
93
|
-
<span className="text-xs">{sortDir === 'asc' ? '↑' : '↓'}</span>
|
|
94
|
-
)}
|
|
95
|
-
</button>
|
|
96
|
-
) : (
|
|
97
|
-
col.label
|
|
98
|
-
)}
|
|
99
|
-
</th>
|
|
100
|
-
))}
|
|
101
|
-
{rowActions && rowActions.length > 0 && <th className="w-20 px-4 py-3" />}
|
|
102
|
-
</tr>
|
|
103
|
-
</thead>
|
|
104
|
-
<tbody className="divide-y divide-[var(--border)]">
|
|
105
|
-
{rows.map((row) => (
|
|
106
|
-
<tr
|
|
107
|
-
key={row.id}
|
|
108
|
-
className={`hover:bg-[var(--accent)] ${selectedIds.includes(row.id) ? 'bg-[var(--accent)]' : ''}`}
|
|
109
|
-
onClick={() => onRowClick?.(row)}
|
|
110
|
-
style={onRowClick ? { cursor: 'pointer' } : undefined}
|
|
111
|
-
>
|
|
112
|
-
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
113
|
-
<input
|
|
114
|
-
type="checkbox"
|
|
115
|
-
checked={selectedIds.includes(row.id)}
|
|
116
|
-
onChange={() => toggleRow(row.id)}
|
|
117
|
-
className="rounded border-[var(--border)]"
|
|
118
|
-
/>
|
|
119
|
-
</td>
|
|
120
|
-
{columns.map((col) => (
|
|
121
|
-
<td key={col.key} className="px-4 py-3">
|
|
122
|
-
{row[col.key]}
|
|
123
|
-
</td>
|
|
124
|
-
))}
|
|
125
|
-
{rowActions && rowActions.length > 0 && (
|
|
126
|
-
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
127
|
-
<RowActionsMenu actions={rowActions} row={row} />
|
|
128
|
-
</td>
|
|
129
|
-
)}
|
|
130
|
-
</tr>
|
|
131
|
-
))}
|
|
132
|
-
</tbody>
|
|
133
|
-
</table>
|
|
134
|
-
</div>
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function RowActionsMenu({ actions, row }: { actions: RowAction[]; row: Row }) {
|
|
139
|
-
const [open, setOpen] = useState(false)
|
|
140
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
141
|
-
|
|
142
|
-
useEffect(() => {
|
|
143
|
-
if (!open) return
|
|
144
|
-
function handleClickOutside(e: MouseEvent) {
|
|
145
|
-
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
146
|
-
setOpen(false)
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
document.addEventListener('mousedown', handleClickOutside)
|
|
150
|
-
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
151
|
-
}, [open])
|
|
152
|
-
|
|
153
|
-
return (
|
|
154
|
-
<div className="relative flex items-center justify-end gap-1" ref={ref}>
|
|
155
|
-
{actions
|
|
156
|
-
.filter((a) => a.icon)
|
|
157
|
-
.map((action) => (
|
|
158
|
-
<button
|
|
159
|
-
key={action.key}
|
|
160
|
-
onClick={() => action.onClick(row)}
|
|
161
|
-
className="hover:bg-muted rounded p-1 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
162
|
-
aria-label={action.label}
|
|
163
|
-
title={action.label}
|
|
164
|
-
>
|
|
165
|
-
{action.icon}
|
|
166
|
-
</button>
|
|
167
|
-
))}
|
|
168
|
-
<button
|
|
169
|
-
onClick={() => setOpen((o) => !o)}
|
|
170
|
-
className="hover:bg-muted rounded p-1"
|
|
171
|
-
aria-label="More actions"
|
|
172
|
-
>
|
|
173
|
-
<MoreVerticalIcon />
|
|
174
|
-
</button>
|
|
175
|
-
{open && (
|
|
176
|
-
<div className="absolute top-full right-0 z-50 mt-1 w-40 rounded-md border border-[var(--border)] bg-[var(--popover)] py-1 shadow-lg">
|
|
177
|
-
{actions.map((action) => (
|
|
178
|
-
<button
|
|
179
|
-
key={action.key}
|
|
180
|
-
className={`flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-[var(--accent)] ${
|
|
181
|
-
action.destructive ? 'text-[var(--destructive)]' : ''
|
|
182
|
-
}`}
|
|
183
|
-
onClick={() => {
|
|
184
|
-
action.onClick(row)
|
|
185
|
-
setOpen(false)
|
|
186
|
-
}}
|
|
187
|
-
>
|
|
188
|
-
{action.icon && <span className="h-4 w-4">{action.icon}</span>}
|
|
189
|
-
{action.label}
|
|
190
|
-
</button>
|
|
191
|
-
))}
|
|
192
|
-
</div>
|
|
193
|
-
)}
|
|
194
|
-
</div>
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function MoreVerticalIcon() {
|
|
199
|
-
return (
|
|
200
|
-
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
201
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01" />
|
|
202
|
-
</svg>
|
|
203
|
-
)
|
|
204
|
-
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
export interface PaginationProps {
|
|
4
|
-
page: number
|
|
5
|
-
perPage: number
|
|
6
|
-
total: number
|
|
7
|
-
onPageChange: (page: number) => void
|
|
8
|
-
onPerPageChange: (perPage: number) => void
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function Pagination({
|
|
12
|
-
page,
|
|
13
|
-
perPage,
|
|
14
|
-
total,
|
|
15
|
-
onPageChange,
|
|
16
|
-
onPerPageChange,
|
|
17
|
-
}: PaginationProps) {
|
|
18
|
-
const totalPages = Math.max(1, Math.ceil(total / perPage))
|
|
19
|
-
const start = (page - 1) * perPage + 1
|
|
20
|
-
const end = Math.min(page * perPage, total)
|
|
21
|
-
|
|
22
|
-
function pageNumbers(): (number | 'ellipsis')[] {
|
|
23
|
-
const pages: (number | 'ellipsis')[] = []
|
|
24
|
-
for (let i = 1; i <= totalPages; i++) {
|
|
25
|
-
if (i === 1 || i === totalPages || Math.abs(i - page) <= 1) {
|
|
26
|
-
pages.push(i)
|
|
27
|
-
} else if (pages[pages.length - 1] !== 'ellipsis') {
|
|
28
|
-
pages.push('ellipsis')
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return pages
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<div className="flex items-center justify-between text-sm">
|
|
36
|
-
<span className="text-[var(--muted-foreground)]">
|
|
37
|
-
{start}–{end} of {total} results
|
|
38
|
-
</span>
|
|
39
|
-
|
|
40
|
-
<div className="flex items-center gap-2">
|
|
41
|
-
<select
|
|
42
|
-
value={perPage}
|
|
43
|
-
onChange={(e) => {
|
|
44
|
-
onPerPageChange(Number(e.target.value))
|
|
45
|
-
onPageChange(1)
|
|
46
|
-
}}
|
|
47
|
-
className="rounded-md border border-[var(--border)] bg-[var(--input-background)] px-2 py-1 text-sm"
|
|
48
|
-
>
|
|
49
|
-
{[10, 25, 50, 100].map((n) => (
|
|
50
|
-
<option key={n} value={n}>
|
|
51
|
-
{n} / page
|
|
52
|
-
</option>
|
|
53
|
-
))}
|
|
54
|
-
</select>
|
|
55
|
-
|
|
56
|
-
<nav className="flex items-center gap-1">
|
|
57
|
-
<button
|
|
58
|
-
onClick={() => onPageChange(page - 1)}
|
|
59
|
-
disabled={page <= 1}
|
|
60
|
-
className="rounded-md px-2 py-1 hover:bg-[var(--accent)] disabled:opacity-50"
|
|
61
|
-
>
|
|
62
|
-
Prev
|
|
63
|
-
</button>
|
|
64
|
-
{pageNumbers().map((p, i) =>
|
|
65
|
-
p === 'ellipsis' ? (
|
|
66
|
-
<span key={`e${i}`} className="px-1 text-[var(--muted-foreground)]">
|
|
67
|
-
…
|
|
68
|
-
</span>
|
|
69
|
-
) : (
|
|
70
|
-
<button
|
|
71
|
-
key={p}
|
|
72
|
-
onClick={() => onPageChange(p)}
|
|
73
|
-
className={`min-w-[2rem] rounded-md px-2 py-1 ${
|
|
74
|
-
p === page
|
|
75
|
-
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
|
76
|
-
: 'hover:bg-[var(--accent)]'
|
|
77
|
-
}`}
|
|
78
|
-
>
|
|
79
|
-
{p}
|
|
80
|
-
</button>
|
|
81
|
-
),
|
|
82
|
-
)}
|
|
83
|
-
<button
|
|
84
|
-
onClick={() => onPageChange(page + 1)}
|
|
85
|
-
disabled={page >= totalPages}
|
|
86
|
-
className="rounded-md px-2 py-1 hover:bg-[var(--accent)] disabled:opacity-50"
|
|
87
|
-
>
|
|
88
|
-
Next
|
|
89
|
-
</button>
|
|
90
|
-
</nav>
|
|
91
|
-
</div>
|
|
92
|
-
</div>
|
|
93
|
-
)
|
|
94
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { forwardRef, type SelectHTMLAttributes, type ReactNode } from 'react'
|
|
2
|
-
import { cv, type VariantProps } from '../../lib/cv.js'
|
|
3
|
-
import { formControlBase, formControlState } from './form-control.js'
|
|
4
|
-
|
|
5
|
-
const select = cv(`${formControlBase} appearance-none pr-8`, {
|
|
6
|
-
variants: {
|
|
7
|
-
size: {
|
|
8
|
-
sm: 'py-1.5 pr-7 pl-3 text-[length:var(--fs-sub)]',
|
|
9
|
-
md: '',
|
|
10
|
-
lg: 'py-2.5 pr-9 pl-3',
|
|
11
|
-
},
|
|
12
|
-
state: {
|
|
13
|
-
default: formControlState.default,
|
|
14
|
-
invalid: formControlState.invalid,
|
|
15
|
-
valid: formControlState.valid,
|
|
16
|
-
},
|
|
17
|
-
},
|
|
18
|
-
defaultVariants: { size: 'md', state: 'default' },
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
export type SelectVariants = VariantProps<typeof select>
|
|
22
|
-
|
|
23
|
-
export interface SelectProps
|
|
24
|
-
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'>, SelectVariants {
|
|
25
|
-
children?: ReactNode
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
29
|
-
{ size, state, className, children, ...rest },
|
|
30
|
-
ref,
|
|
31
|
-
) {
|
|
32
|
-
return (
|
|
33
|
-
<div className="relative">
|
|
34
|
-
<select ref={ref} className={select({ size, state, class: className })} {...rest}>
|
|
35
|
-
{children}
|
|
36
|
-
</select>
|
|
37
|
-
<svg
|
|
38
|
-
aria-hidden
|
|
39
|
-
className="pointer-events-none absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]"
|
|
40
|
-
viewBox="0 0 20 20"
|
|
41
|
-
fill="currentColor"
|
|
42
|
-
>
|
|
43
|
-
<path
|
|
44
|
-
fillRule="evenodd"
|
|
45
|
-
d="M5.23 7.21a.75.75 0 011.06.02L10 11.06l3.71-3.83a.75.75 0 111.08 1.04l-4.25 4.39a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"
|
|
46
|
-
clipRule="evenodd"
|
|
47
|
-
/>
|
|
48
|
-
</svg>
|
|
49
|
-
</div>
|
|
50
|
-
)
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
export { select as selectVariants }
|