diff --git a/package.json b/package.json index d12f8bf21..074e8aeb5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@million/lint": "^1.0.14", "@mux/mux-video-react": "^0.24.3", "@notionhq/client": "^5.13.0", + "@phosphor-icons/react": "^2.1.10", "@portabletext/react": "^6.0.3", "@portabletext/types": "^4.0.2", "@radix-ui/react-accordion": "^1.2.2", @@ -33,6 +34,7 @@ "@react-three/uikit-default": "1.0.60", "@sanity/client": "^7.20.0", "@sanity/image-url": "^2.0.3", + "@sanity/ui": "3.1.14", "@sanity/vision": "^5.18.0", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.48.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d431c75b3..3abd75fe5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@notionhq/client': specifier: ^5.13.0 version: 5.13.0 + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.0.1(react@19.0.1))(react@19.0.1) '@portabletext/react': specifier: ^6.0.3 version: 6.0.3(react@19.0.1) @@ -71,6 +74,9 @@ importers: '@sanity/image-url': specifier: ^2.0.3 version: 2.0.3 + '@sanity/ui': + specifier: 3.1.14 + version: 3.1.14(@emotion/is-prop-valid@1.4.0)(react-dom@19.0.1(react@19.0.1))(react-is@19.2.6)(react@19.0.1)(styled-components@6.4.1(css-to-react-native@3.2.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)) '@sanity/vision': specifier: ^5.18.0 version: 5.18.0(@babel/runtime@7.29.2)(@codemirror/lint@6.8.4)(@codemirror/theme-one-dark@6.1.3)(@emotion/is-prop-valid@1.4.0)(codemirror@6.0.2)(react-dom@19.0.1(react@19.0.1))(react-is@19.2.6)(react@19.0.1)(sanity@5.18.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.0.1)(@oclif/core@4.10.3)(@sanity/cli-core@1.2.1(@noble/hashes@2.0.1)(@sanity/telemetry@0.8.1(react@19.2.4))(@types/node@20.0.0)(jiti@2.6.1)(terser@5.37.0)(yaml@2.8.3))(@types/node@20.0.0)(@types/react-dom@19.0.0)(@types/react@19.0.0)(immer@11.1.8)(jiti@2.6.1)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(styled-components@6.4.1(css-to-react-native@3.2.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(terser@5.37.0)(ts-toolbelt@9.6.0)(typescript@5.8.2))(styled-components@6.4.1(css-to-react-native@3.2.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)) @@ -109,7 +115,7 @@ importers: version: 3.7.1 js-dos: specifier: ^8.3.20 - version: 8.3.20(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@4.2.1) + version: 8.3.20(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@5.0.1) leva: specifier: ^0.9.35 version: 0.9.35(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) @@ -2462,6 +2468,13 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -9633,8 +9646,8 @@ snapshots: '@babel/generator@7.26.10': dependencies: - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 @@ -9793,7 +9806,7 @@ snapshots: '@babel/helpers@7.26.10': dependencies: '@babel/template': 7.26.9 - '@babel/types': 7.27.0 + '@babel/types': 7.29.0 '@babel/helpers@7.29.2': dependencies: @@ -9802,7 +9815,7 @@ snapshots: '@babel/parser@7.27.0': dependencies: - '@babel/types': 7.27.0 + '@babel/types': 7.29.0 '@babel/parser@7.29.2': dependencies: @@ -10380,8 +10393,8 @@ snapshots: '@babel/template@7.26.9': dependencies: '@babel/code-frame': 7.26.2 - '@babel/parser': 7.27.0 - '@babel/types': 7.27.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@babel/template@7.28.6': dependencies: @@ -10393,9 +10406,9 @@ snapshots: dependencies: '@babel/code-frame': 7.26.2 '@babel/generator': 7.26.10 - '@babel/parser': 7.27.0 + '@babel/parser': 7.29.2 '@babel/template': 7.26.9 - '@babel/types': 7.27.0 + '@babel/types': 7.29.0 debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: @@ -11835,6 +11848,11 @@ snapshots: '@opentelemetry/api@1.9.0': optional: true + '@phosphor-icons/react@2.1.10(react-dom@19.0.1(react@19.0.1))(react@19.0.1)': + dependencies: + react: 19.0.1 + react-dom: 19.0.1(react@19.0.1) + '@pkgjs/parseargs@0.11.0': optional: true @@ -12729,7 +12747,7 @@ snapshots: - three - use-sync-external-store - '@reduxjs/toolkit@1.9.7(react-redux@8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@4.2.1))(react@19.0.1)': + '@reduxjs/toolkit@1.9.7(react-redux@8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@5.0.1))(react@19.0.1)': dependencies: immer: 9.0.21 redux: 4.2.1 @@ -12737,7 +12755,7 @@ snapshots: reselect: 4.1.8 optionalDependencies: react: 19.0.1 - react-redux: 8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@4.2.1) + react-redux: 8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@5.0.1) '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.0.0)(react@19.0.1)(redux@5.0.1))(react@19.0.1)': dependencies: @@ -13395,7 +13413,7 @@ snapshots: '@juggle/resize-observer': 3.4.0 '@sanity/color': 3.0.6 '@sanity/icons': 3.7.4(react@19.0.1) - csstype: 3.1.3 + csstype: 3.2.3 motion: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) react: 19.0.1 react-compiler-runtime: 1.0.0(react@19.0.1) @@ -13413,7 +13431,7 @@ snapshots: '@juggle/resize-observer': 3.4.0 '@sanity/color': 3.0.6 '@sanity/icons': 3.7.4(react@19.2.4) - csstype: 3.1.3 + csstype: 3.2.3 motion: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-compiler-runtime: 1.0.0(react@19.2.4) @@ -16451,13 +16469,13 @@ snapshots: jquery@3.7.1: {} - js-dos@8.3.20(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@4.2.1): + js-dos@8.3.20(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@5.0.1): dependencies: - '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@4.2.1))(react@19.0.1) + '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@5.0.1))(react@19.0.1) nipplejs: 0.10.2 preact: 10.26.5 react-checkbox-tree: 1.8.0(react@19.0.1) - react-redux: 8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@4.2.1) + react-redux: 8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@5.0.1) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -17695,7 +17713,7 @@ snapshots: react: 19.0.1 scheduler: 0.25.0 - react-redux@8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@4.2.1): + react-redux@8.1.3(@types/react-dom@19.0.0)(@types/react@19.0.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)(redux@5.0.1): dependencies: '@babel/runtime': 7.26.0 '@types/hoist-non-react-statics': 3.3.7(@types/react@19.0.0) @@ -17708,7 +17726,7 @@ snapshots: '@types/react': 19.0.0 '@types/react-dom': 19.0.0 react-dom: 19.0.1(react@19.0.1) - redux: 4.2.1 + redux: 5.0.1 react-redux@9.2.0(@types/react@19.0.0)(react@19.0.1)(redux@5.0.1): dependencies: diff --git a/sanity.config.ts b/sanity.config.ts index 9746348d5..f9c4dd49c 100644 --- a/sanity.config.ts +++ b/sanity.config.ts @@ -1,6 +1,25 @@ "use client" +import { + BriefcaseIcon, + BrowsersIcon, + CubeIcon, + FadersIcon, + FilesIcon, + FileTextIcon, + HeartIcon, + HouseIcon, + LightningIcon, + RocketIcon, + SquaresFourIcon, + StackIcon, + StarIcon, + TagIcon, + UserIcon, + UsersIcon +} from "@phosphor-icons/react/dist/ssr" import { visionTool } from "@sanity/vision" +import type { ComponentType } from "react" import { defineConfig } from "sanity" import { presentationTool } from "sanity/presentation" import type { StructureBuilder } from "sanity/structure" @@ -11,6 +30,10 @@ import { muxInput } from "sanity-plugin-mux-input" import { dataset, projectId } from "./sanity/env" import { resolve } from "./sanity/presentation/resolve" import { schemaTypes } from "./sanity/schemas" +import { CollectionPane } from "./sanity/studio/collection-pane" +import { createConfirmPublishAction } from "./sanity/studio/confirm-publish-action" + +const isProd = process.env.NODE_ENV === "production" const singletonTypes = new Set([ "homepage", @@ -26,6 +49,43 @@ const singletonTypes = new Set([ const singletonActions = new Set(["publish", "discardChanges", "restore"]) +// A searchable, live, draft-badged list pane for a multi-document collection. +// Replaces S.documentTypeListItem with the custom CollectionPane while keeping +// "Create new" and normal document editing intents working. +function collectionPane( + S: StructureBuilder, + schemaType: string, + title: string, + icon: ComponentType +) { + return S.listItem() + .title(title) + .icon(icon) + .child( + S.component() + .id(`collection-${schemaType}`) + .title(title) + .component(CollectionPane) + .options({ schemaType, icon }) + .canHandleIntent( + (intentName, params) => + (intentName === "edit" || intentName === "create") && + params.type === schemaType + ) + .menuItems([ + S.menuItem() + .title("Create new") + .icon(icon) + .intent({ type: "create", params: { type: schemaType } }) + ]) + .child((childId: string) => + S.document() + .schemaType(schemaType) + .documentId(childId.replace(/^drafts\./, "")) + ) + ) +} + function structure(S: StructureBuilder) { return S.list() .title("Content") @@ -33,6 +93,7 @@ function structure(S: StructureBuilder) { // --- Singletons (Pages) --- S.listItem() .title("Pages") + .icon(BrowsersIcon) .child( S.list() .title("Pages") @@ -40,12 +101,14 @@ function structure(S: StructureBuilder) { S.listItem() .title("Homepage") .id("homepage") + .icon(HouseIcon) .child( S.document().schemaType("homepage").documentId("homepage") ), S.listItem() .title("Services Page") .id("servicesPage") + .icon(RocketIcon) .child( S.document() .schemaType("servicesPage") @@ -54,12 +117,14 @@ function structure(S: StructureBuilder) { S.listItem() .title("People Page") .id("peoplePage") + .icon(UsersIcon) .child( S.document().schemaType("peoplePage").documentId("peoplePage") ), S.listItem() .title("Company Info") .id("companyInfo") + .icon(BriefcaseIcon) .child( S.document() .schemaType("companyInfo") @@ -68,6 +133,7 @@ function structure(S: StructureBuilder) { S.listItem() .title("3D Assets (DEPRECATED)") .id("threeDAssets") + .icon(CubeIcon) .child( S.document() .schemaType("threeDAssets") @@ -76,6 +142,7 @@ function structure(S: StructureBuilder) { S.listItem() .title("Showcase Page") .id("showcasePage") + .icon(StackIcon) .child( S.document() .schemaType("showcasePage") @@ -89,44 +156,50 @@ function structure(S: StructureBuilder) { // --- Content --- S.listItem() .title("Content") + .icon(FilesIcon) .child( S.list() .title("Content") .items([ - S.documentTypeListItem("post").title("Blog Posts"), - S.documentTypeListItem("postCategory").title("Post Categories"), - S.documentTypeListItem("project").title("Projects"), - S.documentTypeListItem("projectCategory").title( - "Project Categories" + collectionPane(S, "post", "Blog Posts", FileTextIcon), + collectionPane(S, "postCategory", "Post Categories", TagIcon), + collectionPane(S, "project", "Projects", StackIcon), + collectionPane( + S, + "projectCategory", + "Project Categories", + TagIcon ), - S.documentTypeListItem("labProject").title("Lab Projects") + collectionPane(S, "labProject", "Lab Projects", LightningIcon) ]) ), // --- Company --- S.listItem() .title("Company") + .icon(UsersIcon) .child( S.list() .title("Company") .items([ - S.documentTypeListItem("client").title("Clients"), - S.documentTypeListItem("person").title("People"), - S.documentTypeListItem("department").title("Departments"), - S.documentTypeListItem("award").title("Awards"), - S.documentTypeListItem("testimonial").title("Testimonials"), - S.documentTypeListItem("value").title("Values") + collectionPane(S, "client", "Clients", BriefcaseIcon), + collectionPane(S, "person", "People", UserIcon), + collectionPane(S, "department", "Departments", SquaresFourIcon), + collectionPane(S, "award", "Awards", StarIcon), + collectionPane(S, "testimonial", "Testimonials", HeartIcon), + collectionPane(S, "value", "Values", TagIcon) ]) ), // --- Careers --- S.listItem() .title("Careers") + .icon(RocketIcon) .child( S.list() .title("Careers") .items([ - S.documentTypeListItem("openPosition").title("Open Positions") + collectionPane(S, "openPosition", "Open Positions", BriefcaseIcon) ]) ), @@ -136,6 +209,7 @@ function structure(S: StructureBuilder) { // Binary files live in public/3d/; only editable content is here. S.listItem() .title("3D Config") + .icon(CubeIcon) .child( S.list() .title("3D Config") @@ -143,6 +217,7 @@ function structure(S: StructureBuilder) { S.listItem() .title("Inspectables") .id("inspectablesConfig") + .icon(FadersIcon) .child( S.document() .schemaType("inspectablesConfig") @@ -151,6 +226,7 @@ function structure(S: StructureBuilder) { S.listItem() .title("Scenes") .id("scenesConfig") + .icon(CubeIcon) .child( S.document() .schemaType("scenesConfig") @@ -159,6 +235,7 @@ function structure(S: StructureBuilder) { S.listItem() .title("Physics") .id("physicsConfig") + .icon(LightningIcon) .child( S.document() .schemaType("physicsConfig") @@ -175,6 +252,8 @@ export default defineConfig({ projectId, dataset, basePath: "/studio", + releases: { enabled: false }, + scheduledDrafts: { enabled: false }, plugins: [ structureTool({ structure }), presentationTool({ @@ -198,9 +277,20 @@ export default defineConfig({ templates.filter(({ schemaType }) => !singletonTypes.has(schemaType)) }, document: { - actions: (input, context) => - singletonTypes.has(context.schemaType) + actions: (input, context) => { + const filtered = singletonTypes.has(context.schemaType) ? input.filter(({ action }) => action && singletonActions.has(action)) : input + + // Production only: gate publishing behind a confirm dialog so changes + // can't go live by accident. Dev/preview publish stays frictionless. + if (!isProd) return filtered + + return filtered.map((action) => + action.action === "publish" + ? createConfirmPublishAction(action) + : action + ) + } } }) diff --git a/sanity/schemas/documents/openPosition.ts b/sanity/schemas/documents/openPosition.ts index b1e9477c5..5e69c2d2e 100644 --- a/sanity/schemas/documents/openPosition.ts +++ b/sanity/schemas/documents/openPosition.ts @@ -1,5 +1,7 @@ import { defineField, defineType } from "sanity" +import { isSlugUniqueForType } from "../utils/unique-slug" + export const openPosition = defineType({ name: "openPosition", title: "Open Position", @@ -16,7 +18,7 @@ export const openPosition = defineType({ name: "slug", title: "Slug", type: "slug", - options: { source: "title" }, + options: { source: "title", isUnique: isSlugUniqueForType }, validation: (rule) => rule.required() }), defineField({ diff --git a/sanity/schemas/documents/post.ts b/sanity/schemas/documents/post.ts index 88d5eb10b..8e6ec878f 100644 --- a/sanity/schemas/documents/post.ts +++ b/sanity/schemas/documents/post.ts @@ -1,5 +1,7 @@ import { defineField, defineType } from "sanity" +import { isSlugUniqueForType } from "../utils/unique-slug" + export const post = defineType({ name: "post", title: "Post", @@ -16,7 +18,7 @@ export const post = defineType({ name: "slug", title: "Slug", type: "slug", - options: { source: "title" }, + options: { source: "title", isUnique: isSlugUniqueForType }, validation: (rule) => rule.required() }), defineField({ diff --git a/sanity/schemas/documents/project.ts b/sanity/schemas/documents/project.ts index 74eded38f..5dd0bc9d4 100644 --- a/sanity/schemas/documents/project.ts +++ b/sanity/schemas/documents/project.ts @@ -1,5 +1,7 @@ import { defineField, defineType } from "sanity" +import { isSlugUniqueForType } from "../utils/unique-slug" + export const project = defineType({ name: "project", title: "Project", @@ -18,7 +20,7 @@ export const project = defineType({ name: "slug", title: "Slug", type: "slug", - options: { source: "title" }, + options: { source: "title", isUnique: isSlugUniqueForType }, validation: (rule) => rule.required() }), defineField({ diff --git a/sanity/schemas/utils/unique-slug.ts b/sanity/schemas/utils/unique-slug.ts new file mode 100644 index 000000000..17a8194ca --- /dev/null +++ b/sanity/schemas/utils/unique-slug.ts @@ -0,0 +1,30 @@ +import type { SlugValidationContext } from "sanity" + +import { apiVersion } from "../../env" + +// Per-type slug uniqueness for a slug field's `options.isUnique`. Counts other +// documents of the same type sharing the slug, ignoring the current doc's +// published+draft pair so editing in place doesn't flag itself. Sanity's +// built-in slug validator calls this and blocks publish when it returns false. +export async function isSlugUniqueForType( + slug: string, + context: SlugValidationContext +): Promise { + const { document, getClient } = context + const type = document?._type + const id = document?._id?.replace(/^drafts\./, "") + + if (!slug || !type || !id) return true + + const client = getClient({ apiVersion }) + const count = await client.fetch( + `count(*[ + _type == $type && + !(_id in [$id, "drafts." + $id]) && + slug.current == $slug + ])`, + { type, id, slug } + ) + + return count === 0 +} diff --git a/sanity/studio/collection-pane.tsx b/sanity/studio/collection-pane.tsx new file mode 100644 index 000000000..e5ac1f486 --- /dev/null +++ b/sanity/studio/collection-pane.tsx @@ -0,0 +1,269 @@ +import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/ssr" +import { + Badge, + Box, + Card, + Flex, + Spinner, + Stack, + Text, + TextInput +} from "@sanity/ui" +import { type ComponentType, useEffect, useRef, useState } from "react" +import { type SanityClient, useClient } from "sanity" +import { usePaneRouter } from "sanity/structure" + +import { apiVersion } from "../env" + +interface CollectionItem { + _id: string + _originalId?: string + title?: string | null + slug?: string | null + subtitle?: string | null + imageUrl?: string | null +} + +// `_originalId` is prefixed `drafts.` when an unpublished edit exists, so it's +// our signal that the document has pending changes. +const isDraftItem = (item: CollectionItem) => + !!item._originalId?.startsWith("drafts.") + +const normalizeId = (id: string) => id.replace(/^drafts\./, "") + +// Slug shown as a path; everything else (role, client) is a plain subtitle. +const getSubtitle = (item: CollectionItem) => + item.slug ? `/${item.slug}` : (item.subtitle ?? null) + +const THUMB_SIZE = 35 + +// `imageUrl` coalesces the common thumbnail fields across our document types +// (heroImage for posts, cover for projects, image/logo/avatar elsewhere). +const PROJECTION = `{ + _id, + _originalId, + "title": coalesce(title, name, "Untitled"), + "slug": slug.current, + "subtitle": coalesce(role, client->title), + "imageUrl": coalesce( + heroImage.asset->url, + cover.asset->url, + image.asset->url, + logo.asset->url, + avatar.asset->url + ) +}` + +const sortByTitle = (items: CollectionItem[]) => + [...items].sort((a, b) => (a.title ?? "").localeCompare(b.title ?? "")) + +interface CollectionPaneProps { + options?: Record +} + +// Searchable, live-updating list pane for a single document type, with a +// "Draft" badge for documents that have unpublished edits. Wired into the desk +// structure via S.component() — see sanity.config.ts. +export function CollectionPane({ options }: CollectionPaneProps) { + const schemaType = options?.schemaType as string + const FallbackIcon = options?.icon as ComponentType<{ size?: number }> + + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [query, setQuery] = useState("") + + const baseClient = useClient({ apiVersion }) + // Pinned to drafts so the list reflects in-progress edits and the badge. + const clientRef = useRef(null) + if (!clientRef.current) { + clientRef.current = baseClient.withConfig({ perspective: "drafts" }) + } + const client = clientRef.current + + const { ChildLink, routerPanesState, groupIndex } = usePaneRouter() + const activeChildId = routerPanesState[groupIndex + 1]?.[0]?.id ?? null + + useEffect(() => { + if (!schemaType) return + + setLoading(true) + let cancelled = false + let timer: ReturnType | undefined + + const fetchItems = () => { + client + .fetch(`*[_type == $type] ${PROJECTION}`, { + type: schemaType + }) + .then((data) => { + if (cancelled) return + setItems(data) + setLoading(false) + }) + .catch(() => { + if (!cancelled) setLoading(false) + }) + } + + fetchItems() + + // Refetch (debounced) on every mutation of this type to stay live. + const subscription = client + .listen( + `*[_type == $type]`, + { type: schemaType }, + { visibility: "query", events: ["mutation"], includeResult: false } + ) + .subscribe(() => { + clearTimeout(timer) + timer = setTimeout(fetchItems, 300) + }) + + return () => { + cancelled = true + clearTimeout(timer) + subscription.unsubscribe() + } + }, [schemaType, client]) + + const term = query.toLowerCase().trim() + const sorted = sortByTitle(items) + const filtered = term + ? sorted.filter( + (item) => + item.title?.toLowerCase().includes(term) || + item.slug?.toLowerCase().includes(term) || + item.subtitle?.toLowerCase().includes(term) + ) + : sorted + + return ( + + + setQuery(e.currentTarget.value)} + clearButton={!!query} + onClear={() => setQuery("")} + /> + + + + {loading && ( + + + + )} + {!loading && filtered.length === 0 && ( + + + {term ? "No matches found" : "Nothing here yet"} + + + )} + {!loading && filtered.length > 0 && ( + + {filtered.map((item) => ( + + ))} + + )} + + + ) +} + +interface ThumbnailProps { + imageUrl?: string | null + FallbackIcon?: ComponentType<{ size?: number }> +} + +const Thumbnail = ({ imageUrl, FallbackIcon }: ThumbnailProps) => ( + + {imageUrl ? ( + // Sanity CDN URL with crop params — plain img is correct inside Studio + // (not a Next.js render context). + + ) : FallbackIcon ? ( + + ) : null} + +) + +interface ItemRowProps { + item: CollectionItem + ChildLink: ReturnType["ChildLink"] + isActive: boolean + FallbackIcon?: ComponentType<{ size?: number }> +} + +const ItemRow = ({ item, ChildLink, isActive, FallbackIcon }: ItemRowProps) => { + const subtitle = getSubtitle(item) + return ( + + + + + + + + {item.title ?? "Untitled"} + + {isDraftItem(item) && ( + + Draft + + )} + + {subtitle && ( + + {subtitle} + + )} + + + + + ) +} diff --git a/sanity/studio/confirm-publish-action.ts b/sanity/studio/confirm-publish-action.ts new file mode 100644 index 000000000..9423919e7 --- /dev/null +++ b/sanity/studio/confirm-publish-action.ts @@ -0,0 +1,43 @@ +import { useState } from "react" +import type { + DocumentActionComponent, + DocumentActionDialogProps, + DocumentActionProps +} from "sanity" + +// Wraps the default publish action in a critical confirm dialog so editors get +// a deliberate warning before changes go live. Intended for production only — +// see sanity.config.ts, where the wrap is applied behind a NODE_ENV check. +export function createConfirmPublishAction( + originalPublishAction: DocumentActionComponent +): DocumentActionComponent { + const ConfirmPublishAction = (props: DocumentActionProps) => { + const original = originalPublishAction(props) + const [dialogOpen, setDialogOpen] = useState(false) + + if (!original) return null + + const { dialog: _originalDialog, ...originalWithoutDialog } = original + const dialog: DocumentActionDialogProps | false = dialogOpen + ? { + type: "confirm", + tone: "critical", + message: + "This will make these changes live on the production site immediately. Continue?", + onCancel: () => setDialogOpen(false), + onConfirm: () => { + setDialogOpen(false) + original.onHandle?.() + } + } + : false + + return { + ...originalWithoutDialog, + onHandle: () => setDialogOpen(true), + dialog + } + } + + return ConfirmPublishAction +}