sanity-plugin-references 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Félix Péault (Flayks)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ <h2 align="center">
2
+ 🔗 References Plugin for Sanity 🔗
3
+ </h2>
4
+ <p align="center">
5
+ See which documents reference the current document in your Sanity Studio.<br/>
6
+ Displays a badge with the reference count and a custom pane with the full list.
7
+ </p>
8
+
9
+ ## Features
10
+
11
+ - **Reference Badge**: Shows count of documents referencing the current document
12
+ - **References Tab**: Lists all referencing documents with:
13
+ - 🔍 **Search** by title or document type
14
+ - ⬇️ **Sorting** by updated date, type, or title (ascending/descending)
15
+ - 📊 **Live Count** of filtered vs total documents
16
+ - 🚀 **Click to Navigate** to any referencing document
17
+
18
+ ## Installation
19
+
20
+ ```sh
21
+ # npm
22
+ npm i sanity-plugin-references
23
+
24
+ # yarn
25
+ yarn add sanity-plugin-references
26
+
27
+ # pnpm
28
+ pnpm add sanity-plugin-references
29
+
30
+ # bun
31
+ bun add sanity-plugin-references
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### Basic Setup
37
+
38
+ ```ts
39
+ import { defineConfig } from 'sanity'
40
+ import { references } from 'sanity-plugin-references'
41
+
42
+ export default defineConfig({
43
+ plugins: [references()],
44
+ })
45
+ ```
46
+
47
+ This adds a **reference count badge** to all documents showing how many other documents reference them.
48
+
49
+ ### Adding the References Tab
50
+
51
+ Add a full References tab with search, filters, and sorting:
52
+
53
+ ```ts
54
+ import { structureTool } from 'sanity/structure'
55
+ import { references, referencesView } from 'sanity-plugin-references'
56
+
57
+ export default defineConfig({
58
+ plugins: [
59
+ references(),
60
+ structureTool({
61
+ defaultDocumentNode: (S) => S.document().views([
62
+ S.view.form(),
63
+ referencesView(S),
64
+ ]),
65
+ }),
66
+ ],
67
+ })
68
+ ```
69
+
70
+ ### Options
71
+
72
+ **Exclude document types** from showing the badge:
73
+
74
+ ```ts
75
+ references({
76
+ exclude: ['media.tag', 'sanity.imageAsset'],
77
+ })
78
+ ```
79
+
80
+ **Customize the tab** title or icon:
81
+
82
+ ```ts
83
+ referencesView(S, { title: 'Incoming Links', icon: SomeIcon })
84
+ ```
85
+
86
+ **Show tab only for specific types** by checking `schemaType` in `defaultDocumentNode`.
87
+
88
+ ## API
89
+
90
+ ### `references(config?)`
91
+
92
+ Main plugin function. Adds reference badges to all documents.
93
+
94
+ - `exclude?: string[]` - Document types to exclude from showing the badge
95
+
96
+ ### `referencesView(S, options?)`
97
+
98
+ Creates a References view for Structure Builder.
99
+
100
+ - `title?: string` - Tab title (default: `'References'`)
101
+ - `icon?: ComponentType` - Tab icon (default: `LinkIcon`)
102
+
103
+ ### Components
104
+
105
+ - **`ReferencesPane`** - Raw component for `S.view.component()` (use `referencesView()` instead)
106
+ - **`ReferencesBadge`** - Badge component (automatically included via `references()` plugin)
107
+
108
+ ## License
109
+
110
+ [MIT](LICENSE) © Félix Péault (Flayks)
111
+
112
+ ## Develop & test
113
+
114
+ This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit)
115
+ with default configuration for build & watch scripts.
116
+
117
+ See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
118
+ on how to run this plugin with hotreload in the studio.
@@ -0,0 +1,123 @@
1
+ import type {ComponentType} from 'react'
2
+ import {ComponentViewBuilder} from 'sanity/structure'
3
+ import {DocumentBadgeComponent} from 'sanity'
4
+ import {Plugin as Plugin_2} from 'sanity'
5
+ import {LinkIcon as ReferencesIcon} from '@sanity/icons'
6
+ import type {StructureBuilder} from 'sanity/structure'
7
+ import {UserViewComponent} from 'sanity/structure'
8
+
9
+ /**
10
+ * Plugin that shows documents referencing the current document.
11
+ * Displays a badge with the reference count.
12
+ *
13
+ * To add the References tab to your documents, use the Structure Builder API
14
+ * with the exported `ReferencesPane` component.
15
+ *
16
+ * @example Basic usage (badge only)
17
+ * ```ts
18
+ * // sanity.config.ts
19
+ * import { references } from 'sanity-plugin-references'
20
+ *
21
+ * export default defineConfig({
22
+ * plugins: [references()],
23
+ * })
24
+ * ```
25
+ *
26
+ * @example Exclude specific document types from showing the badge
27
+ * ```ts
28
+ * references({ exclude: ['media.tag', 'sanity.imageAsset'] })
29
+ * ```
30
+ *
31
+ * @example Adding the References tab with Structure Builder
32
+ * ```ts
33
+ * // sanity.config.ts
34
+ * import { structureTool } from 'sanity/structure'
35
+ * import { references, ReferencesPane } from 'sanity-plugin-references'
36
+ * import { LinkIcon } from '@sanity/icons'
37
+ *
38
+ * export default defineConfig({
39
+ * plugins: [
40
+ * references(),
41
+ * structureTool({
42
+ * defaultDocumentNode: (S, { schemaType }) => {
43
+ * return S.document().views([
44
+ * S.view.form(),
45
+ * S.view.component(ReferencesPane).title('References').icon(LinkIcon),
46
+ * ])
47
+ * },
48
+ * }),
49
+ * ],
50
+ * })
51
+ * ```
52
+ *
53
+ * @example Using the referencesView helper
54
+ * ```ts
55
+ * import { references, referencesView } from 'sanity-plugin-references'
56
+ *
57
+ * structureTool({
58
+ * defaultDocumentNode: (S) => S.document().views([
59
+ * S.view.form(),
60
+ * referencesView(S),
61
+ * ]),
62
+ * })
63
+ * ```
64
+ */
65
+ export declare const references: Plugin_2<void | ReferencesConfig>
66
+
67
+ /**
68
+ * Badge component that displays the count of documents referencing the current document
69
+ */
70
+ export declare const ReferencesBadge: DocumentBadgeComponent
71
+
72
+ export declare interface ReferencesConfig {
73
+ /**
74
+ * Document types to exclude from showing the References badge
75
+ * @example ['media.tag', 'sanity.imageAsset']
76
+ */
77
+ exclude?: string[]
78
+ }
79
+
80
+ export {ReferencesIcon}
81
+
82
+ /**
83
+ * Structure Builder view component that displays documents referencing the current document.
84
+ * Use this component with S.view.component() in your structure configuration.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * import { references, referencesView } from 'sanity-plugin-references'
89
+ * import { LinkIcon } from '@sanity/icons'
90
+ *
91
+ * S.document().views([
92
+ * S.view.form(),
93
+ * referencesView(S),
94
+ * ])
95
+ * ```
96
+ */
97
+ export declare const ReferencesPane: UserViewComponent
98
+
99
+ /**
100
+ * Helper to create a References view for Structure Builder.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * S.document().views([
105
+ * S.view.form(),
106
+ * referencesView(S),
107
+ * ])
108
+ * ```
109
+ *
110
+ * @example With custom title and icon
111
+ * ```ts
112
+ * referencesView(S, { title: 'Incoming Links', icon: MyIcon })
113
+ * ```
114
+ */
115
+ export declare function referencesView(
116
+ S: StructureBuilder,
117
+ options?: {
118
+ title?: string
119
+ icon?: ComponentType
120
+ },
121
+ ): ComponentViewBuilder
122
+
123
+ export {}
package/dist/index.js ADDED
@@ -0,0 +1,188 @@
1
+ import { useClient, useSchema, Preview, definePlugin } from "sanity";
2
+ import { LinkIcon, SortIcon, SearchIcon } from "@sanity/icons";
3
+ import { LinkIcon as LinkIcon2 } from "@sanity/icons";
4
+ import { useState, useEffect, useMemo, useCallback } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { useRouter } from "sanity/router";
7
+ import { Flex, Spinner, Card, Text, Stack, Select, Inline, Radio, Box, Button, TextInput, Badge } from "@sanity/ui";
8
+ const ReferencesBadge = (props) => {
9
+ const { id, published, draft } = props, documentId = published?._id || draft?._id || id, [count, setCount] = useState(null), client = useClient({ apiVersion: "2026-01-05" });
10
+ return useEffect(() => {
11
+ if (!documentId) return;
12
+ const cleanId = documentId.replace(/^drafts\./, "");
13
+ client.fetch(
14
+ /* groq */
15
+ 'count(*[references($id) && !(_id in path("drafts.**"))])',
16
+ { id: cleanId }
17
+ ).then(setCount);
18
+ const subscription = client.listen(
19
+ /* groq */
20
+ "*[references($id)]",
21
+ { id: cleanId }
22
+ ).subscribe(() => {
23
+ client.fetch(
24
+ /* groq */
25
+ 'count(*[references($id) && !(_id in path("drafts.**"))])',
26
+ { id: cleanId }
27
+ ).then(setCount);
28
+ });
29
+ return () => subscription.unsubscribe();
30
+ }, [documentId, client]), count === null || count === 0 ? null : {
31
+ label: `${count} reference${count > 1 ? "s" : ""}`,
32
+ title: `${count} document${count > 1 ? "s" : ""} reference this. See "References" tab.`,
33
+ color: "primary"
34
+ };
35
+ }, ReferencesPane = (props) => {
36
+ const { documentId } = props, client = useClient({ apiVersion: "2026-01-05" }), schema = useSchema(), router = useRouter(), [documents, setDocuments] = useState([]), [loading, setLoading] = useState(!0), [error, setError] = useState(null), [searchQuery, setSearchQuery] = useState(""), [typeFilter, setTypeFilter] = useState("all"), [sortBy, setSortBy] = useState("updatedAt"), [sortDesc, setSortDesc] = useState(!0), cleanId = useMemo(() => documentId.replace(/^drafts\./, ""), [documentId]), fetchDocuments = useCallback(async () => {
37
+ if (cleanId) {
38
+ setLoading(!0), setError(null);
39
+ try {
40
+ const results = await client.fetch(
41
+ `*[references($id) && !(_id in path("drafts.**"))] {
42
+ _id, _type, _updatedAt, "title": coalesce(title, name, "Untitled")
43
+ } | order(_updatedAt desc)`,
44
+ { id: cleanId }
45
+ );
46
+ setDocuments(results);
47
+ } catch (err) {
48
+ setError(err instanceof Error ? err.message : "Failed to fetch references");
49
+ } finally {
50
+ setLoading(!1);
51
+ }
52
+ }
53
+ }, [client, cleanId]);
54
+ useEffect(() => {
55
+ fetchDocuments();
56
+ }, [fetchDocuments]);
57
+ const documentTypes = useMemo(
58
+ () => [...new Set(documents.map((doc) => doc._type))].sort(),
59
+ [documents]
60
+ ), filteredDocuments = useMemo(() => {
61
+ const query = searchQuery.toLowerCase().trim();
62
+ return documents.filter((doc) => typeFilter === "all" || doc._type === typeFilter).filter((doc) => !query || doc.title?.toLowerCase().includes(query) || doc._type.toLowerCase().includes(query)).sort((a, b) => {
63
+ const cmp = sortBy === "updatedAt" ? new Date(a._updatedAt).getTime() - new Date(b._updatedAt).getTime() : sortBy === "type" ? a._type.localeCompare(b._type) : (a.title || "").localeCompare(b.title || "");
64
+ return sortDesc ? -cmp : cmp;
65
+ });
66
+ }, [documents, typeFilter, searchQuery, sortBy, sortDesc]), getSchemaType = useCallback((type) => schema.get(type), [schema]);
67
+ if (loading)
68
+ return /* @__PURE__ */ jsx(Flex, { align: "center", justify: "center", padding: 5, children: /* @__PURE__ */ jsx(Spinner, { muted: !0 }) });
69
+ if (error)
70
+ return /* @__PURE__ */ jsx(Card, { padding: 4, tone: "critical", children: /* @__PURE__ */ jsx(Text, { children: error }) });
71
+ if (documents.length === 0)
72
+ return /* @__PURE__ */ jsx(Card, { padding: 5, children: /* @__PURE__ */ jsxs(Flex, { align: "center", justify: "center", direction: "column", gap: 3, children: [
73
+ /* @__PURE__ */ jsx(LinkIcon, { style: { fontSize: 32, opacity: 0.4 } }),
74
+ /* @__PURE__ */ jsx(Text, { muted: !0, children: "No document reference this one" })
75
+ ] }) });
76
+ const useSelectForFilters = documentTypes.length > 5;
77
+ return /* @__PURE__ */ jsxs(Stack, { space: 0, style: { height: "100%" }, children: [
78
+ /* @__PURE__ */ jsx(Card, { borderBottom: !0, padding: 2, children: /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "nowrap", children: [
79
+ useSelectForFilters ? /* @__PURE__ */ jsxs(
80
+ Select,
81
+ {
82
+ value: typeFilter,
83
+ onChange: (e) => setTypeFilter(e.currentTarget.value),
84
+ fontSize: 1,
85
+ padding: 2,
86
+ style: { minWidth: 150, flexShrink: 0 },
87
+ children: [
88
+ /* @__PURE__ */ jsx("option", { value: "all", children: "All types" }),
89
+ documentTypes.map((type) => {
90
+ const schemaType = getSchemaType(type);
91
+ return /* @__PURE__ */ jsx("option", { value: type, children: schemaType?.title || type }, type);
92
+ })
93
+ ]
94
+ }
95
+ ) : /* @__PURE__ */ jsxs(Inline, { space: 2, style: { flexShrink: 0 }, children: [
96
+ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 1, as: "label", style: { cursor: "pointer", whiteSpace: "nowrap" }, children: [
97
+ /* @__PURE__ */ jsx(Radio, { checked: typeFilter === "all", onChange: () => setTypeFilter("all") }),
98
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: typeFilter === "all" ? "semibold" : "regular", children: "All" })
99
+ ] }),
100
+ documentTypes.map((type) => {
101
+ const schemaType = getSchemaType(type);
102
+ return /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 1, as: "label", style: { cursor: "pointer", whiteSpace: "nowrap" }, children: [
103
+ /* @__PURE__ */ jsx(Radio, { checked: typeFilter === type, onChange: () => setTypeFilter(type) }),
104
+ /* @__PURE__ */ jsx(Text, { size: 1, weight: typeFilter === type ? "semibold" : "regular", children: schemaType?.title || type })
105
+ ] }, type);
106
+ })
107
+ ] }),
108
+ /* @__PURE__ */ jsx(Box, { flex: 1 }),
109
+ /* @__PURE__ */ jsxs(Flex, { gap: 1, style: { flexShrink: 0 }, children: [
110
+ /* @__PURE__ */ jsxs(Select, { value: sortBy, onChange: (e) => setSortBy(e.currentTarget.value), fontSize: 1, padding: 2, children: [
111
+ /* @__PURE__ */ jsx("option", { value: "updatedAt", children: "Updated" }),
112
+ /* @__PURE__ */ jsx("option", { value: "type", children: "Type" }),
113
+ /* @__PURE__ */ jsx("option", { value: "title", children: "Title" })
114
+ ] }),
115
+ /* @__PURE__ */ jsx(
116
+ Button,
117
+ {
118
+ icon: SortIcon,
119
+ mode: "ghost",
120
+ onClick: () => setSortDesc((d) => !d),
121
+ title: sortDesc ? "Descending" : "Ascending",
122
+ fontSize: 1,
123
+ padding: 2
124
+ }
125
+ )
126
+ ] })
127
+ ] }) }),
128
+ /* @__PURE__ */ jsx(Card, { borderBottom: !0, paddingX: 3, paddingY: 0, children: /* @__PURE__ */ jsxs(Flex, { align: "stretch", style: { minHeight: 33 }, children: [
129
+ /* @__PURE__ */ jsx(Flex, { align: "center", paddingY: 2, style: { flexShrink: 0 }, children: /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
130
+ filteredDocuments.length,
131
+ " of ",
132
+ documents.length,
133
+ " document",
134
+ documents.length !== 1 ? "s" : ""
135
+ ] }) }),
136
+ /* @__PURE__ */ jsx(Box, { style: { flex: 1, minWidth: 180, borderLeft: "1px solid var(--card-border-color)", paddingLeft: 4, marginLeft: 12, display: "flex", alignItems: "center" }, children: /* @__PURE__ */ jsx(
137
+ TextInput,
138
+ {
139
+ icon: SearchIcon,
140
+ placeholder: "Search...",
141
+ value: searchQuery,
142
+ onChange: (e) => setSearchQuery(e.currentTarget.value),
143
+ fontSize: 1,
144
+ padding: 2,
145
+ border: !1,
146
+ style: { boxShadow: "none", width: "100%" }
147
+ }
148
+ ) })
149
+ ] }) }),
150
+ /* @__PURE__ */ jsx(Box, { style: { flex: 1, overflow: "auto" }, children: /* @__PURE__ */ jsx(Stack, { space: 1, padding: 2, children: filteredDocuments.length === 0 ? /* @__PURE__ */ jsx(Card, { padding: 4, children: /* @__PURE__ */ jsx(Text, { muted: !0, align: "center", children: "No documents match your filters" }) }) : filteredDocuments.map((doc) => {
151
+ const schemaType = getSchemaType(doc._type);
152
+ return /* @__PURE__ */ jsx(
153
+ Card,
154
+ {
155
+ as: "button",
156
+ padding: 2,
157
+ radius: 2,
158
+ onClick: () => router.navigateIntent("edit", { id: doc._id, type: doc._type }),
159
+ style: { cursor: "pointer", textAlign: "left", width: "100%" },
160
+ children: /* @__PURE__ */ jsxs(Flex, { gap: 3, align: "center", children: [
161
+ /* @__PURE__ */ jsx(Box, { flex: 1, children: schemaType ? /* @__PURE__ */ jsx(Preview, { schemaType, value: doc, layout: "default" }) : /* @__PURE__ */ jsx(Text, { weight: "medium", children: doc.title }) }),
162
+ /* @__PURE__ */ jsx(Badge, { tone: "primary", fontSize: 0, children: schemaType?.title || doc._type })
163
+ ] })
164
+ },
165
+ doc._id
166
+ );
167
+ }) }) })
168
+ ] });
169
+ }, references = definePlugin((config) => {
170
+ const excludeTypes = config?.exclude || [];
171
+ return {
172
+ name: "references",
173
+ document: {
174
+ badges: (prev, context) => excludeTypes.includes(context.schemaType) ? prev : [...prev, ReferencesBadge]
175
+ }
176
+ };
177
+ });
178
+ function referencesView(S, options) {
179
+ return S.view.component(ReferencesPane).title(options?.title || "References").icon(options?.icon || LinkIcon);
180
+ }
181
+ export {
182
+ ReferencesBadge,
183
+ LinkIcon2 as ReferencesIcon,
184
+ ReferencesPane,
185
+ references,
186
+ referencesView
187
+ };
188
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/badge.tsx","../src/pane.tsx","../src/index.ts"],"sourcesContent":["import { useState, useEffect } from 'react'\nimport { type DocumentBadgeComponent, useClient } from 'sanity'\n\n/**\n * Badge component that displays the count of documents referencing the current document\n */\nexport const ReferencesBadge: DocumentBadgeComponent = (props) => {\n const { id, published, draft } = props\n const documentId = published?._id || draft?._id || id\n const [count, setCount] = useState<number | null>(null)\n\n const client = useClient({ apiVersion: '2026-01-05' })\n\n useEffect(() => {\n if (!documentId) return\n\n const cleanId = documentId.replace(/^drafts\\./, '')\n\n // Get count of documents referencing this document\n client.fetch<number>(\n /* groq */`count(*[references($id) && !(_id in path(\"drafts.**\"))])`,\n { id: cleanId }\n ).then(setCount)\n\n // Subscribe to changes\n const subscription = client.listen(\n /* groq */`*[references($id)]`,\n { id: cleanId }\n ).subscribe(() => {\n client.fetch<number>(\n /* groq */`count(*[references($id) && !(_id in path(\"drafts.**\"))])`,\n { id: cleanId }\n ).then(setCount)\n })\n\n return () => subscription.unsubscribe()\n }, [documentId, client])\n\n // Don't show badge if no references or still loading\n if (count === null || count === 0) {\n return null\n }\n\n return {\n label: `${count} reference${count > 1 ? 's' : ''}`,\n title: `${count} document${count > 1 ? 's' : ''} reference this. See \"References\" tab.`,\n color: 'primary',\n }\n}\n","import { useMemo, useState, useEffect, useCallback } from 'react'\nimport { useClient, useSchema, Preview } from 'sanity'\nimport { type UserViewComponent } from 'sanity/structure'\nimport { useRouter } from 'sanity/router'\nimport { Box, Card, Flex, Inline, Stack, Text, TextInput, Select, Spinner, Button, Badge, Radio } from '@sanity/ui'\nimport { SearchIcon, SortIcon, LinkIcon } from '@sanity/icons'\n\ninterface ReferencingDocument {\n _id: string\n _type: string\n _updatedAt: string\n title?: string\n}\ntype SortOption = 'updatedAt' | 'type' | 'title'\n\n/**\n * Structure Builder view component that displays documents referencing the current document.\n * Use this component with S.view.component() in your structure configuration.\n *\n * @example\n * ```ts\n * import { references, referencesView } from 'sanity-plugin-references'\n * import { LinkIcon } from '@sanity/icons'\n *\n * S.document().views([\n * S.view.form(),\n * referencesView(S),\n * ])\n * ```\n */\nexport const ReferencesPane: UserViewComponent = (props) => {\n const { documentId } = props\n const client = useClient({ apiVersion: '2026-01-05' })\n const schema = useSchema()\n const router = useRouter()\n\n const [documents, setDocuments] = useState<ReferencingDocument[]>([])\n const [loading, setLoading] = useState(true)\n const [error, setError] = useState<string | null>(null)\n const [searchQuery, setSearchQuery] = useState('')\n const [typeFilter, setTypeFilter] = useState('all')\n const [sortBy, setSortBy] = useState<SortOption>('updatedAt')\n const [sortDesc, setSortDesc] = useState(true)\n\n const cleanId = useMemo(() => documentId.replace(/^drafts\\./, ''), [documentId])\n\n const fetchDocuments = useCallback(async () => {\n if (!cleanId) return\n setLoading(true)\n setError(null)\n try {\n const results = await client.fetch<ReferencingDocument[]>(\n `*[references($id) && !(_id in path(\"drafts.**\"))] {\n _id, _type, _updatedAt, \"title\": coalesce(title, name, \"Untitled\")\n } | order(_updatedAt desc)`,\n { id: cleanId }\n )\n setDocuments(results)\n } catch (err) {\n setError(err instanceof Error ? err.message : 'Failed to fetch references')\n } finally {\n setLoading(false)\n }\n }, [client, cleanId])\n\n useEffect(() => { fetchDocuments() }, [fetchDocuments])\n\n const documentTypes = useMemo(() =>\n [...new Set(documents.map((doc: ReferencingDocument) => doc._type))].sort(),\n [documents]\n )\n\n const filteredDocuments = useMemo(() => {\n const query = searchQuery.toLowerCase().trim()\n return documents\n .filter((doc: ReferencingDocument) => typeFilter === 'all' || doc._type === typeFilter)\n .filter((doc: ReferencingDocument) => !query || doc.title?.toLowerCase().includes(query) || doc._type.toLowerCase().includes(query))\n .sort((a: ReferencingDocument, b: ReferencingDocument) => {\n const cmp = sortBy === 'updatedAt'\n ? new Date(a._updatedAt).getTime() - new Date(b._updatedAt).getTime()\n : sortBy === 'type'\n ? a._type.localeCompare(b._type)\n : (a.title || '').localeCompare(b.title || '')\n return sortDesc ? -cmp : cmp\n })\n }, [documents, typeFilter, searchQuery, sortBy, sortDesc])\n\n const getSchemaType = useCallback((type: string) => schema.get(type), [schema])\n\n if (loading) {\n return <Flex align=\"center\" justify=\"center\" padding={5}><Spinner muted /></Flex>\n }\n\n if (error) {\n return <Card padding={4} tone=\"critical\"><Text>{error}</Text></Card>\n }\n\n if (documents.length === 0) {\n return (\n <Card padding={5}>\n <Flex align=\"center\" justify=\"center\" direction=\"column\" gap={3}>\n <LinkIcon style={{ fontSize: 32, opacity: 0.4 }} />\n <Text muted>No document reference this one</Text>\n </Flex>\n </Card>\n )\n }\n\n const useSelectForFilters = documentTypes.length > 5\n\n return (\n <Stack space={0} style={{ height: '100%' }}>\n {/* Toolbar */}\n <Card borderBottom padding={2}>\n <Flex gap={2} align=\"center\" wrap=\"nowrap\">\n {useSelectForFilters ? (\n <Select\n value={typeFilter}\n onChange={e => setTypeFilter(e.currentTarget.value)}\n fontSize={1}\n padding={2}\n style={{ minWidth: 150, flexShrink: 0 }}\n >\n <option value=\"all\">All types</option>\n {documentTypes.map((type: string) => {\n const schemaType = getSchemaType(type)\n return (\n <option key={type} value={type}>\n {schemaType?.title || type}\n </option>\n )\n })}\n </Select>\n ) : (\n <Inline space={2} style={{ flexShrink: 0 }}>\n <Flex align=\"center\" gap={1} as=\"label\" style={{ cursor: 'pointer', whiteSpace: 'nowrap' }}>\n <Radio checked={typeFilter === 'all'} onChange={() => setTypeFilter('all')} />\n <Text size={1} weight={typeFilter === 'all' ? 'semibold' : 'regular'}>All</Text>\n </Flex>\n {documentTypes.map((type: string) => {\n const schemaType = getSchemaType(type)\n return (\n <Flex key={type} align=\"center\" gap={1} as=\"label\" style={{ cursor: 'pointer', whiteSpace: 'nowrap' }}>\n <Radio checked={typeFilter === type} onChange={() => setTypeFilter(type)} />\n <Text size={1} weight={typeFilter === type ? 'semibold' : 'regular'}>\n {schemaType?.title || type}\n </Text>\n </Flex>\n )\n })}\n </Inline>\n )}\n <Box flex={1} />\n <Flex gap={1} style={{ flexShrink: 0 }}>\n <Select value={sortBy} onChange={e => setSortBy(e.currentTarget.value as SortOption)} fontSize={1} padding={2}>\n <option value=\"updatedAt\">Updated</option>\n <option value=\"type\">Type</option>\n <option value=\"title\">Title</option>\n </Select>\n <Button\n icon={SortIcon}\n mode=\"ghost\"\n onClick={() => setSortDesc((d: boolean) => !d)}\n title={sortDesc ? 'Descending' : 'Ascending'}\n fontSize={1}\n padding={2}\n />\n </Flex>\n </Flex>\n </Card>\n\n {/* Count + Search */}\n <Card borderBottom paddingX={3} paddingY={0}>\n <Flex align=\"stretch\" style={{ minHeight: 33 }}>\n <Flex align=\"center\" paddingY={2} style={{ flexShrink: 0 }}>\n <Text size={1} muted>\n {filteredDocuments.length} of {documents.length} document{documents.length !== 1 ? 's' : ''}\n </Text>\n </Flex>\n <Box style={{ flex: 1, minWidth: 180, borderLeft: '1px solid var(--card-border-color)', paddingLeft: 4, marginLeft: 12, display: 'flex', alignItems: 'center' }}>\n <TextInput\n icon={SearchIcon}\n placeholder=\"Search...\"\n value={searchQuery}\n onChange={e => setSearchQuery(e.currentTarget.value)}\n fontSize={1}\n padding={2}\n border={false}\n style={{ boxShadow: 'none', width: '100%' }}\n />\n </Box>\n </Flex>\n </Card>\n\n {/* Document list */}\n <Box style={{ flex: 1, overflow: 'auto' }}>\n <Stack space={1} padding={2}>\n {filteredDocuments.length === 0 ? (\n <Card padding={4}><Text muted align=\"center\">No documents match your filters</Text></Card>\n ) : (\n filteredDocuments.map((doc: ReferencingDocument) => {\n const schemaType = getSchemaType(doc._type)\n return (\n <Card\n key={doc._id}\n as=\"button\"\n padding={2}\n radius={2}\n onClick={() => router.navigateIntent('edit', { id: doc._id, type: doc._type })}\n style={{ cursor: 'pointer', textAlign: 'left', width: '100%' }}\n >\n <Flex gap={3} align=\"center\">\n <Box flex={1}>\n {schemaType ? (\n <Preview schemaType={schemaType} value={doc} layout=\"default\" />\n ) : (\n <Text weight=\"medium\">{doc.title}</Text>\n )}\n </Box>\n <Badge tone=\"primary\" fontSize={0}>{schemaType?.title || doc._type}</Badge>\n </Flex>\n </Card>\n )\n })\n )}\n </Stack>\n </Box>\n </Stack>\n )\n}\n","import { definePlugin } from 'sanity'\nimport type { StructureBuilder } from 'sanity/structure'\nimport { LinkIcon } from '@sanity/icons'\nimport type { ComponentType } from 'react'\nimport { ReferencesBadge } from './badge'\nimport { ReferencesPane } from './pane'\n\ninterface ReferencesConfig {\n /**\n * Document types to exclude from showing the References badge\n * @example ['media.tag', 'sanity.imageAsset']\n */\n exclude?: string[]\n}\n\n/**\n * Plugin that shows documents referencing the current document.\n * Displays a badge with the reference count.\n *\n * To add the References tab to your documents, use the Structure Builder API\n * with the exported `ReferencesPane` component.\n *\n * @example Basic usage (badge only)\n * ```ts\n * // sanity.config.ts\n * import { references } from 'sanity-plugin-references'\n *\n * export default defineConfig({\n * plugins: [references()],\n * })\n * ```\n *\n * @example Exclude specific document types from showing the badge\n * ```ts\n * references({ exclude: ['media.tag', 'sanity.imageAsset'] })\n * ```\n *\n * @example Adding the References tab with Structure Builder\n * ```ts\n * // sanity.config.ts\n * import { structureTool } from 'sanity/structure'\n * import { references, ReferencesPane } from 'sanity-plugin-references'\n * import { LinkIcon } from '@sanity/icons'\n *\n * export default defineConfig({\n * plugins: [\n * references(),\n * structureTool({\n * defaultDocumentNode: (S, { schemaType }) => {\n * return S.document().views([\n * S.view.form(),\n * S.view.component(ReferencesPane).title('References').icon(LinkIcon),\n * ])\n * },\n * }),\n * ],\n * })\n * ```\n *\n * @example Using the referencesView helper\n * ```ts\n * import { references, referencesView } from 'sanity-plugin-references'\n *\n * structureTool({\n * defaultDocumentNode: (S) => S.document().views([\n * S.view.form(),\n * referencesView(S),\n * ]),\n * })\n * ```\n */\nexport const references = definePlugin<ReferencesConfig | void>((config) => {\n const excludeTypes = config?.exclude || []\n\n return {\n name: 'references',\n document: {\n badges: (prev, context) => {\n // Don't show badge for excluded types\n if (excludeTypes.includes(context.schemaType)) {\n return prev\n }\n return [...prev, ReferencesBadge]\n },\n },\n }\n})\n\nexport type { ReferencesConfig }\n\n\n/**\n * Helper to create a References view for Structure Builder.\n *\n * @example\n * ```ts\n * S.document().views([\n * S.view.form(),\n * referencesView(S),\n * ])\n * ```\n *\n * @example With custom title and icon\n * ```ts\n * referencesView(S, { title: 'Incoming Links', icon: MyIcon })\n * ```\n */\nexport function referencesView(S: StructureBuilder, options?: {\n title?: string\n icon?: ComponentType\n}) {\n return S.view\n .component(ReferencesPane)\n .title(options?.title || 'References')\n .icon(options?.icon || LinkIcon)\n}\n\nexport { ReferencesPane } from './pane'\nexport { ReferencesBadge } from './badge'\nexport { LinkIcon as ReferencesIcon } from '@sanity/icons'\n"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "sanity-plugin-references",
3
+ "version": "1.0.0",
4
+ "description": "See which documents reference the current document in Sanity Studio",
5
+ "keywords": [
6
+ "sanity",
7
+ "sanity-plugin",
8
+ "references",
9
+ "incoming-references",
10
+ "document-references"
11
+ ],
12
+ "homepage": "https://github.com/flayks/sanity-plugin-references#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/flayks/sanity-plugin-references/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+ssh://git@github.com/flayks/sanity-plugin-references.git"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Félix Péault (Flayks) <hello@flayks.com>",
22
+ "sideEffects": false,
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "source": "./src/index.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "main": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "files": [
35
+ "dist",
36
+ "sanity.json",
37
+ "src",
38
+ "v2-incompatible.js"
39
+ ],
40
+ "scripts": {
41
+ "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
42
+ "format": "prettier --write --cache --ignore-unknown .",
43
+ "link-watch": "plugin-kit link-watch",
44
+ "lint": "eslint .",
45
+ "prepublishOnly": "npm run build",
46
+ "watch": "pkg-utils watch --strict"
47
+ },
48
+ "dependencies": {
49
+ "@sanity/icons": "^3.7.4",
50
+ "@sanity/incompatible-plugin": "^1.0.5",
51
+ "@sanity/ui": "^3.1.11",
52
+ "@types/react-dom": "^19.2.3",
53
+ "react-icons": "^5.5.0"
54
+ },
55
+ "devDependencies": {
56
+ "@sanity/pkg-utils": "^10.2.5",
57
+ "@sanity/plugin-kit": "^4.0.20",
58
+ "@semantic-release/changelog": "^6.0.3",
59
+ "@semantic-release/commit-analyzer": "^13.0.1",
60
+ "@semantic-release/git": "^10.0.1",
61
+ "@semantic-release/github": "^12.0.2",
62
+ "@semantic-release/npm": "^13.1.3",
63
+ "@semantic-release/release-notes-generator": "^14.1.0",
64
+ "@types/react": "^19.2.7",
65
+ "@typescript-eslint/eslint-plugin": "^8",
66
+ "@typescript-eslint/parser": "^8",
67
+ "eslint": "^9",
68
+ "eslint-config-prettier": "^10",
69
+ "eslint-config-sanity": "^7.1.4",
70
+ "eslint-plugin-prettier": "^5.5.4",
71
+ "eslint-plugin-react": "^7.37.5",
72
+ "eslint-plugin-react-hooks": "^7.0.1",
73
+ "prettier": "^3.7.4",
74
+ "prettier-plugin-packagejson": "^2.5.20",
75
+ "react": "^19.2.3",
76
+ "react-dom": "^19.2.3",
77
+ "sanity": "^5.1.0",
78
+ "semantic-release": "^25.0.2",
79
+ "styled-components": "^6.1.19",
80
+ "typescript": "^5.9"
81
+ },
82
+ "peerDependencies": {
83
+ "react": "^18 || ^19",
84
+ "react-dom": "^18 || ^19",
85
+ "sanity": "^3 || ^4 || ^5"
86
+ },
87
+ "engines": {
88
+ "node": ">=18"
89
+ },
90
+ "publishConfig": {
91
+ "exports": {
92
+ ".": {
93
+ "import": "./dist/index.js",
94
+ "default": "./dist/index.js"
95
+ },
96
+ "./package.json": "./package.json"
97
+ }
98
+ },
99
+ "browserslist": "extends @sanity/browserslist-config",
100
+ "sanityPlugin": {
101
+ "verifyPackage": {
102
+ "eslintImports": false
103
+ }
104
+ }
105
+ }
package/sanity.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "parts": [
3
+ {
4
+ "implements": "part:@sanity/base/sanity-root",
5
+ "path": "./v2-incompatible.js"
6
+ }
7
+ ]
8
+ }
package/src/badge.tsx ADDED
@@ -0,0 +1,49 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { type DocumentBadgeComponent, useClient } from 'sanity'
3
+
4
+ /**
5
+ * Badge component that displays the count of documents referencing the current document
6
+ */
7
+ export const ReferencesBadge: DocumentBadgeComponent = (props) => {
8
+ const { id, published, draft } = props
9
+ const documentId = published?._id || draft?._id || id
10
+ const [count, setCount] = useState<number | null>(null)
11
+
12
+ const client = useClient({ apiVersion: '2026-01-05' })
13
+
14
+ useEffect(() => {
15
+ if (!documentId) return
16
+
17
+ const cleanId = documentId.replace(/^drafts\./, '')
18
+
19
+ // Get count of documents referencing this document
20
+ client.fetch<number>(
21
+ /* groq */`count(*[references($id) && !(_id in path("drafts.**"))])`,
22
+ { id: cleanId }
23
+ ).then(setCount)
24
+
25
+ // Subscribe to changes
26
+ const subscription = client.listen(
27
+ /* groq */`*[references($id)]`,
28
+ { id: cleanId }
29
+ ).subscribe(() => {
30
+ client.fetch<number>(
31
+ /* groq */`count(*[references($id) && !(_id in path("drafts.**"))])`,
32
+ { id: cleanId }
33
+ ).then(setCount)
34
+ })
35
+
36
+ return () => subscription.unsubscribe()
37
+ }, [documentId, client])
38
+
39
+ // Don't show badge if no references or still loading
40
+ if (count === null || count === 0) {
41
+ return null
42
+ }
43
+
44
+ return {
45
+ label: `${count} reference${count > 1 ? 's' : ''}`,
46
+ title: `${count} document${count > 1 ? 's' : ''} reference this. See "References" tab.`,
47
+ color: 'primary',
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { definePlugin } from 'sanity'
2
+ import type { StructureBuilder } from 'sanity/structure'
3
+ import { LinkIcon } from '@sanity/icons'
4
+ import type { ComponentType } from 'react'
5
+ import { ReferencesBadge } from './badge'
6
+ import { ReferencesPane } from './pane'
7
+
8
+ interface ReferencesConfig {
9
+ /**
10
+ * Document types to exclude from showing the References badge
11
+ * @example ['media.tag', 'sanity.imageAsset']
12
+ */
13
+ exclude?: string[]
14
+ }
15
+
16
+ /**
17
+ * Plugin that shows documents referencing the current document.
18
+ * Displays a badge with the reference count.
19
+ *
20
+ * To add the References tab to your documents, use the Structure Builder API
21
+ * with the exported `ReferencesPane` component.
22
+ *
23
+ * @example Basic usage (badge only)
24
+ * ```ts
25
+ * // sanity.config.ts
26
+ * import { references } from 'sanity-plugin-references'
27
+ *
28
+ * export default defineConfig({
29
+ * plugins: [references()],
30
+ * })
31
+ * ```
32
+ *
33
+ * @example Exclude specific document types from showing the badge
34
+ * ```ts
35
+ * references({ exclude: ['media.tag', 'sanity.imageAsset'] })
36
+ * ```
37
+ *
38
+ * @example Adding the References tab with Structure Builder
39
+ * ```ts
40
+ * // sanity.config.ts
41
+ * import { structureTool } from 'sanity/structure'
42
+ * import { references, ReferencesPane } from 'sanity-plugin-references'
43
+ * import { LinkIcon } from '@sanity/icons'
44
+ *
45
+ * export default defineConfig({
46
+ * plugins: [
47
+ * references(),
48
+ * structureTool({
49
+ * defaultDocumentNode: (S, { schemaType }) => {
50
+ * return S.document().views([
51
+ * S.view.form(),
52
+ * S.view.component(ReferencesPane).title('References').icon(LinkIcon),
53
+ * ])
54
+ * },
55
+ * }),
56
+ * ],
57
+ * })
58
+ * ```
59
+ *
60
+ * @example Using the referencesView helper
61
+ * ```ts
62
+ * import { references, referencesView } from 'sanity-plugin-references'
63
+ *
64
+ * structureTool({
65
+ * defaultDocumentNode: (S) => S.document().views([
66
+ * S.view.form(),
67
+ * referencesView(S),
68
+ * ]),
69
+ * })
70
+ * ```
71
+ */
72
+ export const references = definePlugin<ReferencesConfig | void>((config) => {
73
+ const excludeTypes = config?.exclude || []
74
+
75
+ return {
76
+ name: 'references',
77
+ document: {
78
+ badges: (prev, context) => {
79
+ // Don't show badge for excluded types
80
+ if (excludeTypes.includes(context.schemaType)) {
81
+ return prev
82
+ }
83
+ return [...prev, ReferencesBadge]
84
+ },
85
+ },
86
+ }
87
+ })
88
+
89
+ export type { ReferencesConfig }
90
+
91
+
92
+ /**
93
+ * Helper to create a References view for Structure Builder.
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * S.document().views([
98
+ * S.view.form(),
99
+ * referencesView(S),
100
+ * ])
101
+ * ```
102
+ *
103
+ * @example With custom title and icon
104
+ * ```ts
105
+ * referencesView(S, { title: 'Incoming Links', icon: MyIcon })
106
+ * ```
107
+ */
108
+ export function referencesView(S: StructureBuilder, options?: {
109
+ title?: string
110
+ icon?: ComponentType
111
+ }) {
112
+ return S.view
113
+ .component(ReferencesPane)
114
+ .title(options?.title || 'References')
115
+ .icon(options?.icon || LinkIcon)
116
+ }
117
+
118
+ export { ReferencesPane } from './pane'
119
+ export { ReferencesBadge } from './badge'
120
+ export { LinkIcon as ReferencesIcon } from '@sanity/icons'
package/src/pane.tsx ADDED
@@ -0,0 +1,230 @@
1
+ import { useMemo, useState, useEffect, useCallback } from 'react'
2
+ import { useClient, useSchema, Preview } from 'sanity'
3
+ import { type UserViewComponent } from 'sanity/structure'
4
+ import { useRouter } from 'sanity/router'
5
+ import { Box, Card, Flex, Inline, Stack, Text, TextInput, Select, Spinner, Button, Badge, Radio } from '@sanity/ui'
6
+ import { SearchIcon, SortIcon, LinkIcon } from '@sanity/icons'
7
+
8
+ interface ReferencingDocument {
9
+ _id: string
10
+ _type: string
11
+ _updatedAt: string
12
+ title?: string
13
+ }
14
+ type SortOption = 'updatedAt' | 'type' | 'title'
15
+
16
+ /**
17
+ * Structure Builder view component that displays documents referencing the current document.
18
+ * Use this component with S.view.component() in your structure configuration.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { references, referencesView } from 'sanity-plugin-references'
23
+ * import { LinkIcon } from '@sanity/icons'
24
+ *
25
+ * S.document().views([
26
+ * S.view.form(),
27
+ * referencesView(S),
28
+ * ])
29
+ * ```
30
+ */
31
+ export const ReferencesPane: UserViewComponent = (props) => {
32
+ const { documentId } = props
33
+ const client = useClient({ apiVersion: '2026-01-05' })
34
+ const schema = useSchema()
35
+ const router = useRouter()
36
+
37
+ const [documents, setDocuments] = useState<ReferencingDocument[]>([])
38
+ const [loading, setLoading] = useState(true)
39
+ const [error, setError] = useState<string | null>(null)
40
+ const [searchQuery, setSearchQuery] = useState('')
41
+ const [typeFilter, setTypeFilter] = useState('all')
42
+ const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
43
+ const [sortDesc, setSortDesc] = useState(true)
44
+
45
+ const cleanId = useMemo(() => documentId.replace(/^drafts\./, ''), [documentId])
46
+
47
+ const fetchDocuments = useCallback(async () => {
48
+ if (!cleanId) return
49
+ setLoading(true)
50
+ setError(null)
51
+ try {
52
+ const results = await client.fetch<ReferencingDocument[]>(
53
+ `*[references($id) && !(_id in path("drafts.**"))] {
54
+ _id, _type, _updatedAt, "title": coalesce(title, name, "Untitled")
55
+ } | order(_updatedAt desc)`,
56
+ { id: cleanId }
57
+ )
58
+ setDocuments(results)
59
+ } catch (err) {
60
+ setError(err instanceof Error ? err.message : 'Failed to fetch references')
61
+ } finally {
62
+ setLoading(false)
63
+ }
64
+ }, [client, cleanId])
65
+
66
+ useEffect(() => { fetchDocuments() }, [fetchDocuments])
67
+
68
+ const documentTypes = useMemo(() =>
69
+ [...new Set(documents.map((doc: ReferencingDocument) => doc._type))].sort(),
70
+ [documents]
71
+ )
72
+
73
+ const filteredDocuments = useMemo(() => {
74
+ const query = searchQuery.toLowerCase().trim()
75
+ return documents
76
+ .filter((doc: ReferencingDocument) => typeFilter === 'all' || doc._type === typeFilter)
77
+ .filter((doc: ReferencingDocument) => !query || doc.title?.toLowerCase().includes(query) || doc._type.toLowerCase().includes(query))
78
+ .sort((a: ReferencingDocument, b: ReferencingDocument) => {
79
+ const cmp = sortBy === 'updatedAt'
80
+ ? new Date(a._updatedAt).getTime() - new Date(b._updatedAt).getTime()
81
+ : sortBy === 'type'
82
+ ? a._type.localeCompare(b._type)
83
+ : (a.title || '').localeCompare(b.title || '')
84
+ return sortDesc ? -cmp : cmp
85
+ })
86
+ }, [documents, typeFilter, searchQuery, sortBy, sortDesc])
87
+
88
+ const getSchemaType = useCallback((type: string) => schema.get(type), [schema])
89
+
90
+ if (loading) {
91
+ return <Flex align="center" justify="center" padding={5}><Spinner muted /></Flex>
92
+ }
93
+
94
+ if (error) {
95
+ return <Card padding={4} tone="critical"><Text>{error}</Text></Card>
96
+ }
97
+
98
+ if (documents.length === 0) {
99
+ return (
100
+ <Card padding={5}>
101
+ <Flex align="center" justify="center" direction="column" gap={3}>
102
+ <LinkIcon style={{ fontSize: 32, opacity: 0.4 }} />
103
+ <Text muted>No document reference this one</Text>
104
+ </Flex>
105
+ </Card>
106
+ )
107
+ }
108
+
109
+ const useSelectForFilters = documentTypes.length > 5
110
+
111
+ return (
112
+ <Stack space={0} style={{ height: '100%' }}>
113
+ {/* Toolbar */}
114
+ <Card borderBottom padding={2}>
115
+ <Flex gap={2} align="center" wrap="nowrap">
116
+ {useSelectForFilters ? (
117
+ <Select
118
+ value={typeFilter}
119
+ onChange={e => setTypeFilter(e.currentTarget.value)}
120
+ fontSize={1}
121
+ padding={2}
122
+ style={{ minWidth: 150, flexShrink: 0 }}
123
+ >
124
+ <option value="all">All types</option>
125
+ {documentTypes.map((type: string) => {
126
+ const schemaType = getSchemaType(type)
127
+ return (
128
+ <option key={type} value={type}>
129
+ {schemaType?.title || type}
130
+ </option>
131
+ )
132
+ })}
133
+ </Select>
134
+ ) : (
135
+ <Inline space={2} style={{ flexShrink: 0 }}>
136
+ <Flex align="center" gap={1} as="label" style={{ cursor: 'pointer', whiteSpace: 'nowrap' }}>
137
+ <Radio checked={typeFilter === 'all'} onChange={() => setTypeFilter('all')} />
138
+ <Text size={1} weight={typeFilter === 'all' ? 'semibold' : 'regular'}>All</Text>
139
+ </Flex>
140
+ {documentTypes.map((type: string) => {
141
+ const schemaType = getSchemaType(type)
142
+ return (
143
+ <Flex key={type} align="center" gap={1} as="label" style={{ cursor: 'pointer', whiteSpace: 'nowrap' }}>
144
+ <Radio checked={typeFilter === type} onChange={() => setTypeFilter(type)} />
145
+ <Text size={1} weight={typeFilter === type ? 'semibold' : 'regular'}>
146
+ {schemaType?.title || type}
147
+ </Text>
148
+ </Flex>
149
+ )
150
+ })}
151
+ </Inline>
152
+ )}
153
+ <Box flex={1} />
154
+ <Flex gap={1} style={{ flexShrink: 0 }}>
155
+ <Select value={sortBy} onChange={e => setSortBy(e.currentTarget.value as SortOption)} fontSize={1} padding={2}>
156
+ <option value="updatedAt">Updated</option>
157
+ <option value="type">Type</option>
158
+ <option value="title">Title</option>
159
+ </Select>
160
+ <Button
161
+ icon={SortIcon}
162
+ mode="ghost"
163
+ onClick={() => setSortDesc((d: boolean) => !d)}
164
+ title={sortDesc ? 'Descending' : 'Ascending'}
165
+ fontSize={1}
166
+ padding={2}
167
+ />
168
+ </Flex>
169
+ </Flex>
170
+ </Card>
171
+
172
+ {/* Count + Search */}
173
+ <Card borderBottom paddingX={3} paddingY={0}>
174
+ <Flex align="stretch" style={{ minHeight: 33 }}>
175
+ <Flex align="center" paddingY={2} style={{ flexShrink: 0 }}>
176
+ <Text size={1} muted>
177
+ {filteredDocuments.length} of {documents.length} document{documents.length !== 1 ? 's' : ''}
178
+ </Text>
179
+ </Flex>
180
+ <Box style={{ flex: 1, minWidth: 180, borderLeft: '1px solid var(--card-border-color)', paddingLeft: 4, marginLeft: 12, display: 'flex', alignItems: 'center' }}>
181
+ <TextInput
182
+ icon={SearchIcon}
183
+ placeholder="Search..."
184
+ value={searchQuery}
185
+ onChange={e => setSearchQuery(e.currentTarget.value)}
186
+ fontSize={1}
187
+ padding={2}
188
+ border={false}
189
+ style={{ boxShadow: 'none', width: '100%' }}
190
+ />
191
+ </Box>
192
+ </Flex>
193
+ </Card>
194
+
195
+ {/* Document list */}
196
+ <Box style={{ flex: 1, overflow: 'auto' }}>
197
+ <Stack space={1} padding={2}>
198
+ {filteredDocuments.length === 0 ? (
199
+ <Card padding={4}><Text muted align="center">No documents match your filters</Text></Card>
200
+ ) : (
201
+ filteredDocuments.map((doc: ReferencingDocument) => {
202
+ const schemaType = getSchemaType(doc._type)
203
+ return (
204
+ <Card
205
+ key={doc._id}
206
+ as="button"
207
+ padding={2}
208
+ radius={2}
209
+ onClick={() => router.navigateIntent('edit', { id: doc._id, type: doc._type })}
210
+ style={{ cursor: 'pointer', textAlign: 'left', width: '100%' }}
211
+ >
212
+ <Flex gap={3} align="center">
213
+ <Box flex={1}>
214
+ {schemaType ? (
215
+ <Preview schemaType={schemaType} value={doc} layout="default" />
216
+ ) : (
217
+ <Text weight="medium">{doc.title}</Text>
218
+ )}
219
+ </Box>
220
+ <Badge tone="primary" fontSize={0}>{schemaType?.title || doc._type}</Badge>
221
+ </Flex>
222
+ </Card>
223
+ )
224
+ })
225
+ )}
226
+ </Stack>
227
+ </Box>
228
+ </Stack>
229
+ )
230
+ }
@@ -0,0 +1,11 @@
1
+ const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
2
+ const {name, version, sanityExchangeUrl} = require('./package.json')
3
+
4
+ export default showIncompatiblePluginDialog({
5
+ name: name,
6
+ versions: {
7
+ v3: version,
8
+ v2: undefined,
9
+ },
10
+ sanityExchangeUrl,
11
+ })