@pagebridge/sanity 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/LICENSE +21 -0
  3. package/README.md +214 -0
  4. package/dist/components/RefreshQueueTool.d.ts +2 -0
  5. package/dist/components/RefreshQueueTool.d.ts.map +1 -0
  6. package/dist/components/RefreshQueueTool.js +55 -0
  7. package/dist/components/SearchPerformancePane.d.ts +6 -0
  8. package/dist/components/SearchPerformancePane.d.ts.map +1 -0
  9. package/dist/components/SearchPerformancePane.js +80 -0
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +4 -0
  13. package/dist/plugin.d.ts +40 -0
  14. package/dist/plugin.d.ts.map +1 -0
  15. package/dist/plugin.js +89 -0
  16. package/dist/schemas/gscRefreshTask.d.ts +24 -0
  17. package/dist/schemas/gscRefreshTask.d.ts.map +1 -0
  18. package/dist/schemas/gscRefreshTask.js +196 -0
  19. package/dist/schemas/gscSite.d.ts +7 -0
  20. package/dist/schemas/gscSite.d.ts.map +1 -0
  21. package/dist/schemas/gscSite.js +68 -0
  22. package/dist/schemas/gscSnapshot.d.ts +22 -0
  23. package/dist/schemas/gscSnapshot.d.ts.map +1 -0
  24. package/dist/schemas/gscSnapshot.js +124 -0
  25. package/dist/schemas/index.d.ts +4 -0
  26. package/dist/schemas/index.d.ts.map +1 -0
  27. package/dist/schemas/index.js +3 -0
  28. package/eslint.config.js +3 -0
  29. package/package.json +50 -0
  30. package/src/components/RefreshQueueTool.tsx +196 -0
  31. package/src/components/SearchPerformancePane.tsx +237 -0
  32. package/src/index.ts +18 -0
  33. package/src/plugin.ts +115 -0
  34. package/src/schemas/gscRefreshTask.ts +203 -0
  35. package/src/schemas/gscSite.ts +71 -0
  36. package/src/schemas/gscSnapshot.ts +131 -0
  37. package/src/schemas/index.ts +11 -0
  38. package/tsconfig.json +23 -0
@@ -0,0 +1,196 @@
1
+ import { useClient } from "sanity";
2
+ import {
3
+ Card,
4
+ Stack,
5
+ Text,
6
+ Button,
7
+ Flex,
8
+ Badge,
9
+ Menu,
10
+ MenuButton,
11
+ MenuItem,
12
+ type BadgeTone,
13
+ } from "@sanity/ui";
14
+ import {
15
+ EllipsisVerticalIcon,
16
+ CheckmarkIcon,
17
+ ClockIcon,
18
+ CloseIcon,
19
+ } from "@sanity/icons";
20
+ import { useEffect, useState, useCallback } from "react";
21
+
22
+ interface RefreshTask {
23
+ _id: string;
24
+ reason: "position_decay" | "low_ctr" | "impressions_drop" | "manual";
25
+ severity: "low" | "medium" | "high";
26
+ status: string;
27
+ snoozedUntil?: string;
28
+ metrics?: {
29
+ positionBefore?: number;
30
+ positionNow?: number;
31
+ positionDelta?: number;
32
+ };
33
+ documentTitle?: string;
34
+ documentSlug?: string;
35
+ createdAt: string;
36
+ }
37
+
38
+ type FilterType = "open" | "snoozed" | "all";
39
+
40
+ const severityTone: Record<string, BadgeTone> = {
41
+ high: "critical",
42
+ medium: "caution",
43
+ low: "default",
44
+ };
45
+
46
+ const reasonLabels: Record<string, string> = {
47
+ position_decay: "Position Drop",
48
+ low_ctr: "Low CTR",
49
+ impressions_drop: "Traffic Drop",
50
+ manual: "Manual",
51
+ };
52
+
53
+ export function RefreshQueueTool() {
54
+ const client = useClient({ apiVersion: "2024-01-01" });
55
+ const [tasks, setTasks] = useState<RefreshTask[]>([]);
56
+ const [filter, setFilter] = useState<FilterType>("open");
57
+
58
+ const fetchTasks = useCallback(async () => {
59
+ const query =
60
+ filter === "all"
61
+ ? `*[_type == "gscRefreshTask" && status != "done" && status != "dismissed"]`
62
+ : `*[_type == "gscRefreshTask" && status == $status]`;
63
+
64
+ const results = await client.fetch<RefreshTask[]>(
65
+ `${query} | order(severity desc, createdAt desc) {
66
+ _id,
67
+ reason,
68
+ severity,
69
+ status,
70
+ snoozedUntil,
71
+ metrics,
72
+ "documentTitle": linkedDocument->title,
73
+ "documentSlug": linkedDocument->slug.current,
74
+ createdAt
75
+ }`,
76
+ { status: filter },
77
+ );
78
+ setTasks(results);
79
+ }, [filter, client]);
80
+
81
+ useEffect(() => {
82
+ fetchTasks();
83
+ }, [fetchTasks]);
84
+
85
+ const updateStatus = async (
86
+ taskId: string,
87
+ status: string,
88
+ snoozeDays?: number,
89
+ ) => {
90
+ const patch: Record<string, unknown> = { status };
91
+
92
+ if (status === "snoozed" && snoozeDays) {
93
+ const until = new Date();
94
+ until.setDate(until.getDate() + snoozeDays);
95
+ patch.snoozedUntil = until.toISOString();
96
+ }
97
+
98
+ if (status === "done") {
99
+ patch.resolvedAt = new Date().toISOString();
100
+ }
101
+
102
+ await client.patch(taskId).set(patch).commit();
103
+ setTasks((prev) =>
104
+ prev.filter((t) => t._id !== taskId || status === filter),
105
+ );
106
+ };
107
+
108
+ return (
109
+ <Card padding={4}>
110
+ <Stack space={4}>
111
+ <Flex justify="space-between" align="center">
112
+ <Text size={3} weight="semibold">
113
+ Content Refresh Queue
114
+ </Text>
115
+ <Flex gap={2}>
116
+ {(["open", "snoozed", "all"] as const).map((f) => (
117
+ <Button
118
+ key={f}
119
+ mode={filter === f ? "default" : "ghost"}
120
+ text={f.charAt(0).toUpperCase() + f.slice(1)}
121
+ onClick={() => setFilter(f)}
122
+ fontSize={1}
123
+ />
124
+ ))}
125
+ </Flex>
126
+ </Flex>
127
+
128
+ {tasks.length === 0 ? (
129
+ <Card padding={5} tone="positive" radius={2}>
130
+ <Text align="center">🎉 No pending refresh tasks!</Text>
131
+ </Card>
132
+ ) : (
133
+ <Stack space={2}>
134
+ {tasks.map((task) => (
135
+ <Card key={task._id} padding={3} radius={2} shadow={1}>
136
+ <Flex justify="space-between" align="flex-start">
137
+ <Stack space={2} style={{ flex: 1 }}>
138
+ <Flex gap={2} align="center">
139
+ <Badge tone={severityTone[task.severity]}>
140
+ {task.severity}
141
+ </Badge>
142
+ <Badge>{reasonLabels[task.reason]}</Badge>
143
+ </Flex>
144
+ <Text weight="semibold">
145
+ {task.documentTitle || "Untitled"}
146
+ </Text>
147
+ <Text size={1} muted>
148
+ /{task.documentSlug}
149
+ </Text>
150
+ {task.metrics && (
151
+ <Text size={1} muted>
152
+ Position: {task.metrics.positionBefore?.toFixed(1)} →{" "}
153
+ {task.metrics.positionNow?.toFixed(1)} (
154
+ {(task.metrics.positionDelta ?? 0) > 0 ? "+" : ""}
155
+ {task.metrics.positionDelta?.toFixed(1)})
156
+ </Text>
157
+ )}
158
+ </Stack>
159
+ <MenuButton
160
+ button={<Button icon={EllipsisVerticalIcon} mode="ghost" />}
161
+ id={`menu-${task._id}`}
162
+ menu={
163
+ <Menu>
164
+ <MenuItem
165
+ icon={CheckmarkIcon}
166
+ text="Mark as Done"
167
+ onClick={() => updateStatus(task._id, "done")}
168
+ />
169
+ <MenuItem
170
+ icon={ClockIcon}
171
+ text="Snooze 7 days"
172
+ onClick={() => updateStatus(task._id, "snoozed", 7)}
173
+ />
174
+ <MenuItem
175
+ icon={ClockIcon}
176
+ text="Snooze 30 days"
177
+ onClick={() => updateStatus(task._id, "snoozed", 30)}
178
+ />
179
+ <MenuItem
180
+ icon={CloseIcon}
181
+ text="Dismiss"
182
+ tone="critical"
183
+ onClick={() => updateStatus(task._id, "dismissed")}
184
+ />
185
+ </Menu>
186
+ }
187
+ />
188
+ </Flex>
189
+ </Card>
190
+ ))}
191
+ </Stack>
192
+ )}
193
+ </Stack>
194
+ </Card>
195
+ );
196
+ }
@@ -0,0 +1,237 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useClient } from "sanity";
3
+ import { Card, Stack, Text, Badge, Flex, Box, Spinner, Tooltip } from "@sanity/ui";
4
+ import { ArrowUpIcon, ArrowDownIcon, ArrowRightIcon, CheckmarkCircleIcon, CloseCircleIcon, WarningOutlineIcon } from "@sanity/icons";
5
+
6
+ interface IndexStatus {
7
+ verdict: "indexed" | "not_indexed" | "excluded";
8
+ coverageState: string | null;
9
+ lastCrawlTime: string | null;
10
+ }
11
+
12
+ interface PerformanceData {
13
+ clicks: number;
14
+ impressions: number;
15
+ ctr: number;
16
+ position: number;
17
+ positionDelta: number;
18
+ topQueries: { query: string; clicks: number; position: number }[];
19
+ lastUpdated: string;
20
+ indexStatus?: IndexStatus;
21
+ }
22
+
23
+ interface SearchPerformancePaneProps {
24
+ documentId: string;
25
+ }
26
+
27
+ export function SearchPerformancePane({
28
+ documentId,
29
+ }: SearchPerformancePaneProps) {
30
+ const client = useClient({ apiVersion: "2024-01-01" });
31
+ const [data, setData] = useState<PerformanceData | undefined>(undefined);
32
+ const [loading, setLoading] = useState(true);
33
+
34
+ useEffect(() => {
35
+ async function fetchData() {
36
+ const snapshot = await client.fetch(
37
+ `*[_type == "gscSnapshot" && linkedDocument._ref == $id && period == "last28"][0]{
38
+ clicks,
39
+ impressions,
40
+ ctr,
41
+ position,
42
+ topQueries,
43
+ fetchedAt,
44
+ indexStatus
45
+ }`,
46
+ { id: documentId },
47
+ );
48
+
49
+ const previousSnapshot = await client.fetch(
50
+ `*[_type == "gscSnapshot" && linkedDocument._ref == $id && period == "last28"] | order(fetchedAt desc)[1]{
51
+ position
52
+ }`,
53
+ { id: documentId },
54
+ );
55
+
56
+ if (snapshot) {
57
+ setData({
58
+ ...snapshot,
59
+ positionDelta: previousSnapshot
60
+ ? snapshot.position - previousSnapshot.position
61
+ : 0,
62
+ lastUpdated: snapshot.fetchedAt,
63
+ indexStatus: snapshot.indexStatus,
64
+ });
65
+ }
66
+ setLoading(false);
67
+ }
68
+
69
+ fetchData();
70
+ }, [documentId, client]);
71
+
72
+ if (loading) {
73
+ return (
74
+ <Card padding={4}>
75
+ <Flex justify="center">
76
+ <Spinner />
77
+ </Flex>
78
+ </Card>
79
+ );
80
+ }
81
+
82
+ if (!data) {
83
+ return (
84
+ <Card padding={4} tone="caution">
85
+ <Text>No search data available for this document.</Text>
86
+ </Card>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <Card padding={4}>
92
+ <Stack space={4}>
93
+ <Flex justify="space-between" align="center">
94
+ <Text weight="semibold" size={2}>
95
+ Search Performance (Last 28 Days)
96
+ </Text>
97
+ {data.indexStatus && <IndexStatusBadge status={data.indexStatus} />}
98
+ </Flex>
99
+
100
+ <Flex gap={3} wrap="wrap">
101
+ <MetricCard label="Clicks" value={data.clicks.toLocaleString()} />
102
+ <MetricCard
103
+ label="Impressions"
104
+ value={data.impressions.toLocaleString()}
105
+ />
106
+ <MetricCard label="CTR" value={`${(data.ctr * 100).toFixed(1)}%`} />
107
+ <MetricCard
108
+ label="Avg. Position"
109
+ value={data.position.toFixed(1)}
110
+ trend={data.positionDelta}
111
+ invertTrend
112
+ />
113
+ </Flex>
114
+
115
+ {data.topQueries?.length > 0 && (
116
+ <Box>
117
+ <Text size={1} weight="semibold" muted style={{ marginBottom: 8 }}>
118
+ Top Queries
119
+ </Text>
120
+ <Stack space={2}>
121
+ {data.topQueries.slice(0, 5).map((q, i) => (
122
+ <Flex key={i} justify="space-between" align="center">
123
+ <Text size={1} style={{ flex: 1 }}>
124
+ {q.query}
125
+ </Text>
126
+ <Text size={1} muted>
127
+ {q.clicks} clicks · pos {q.position.toFixed(1)}
128
+ </Text>
129
+ </Flex>
130
+ ))}
131
+ </Stack>
132
+ </Box>
133
+ )}
134
+
135
+ <Text size={0} muted>
136
+ Last updated: {new Date(data.lastUpdated).toLocaleDateString()}
137
+ </Text>
138
+ </Stack>
139
+ </Card>
140
+ );
141
+ }
142
+
143
+ interface MetricCardProps {
144
+ label: string;
145
+ value: string;
146
+ trend?: number;
147
+ invertTrend?: boolean;
148
+ }
149
+
150
+ function MetricCard({ label, value, trend, invertTrend }: MetricCardProps) {
151
+ const TrendIcon =
152
+ trend === undefined || trend === 0
153
+ ? ArrowRightIcon
154
+ : (invertTrend ? trend < 0 : trend > 0)
155
+ ? ArrowUpIcon
156
+ : ArrowDownIcon;
157
+
158
+ const trendTone =
159
+ trend === undefined || trend === 0
160
+ ? "default"
161
+ : (invertTrend ? trend < 0 : trend > 0)
162
+ ? "positive"
163
+ : "critical";
164
+
165
+ return (
166
+ <Card padding={3} radius={2} shadow={1} style={{ minWidth: 100 }}>
167
+ <Stack space={2}>
168
+ <Text size={0} muted>
169
+ {label}
170
+ </Text>
171
+ <Flex align="center" gap={2}>
172
+ <Text size={3} weight="semibold">
173
+ {value}
174
+ </Text>
175
+ {trend !== undefined && trend !== 0 && (
176
+ <Badge tone={trendTone} fontSize={0}>
177
+ <TrendIcon />
178
+ {Math.abs(trend).toFixed(1)}
179
+ </Badge>
180
+ )}
181
+ </Flex>
182
+ </Stack>
183
+ </Card>
184
+ );
185
+ }
186
+
187
+ interface IndexStatusBadgeProps {
188
+ status: IndexStatus;
189
+ }
190
+
191
+ function IndexStatusBadge({ status }: IndexStatusBadgeProps) {
192
+ const config = {
193
+ indexed: {
194
+ tone: "positive" as const,
195
+ icon: CheckmarkCircleIcon,
196
+ label: "Indexed",
197
+ },
198
+ not_indexed: {
199
+ tone: "critical" as const,
200
+ icon: CloseCircleIcon,
201
+ label: "Not Indexed",
202
+ },
203
+ excluded: {
204
+ tone: "caution" as const,
205
+ icon: WarningOutlineIcon,
206
+ label: "Excluded",
207
+ },
208
+ };
209
+
210
+ const { tone, icon: Icon, label } = config[status.verdict];
211
+ const tooltipContent = status.coverageState || label;
212
+
213
+ return (
214
+ <Tooltip
215
+ content={
216
+ <Box padding={2}>
217
+ <Stack space={2}>
218
+ <Text size={1}>{tooltipContent}</Text>
219
+ {status.lastCrawlTime && (
220
+ <Text size={0} muted>
221
+ Last crawled: {new Date(status.lastCrawlTime).toLocaleDateString()}
222
+ </Text>
223
+ )}
224
+ </Stack>
225
+ </Box>
226
+ }
227
+ placement="top"
228
+ >
229
+ <Badge tone={tone} fontSize={1} style={{ cursor: "help" }}>
230
+ <Flex align="center" gap={1}>
231
+ <Icon />
232
+ {label}
233
+ </Flex>
234
+ </Badge>
235
+ </Tooltip>
236
+ );
237
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { SearchPerformancePane } from "./components/SearchPerformancePane";
2
+ export { RefreshQueueTool } from "./components/RefreshQueueTool";
3
+ export {
4
+ gscPlugin,
5
+ createGscStructureResolver,
6
+ createPageBridgeStructure,
7
+ PAGEBRIDGE_TYPES,
8
+ type GscPluginConfig,
9
+ } from "./plugin";
10
+ export {
11
+ gscSite,
12
+ gscSnapshot,
13
+ gscRefreshTask,
14
+ createGscSnapshot,
15
+ createGscRefreshTask,
16
+ type GscSnapshotOptions,
17
+ type GscRefreshTaskOptions,
18
+ } from "./schemas";
package/src/plugin.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { definePlugin } from "sanity";
2
+ import type {
3
+ DefaultDocumentNodeResolver,
4
+ StructureBuilder,
5
+ } from "sanity/structure";
6
+ import { ChartUpwardIcon, EarthGlobeIcon } from "@sanity/icons";
7
+ import { gscSite } from "./schemas/gscSite";
8
+ import { createGscSnapshot } from "./schemas/gscSnapshot";
9
+ import { createGscRefreshTask } from "./schemas/gscRefreshTask";
10
+ import { RefreshQueueTool } from "./components/RefreshQueueTool";
11
+ import { SearchPerformancePane } from "./components/SearchPerformancePane";
12
+
13
+ export interface GscPluginConfig {
14
+ /**
15
+ * Array of Sanity document type names that represent your content.
16
+ * These will be available for linking in gscSnapshot and gscRefreshTask schemas.
17
+ * Example: ['post', 'article', 'page']
18
+ */
19
+ contentTypes?: string[];
20
+ }
21
+
22
+ /** Document type names registered by the PageBridge plugin */
23
+ export const PAGEBRIDGE_TYPES = [
24
+ "gscSite",
25
+ "gscSnapshot",
26
+ "gscRefreshTask",
27
+ ] as const;
28
+
29
+ /**
30
+ * Creates a "PageBridge" folder list item for the desk structure.
31
+ * Use with structureTool's `structure` option to group PageBridge
32
+ * documents into a single folder and filter them from the default list.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * structureTool({
37
+ * structure: (S, context) =>
38
+ * S.list()
39
+ * .title("Content")
40
+ * .items([
41
+ * createPageBridgeStructure(S),
42
+ * S.divider(),
43
+ * ...S.documentTypeListItems().filter(
44
+ * (item) => !PAGEBRIDGE_TYPES.includes(item.getId() as any),
45
+ * ),
46
+ * ]),
47
+ * })
48
+ * ```
49
+ */
50
+ export const createPageBridgeStructure = (S: StructureBuilder) =>
51
+ S.listItem()
52
+ .title("PageBridge")
53
+ .icon(EarthGlobeIcon)
54
+ .child(
55
+ S.list()
56
+ .title("PageBridge")
57
+ .items([
58
+ S.listItem()
59
+ .title("GSC Sites")
60
+ .schemaType("gscSite")
61
+ .child(S.documentTypeList("gscSite").title("GSC Sites")),
62
+ S.listItem()
63
+ .title("Snapshots")
64
+ .schemaType("gscSnapshot")
65
+ .child(S.documentTypeList("gscSnapshot").title("Snapshots")),
66
+ S.listItem()
67
+ .title("Refresh Tasks")
68
+ .schemaType("gscRefreshTask")
69
+ .child(
70
+ S.documentTypeList("gscRefreshTask").title("Refresh Tasks"),
71
+ ),
72
+ ]),
73
+ );
74
+
75
+ /**
76
+ * Creates a structure resolver that adds the Performance view to content types
77
+ * Use this with structureTool's defaultDocumentNode option
78
+ */
79
+ export const createGscStructureResolver = (
80
+ contentTypes: string[] = [],
81
+ ): DefaultDocumentNodeResolver => {
82
+ return (S, { schemaType }) => {
83
+ if (contentTypes.includes(schemaType)) {
84
+ return S.document().views([
85
+ S.view.form(),
86
+ S.view
87
+ .component(SearchPerformancePane)
88
+ .title("Performance")
89
+ .icon(ChartUpwardIcon),
90
+ ]);
91
+ }
92
+ return S.document().views([S.view.form()]);
93
+ };
94
+ };
95
+
96
+ export const gscPlugin = definePlugin<GscPluginConfig | void>((config) => {
97
+ const contentTypes = config?.contentTypes ?? [];
98
+
99
+ const gscSnapshot = createGscSnapshot({ contentTypes });
100
+ const gscRefreshTask = createGscRefreshTask({ contentTypes });
101
+
102
+ return {
103
+ name: "pagebridge-sanity",
104
+ schema: {
105
+ types: [gscSite, gscSnapshot, gscRefreshTask],
106
+ },
107
+ tools: [
108
+ {
109
+ name: "gsc-refresh-queue",
110
+ title: "Refresh Queue",
111
+ component: RefreshQueueTool,
112
+ },
113
+ ],
114
+ };
115
+ });