@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.
- package/CHANGELOG.md +8 -0
- package/dist/__tests__/lib/seo-service-errors.test.js +2 -6
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/lib/seo-service.d.ts +0 -5
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +0 -11
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/package.json +5 -5
- package/src/__tests__/lib/seo-service-errors.test.ts +1 -9
- package/src/lib/seo-service.ts +0 -14
- 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,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 +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
|
-
}
|