@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
  5. package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
  6. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
  7. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
  8. package/dist/__tests__/lib/seo-service-errors.test.js +5 -6
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  10. package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  12. package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
  13. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  14. package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
  15. package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
  16. package/dist/components/seo/FaqProposalPreview.js +62 -0
  17. package/dist/components/seo/FaqProposalPreview.js.map +1 -0
  18. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  19. package/dist/components/seo/SeoIssueFixPanel.js +7 -2
  20. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  21. package/dist/lib/seo-service.d.ts +61 -7
  22. package/dist/lib/seo-service.d.ts.map +1 -1
  23. package/dist/lib/seo-service.js +9 -13
  24. package/dist/lib/seo-service.js.map +1 -1
  25. package/dist/prenta-admin.css +1 -1
  26. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  27. package/dist/views/seo/ProposalsTab.js +160 -7
  28. package/dist/views/seo/ProposalsTab.js.map +1 -1
  29. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
  30. package/dist/views/settings/SeoAutopilotCard.js +6 -0
  31. package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
  32. package/package.json +5 -5
  33. package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
  34. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
  35. package/src/__tests__/lib/seo-service-errors.test.ts +5 -9
  36. package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
  37. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
  38. package/src/components/seo/FaqProposalPreview.tsx +134 -0
  39. package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
  40. package/src/lib/seo-service.ts +57 -15
  41. package/src/views/seo/ProposalsTab.tsx +279 -13
  42. package/src/views/settings/SeoAutopilotCard.tsx +6 -0
  43. package/dist/components/ContentOverviewChart.d.ts +0 -8
  44. package/dist/components/ContentOverviewChart.d.ts.map +0 -1
  45. package/dist/components/ContentOverviewChart.js +0 -20
  46. package/dist/components/ContentOverviewChart.js.map +0 -1
  47. package/dist/components/SEOPerformance.d.ts +0 -5
  48. package/dist/components/SEOPerformance.d.ts.map +0 -1
  49. package/dist/components/SEOPerformance.js +0 -29
  50. package/dist/components/SEOPerformance.js.map +0 -1
  51. package/dist/components/ui/Avatar.d.ts +0 -9
  52. package/dist/components/ui/Avatar.d.ts.map +0 -1
  53. package/dist/components/ui/Avatar.js +0 -21
  54. package/dist/components/ui/Avatar.js.map +0 -1
  55. package/dist/components/ui/CommandPalette.d.ts +0 -14
  56. package/dist/components/ui/CommandPalette.d.ts.map +0 -1
  57. package/dist/components/ui/CommandPalette.js +0 -58
  58. package/dist/components/ui/CommandPalette.js.map +0 -1
  59. package/dist/components/ui/DataTable.d.ts +0 -30
  60. package/dist/components/ui/DataTable.d.ts.map +0 -1
  61. package/dist/components/ui/DataTable.js +0 -44
  62. package/dist/components/ui/DataTable.js.map +0 -1
  63. package/dist/components/ui/Pagination.d.ts +0 -9
  64. package/dist/components/ui/Pagination.d.ts.map +0 -1
  65. package/dist/components/ui/Pagination.js +0 -26
  66. package/dist/components/ui/Pagination.js.map +0 -1
  67. package/dist/components/ui/Select.d.ts +0 -16
  68. package/dist/components/ui/Select.d.ts.map +0 -1
  69. package/dist/components/ui/Select.js +0 -24
  70. package/dist/components/ui/Select.js.map +0 -1
  71. package/dist/components/ui/Toast.d.ts +0 -18
  72. package/dist/components/ui/Toast.d.ts.map +0 -1
  73. package/dist/components/ui/Toast.js +0 -29
  74. package/dist/components/ui/Toast.js.map +0 -1
  75. package/dist/components/ui/index.d.ts +0 -46
  76. package/dist/components/ui/index.d.ts.map +0 -1
  77. package/dist/components/ui/index.js +0 -24
  78. package/dist/components/ui/index.js.map +0 -1
  79. package/dist/hooks/useContentLock.d.ts +0 -12
  80. package/dist/hooks/useContentLock.d.ts.map +0 -1
  81. package/dist/hooks/useContentLock.js +0 -38
  82. package/dist/hooks/useContentLock.js.map +0 -1
  83. package/dist/hooks/useDebounce.d.ts +0 -2
  84. package/dist/hooks/useDebounce.d.ts.map +0 -1
  85. package/dist/hooks/useDebounce.js +0 -11
  86. package/dist/hooks/useDebounce.js.map +0 -1
  87. package/src/components/ContentOverviewChart.tsx +0 -75
  88. package/src/components/SEOPerformance.tsx +0 -163
  89. package/src/components/ui/Avatar.tsx +0 -42
  90. package/src/components/ui/CommandPalette.tsx +0 -133
  91. package/src/components/ui/DataTable.tsx +0 -204
  92. package/src/components/ui/Pagination.tsx +0 -94
  93. package/src/components/ui/Select.tsx +0 -53
  94. package/src/components/ui/Toast.tsx +0 -72
  95. package/src/components/ui/index.ts +0 -61
  96. package/src/hooks/useContentLock.ts +0 -55
  97. 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 }