Skip to content

Commit 67efd9f

Browse files
committed
added sanity preview button
1 parent f27cb68 commit 67efd9f

10 files changed

Lines changed: 344 additions & 17 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { NextRequest } from 'next/server';
2+
import { NextResponse } from 'next/server';
3+
import { v4 as uuidv4 } from 'uuid';
4+
import { createClient } from 'next-sanity';
5+
6+
// Set this in your environment variables for security
7+
const SHARED_SECRET = process.env.NEXT_PUBLIC_PREVIEW_TOKEN_SECRET;
8+
const sanityClient = createClient({
9+
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
10+
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
11+
apiVersion: '2025-09-22',
12+
token: process.env.SANITY_API_WRITE_TOKEN, // Must have write access
13+
useCdn: false,
14+
});
15+
16+
export async function POST(req: NextRequest) {
17+
const { documentId, secret } = await req.json();
18+
// check if authorized
19+
console.log('Received request to generate preview token for document ID:', documentId);
20+
console.log('Using secret:', secret);
21+
console.log('Expected secret:', SHARED_SECRET);
22+
23+
if (!documentId || !secret || secret !== SHARED_SECRET) {
24+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
25+
}
26+
const token = uuidv4();
27+
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 2).toISOString(); // 2 hours expiry
28+
29+
// Create previewSession document in Sanity
30+
await sanityClient.create({
31+
_type: 'previewSession',
32+
token,
33+
documentId,
34+
expiresAt,
35+
});
36+
37+
return NextResponse.json({ token, expiresAt });
38+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { NextRequest } from 'next/server';
2+
import { NextResponse } from 'next/server';
3+
import { createClient } from 'next-sanity';
4+
5+
const sanityClient = createClient({
6+
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
7+
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
8+
apiVersion: '2025-09-22',
9+
token: process.env.SANITY_API_TOKEN, // must have read access
10+
useCdn: false,
11+
});
12+
13+
export async function POST(req: NextRequest) {
14+
const { token } = await req.json();
15+
if (!token) {
16+
return NextResponse.json({ error: 'Token required' }, { status: 400 });
17+
}
18+
19+
// Find previewSession by token
20+
const session = await sanityClient.fetch(
21+
'*[_type == "previewSession" && token == $token && (!defined(expiresAt) || expiresAt > now())][0]',
22+
{ token }
23+
);
24+
25+
if (!session) {
26+
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 });
27+
}
28+
29+
// Fetch the draft document (post or podcast)
30+
const doc = await sanityClient.fetch(
31+
'*[_id == $docId && (_type == "post" || _type == "podcast")][0]',
32+
{ docId: session.documentId }
33+
);
34+
35+
if (!doc) {
36+
return NextResponse.json({ error: 'Document not found' }, { status: 404 });
37+
}
38+
39+
return NextResponse.json({ document: doc });
40+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"tailwind-merge": "^3.3.1",
106106
"tailwindcss-animate": "^1.0.7",
107107
"typescript": "5.9.2",
108+
"uuid": "^13.0.0",
108109
"vaul": "^1.1.2",
109110
"zod": "^4.1.8"
110111
},

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sanity.config.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ import {
3030

3131
import { apiVersion, dataset, projectId, studioUrl } from "@/sanity/lib/api";
3232
import { pageStructure, singletonPlugin } from "@/sanity/plugins/settings";
33+
import { sharePreviewAction } from "@/sanity/components/documentActions/sharePreviewAction";
3334
import { assistWithPresets } from "@/sanity/plugins/assist";
3435
import author from "@/sanity/schemas/documents/author";
36+
import previewSession from "@/sanity/schemas/previewSession";
3537
import course from "@/sanity/schemas/documents/course";
3638
import lesson from "@/sanity/schemas/documents/lesson";
3739
import guest from "@/sanity/schemas/documents/guest";
@@ -118,27 +120,35 @@ export const podcastStructure = (): StructureResolver => {
118120
]);
119121
};
120122
};
121-
122123
export default defineConfig({
123124
basePath: studioUrl,
124125
projectId,
125126
dataset,
126127
schema: {
127-
types: [
128-
// Singletons
129-
settings,
130-
// Documents
131-
author,
132-
course,
133-
lesson,
134-
guest,
135-
page,
136-
podcast,
137-
podcastType,
138-
post,
139-
sponsor,
140-
youtubeUpdateTask,
141-
],
128+
types: [
129+
// Singletons
130+
settings,
131+
// Documents
132+
author,
133+
course,
134+
lesson,
135+
guest,
136+
page,
137+
podcast,
138+
podcastType,
139+
post,
140+
sponsor,
141+
youtubeUpdateTask,
142+
previewSession,
143+
],
144+
},
145+
document: {
146+
actions: (prev, context) => {
147+
if (context.schemaType === 'post' || context.schemaType === 'podcast') {
148+
return [sharePreviewAction, ...prev];
149+
}
150+
return prev;
151+
},
142152
},
143153
plugins: [
144154
presentationTool({
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { useClient } from 'sanity';
3+
import { CopyIcon } from '@sanity/icons';
4+
import { Button, Dialog, Text, Stack, Card } from '@sanity/ui';
5+
import { RecycleIcon } from 'lucide-react';
6+
7+
interface SharePreviewActionButtonProps {
8+
id: string;
9+
type: string;
10+
onClose: () => void;
11+
}
12+
13+
const SharePreviewActionButton: React.FC<SharePreviewActionButtonProps> = ({ id, type, onClose }) => {
14+
const [shareUrl, setShareUrl] = useState<string | null>(null);
15+
const [loading, setLoading] = useState(false);
16+
const [error, setError] = useState<string | null>(null);
17+
const [copied, setCopied] = useState(false);
18+
const client = useClient({ apiVersion: '2025-09-22' });
19+
20+
const fetchShareUrl = async () => {
21+
setLoading(true);
22+
setError(null);
23+
setShareUrl(null);
24+
console.log('Generating preview link for document ID:', id);
25+
try {
26+
const res = await fetch('/api/generate-preview-token', {
27+
method: 'POST',
28+
headers: { 'Content-Type': 'application/json' },
29+
body: JSON.stringify({
30+
documentId: id,
31+
secret: process.env.NEXT_PUBLIC_PREVIEW_TOKEN_SECRET,
32+
}),
33+
});
34+
const data = await res.json();
35+
if (res.ok && data.token) {
36+
setShareUrl(`${window.location.origin}/preview/${data.token}`);
37+
} else {
38+
setError(data.error || 'Failed to generate link');
39+
}
40+
} catch (e) {
41+
setError('Error generating link');
42+
} finally {
43+
setLoading(false);
44+
}
45+
};
46+
47+
useEffect(() => {
48+
fetchShareUrl();
49+
// eslint-disable-next-line react-hooks/exhaustive-deps
50+
}, []);
51+
52+
return (
53+
<Dialog
54+
header="Shareable Preview Link"
55+
id="share-preview-dialog"
56+
width={1}
57+
onClose={onClose}
58+
>
59+
<Card padding={4}>
60+
<Stack space={3}>
61+
{loading && <Text>Generating link...</Text>}
62+
{!loading && shareUrl && <>
63+
<Text as="p">Copy and share this link:</Text>
64+
<Text as="code">{shareUrl}</Text>
65+
<Button
66+
text={copied ? 'Copied!' : 'Copy Link'}
67+
icon={CopyIcon}
68+
tone="primary"
69+
disabled={copied}
70+
onClick={async () => {
71+
if (shareUrl) {
72+
await navigator.clipboard.writeText(shareUrl);
73+
setCopied(true);
74+
setTimeout(() => setCopied(false), 1000);
75+
}
76+
}}
77+
style={{ marginTop: 8 }}
78+
/>
79+
<Button
80+
text="Regenerate"
81+
icon={RecycleIcon}
82+
tone="primary"
83+
onClick={async () => {
84+
await fetchShareUrl();
85+
}}
86+
style={{ marginTop: 8 }}
87+
/>
88+
</>}
89+
{!loading && error && (
90+
<Card padding={3} tone="critical" border radius={2} style={{ marginTop: 8 }}>
91+
<Text>{error}</Text>
92+
</Card>
93+
)}
94+
</Stack>
95+
</Card>
96+
</Dialog>
97+
);
98+
};
99+
100+
export default SharePreviewActionButton;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { DocumentActionComponent, DocumentActionProps } from 'sanity';
2+
import React, { useState } from 'react';
3+
import SharePreviewActionButton from './SharePreviewActionButton';
4+
import { ShareIcon } from '@sanity/icons';
5+
6+
7+
export const sharePreviewAction: DocumentActionComponent = (props: DocumentActionProps) => {
8+
const [open, setOpen] = React.useState(false);
9+
if (props.type !== 'post' && props.type !== 'podcast') return null;
10+
return {
11+
label: 'Share Preview',
12+
icon: () => <ShareIcon />,
13+
onHandle: () => setOpen(true),
14+
dialog: open && {
15+
type: 'dialog',
16+
onClose: () => {
17+
setOpen(false);
18+
},
19+
content: (
20+
<>
21+
{open ? (
22+
<SharePreviewActionButton
23+
id={props.id as string}
24+
type={props.type as string}
25+
onClose={() => {
26+
setOpen(false);
27+
props.onComplete();
28+
}}
29+
/>) : null}
30+
</>
31+
)
32+
}
33+
};
34+
};

sanity/extract.json

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,62 @@
11
[
2+
{
3+
"name": "previewSession",
4+
"type": "document",
5+
"attributes": {
6+
"_id": {
7+
"type": "objectAttribute",
8+
"value": {
9+
"type": "string"
10+
}
11+
},
12+
"_type": {
13+
"type": "objectAttribute",
14+
"value": {
15+
"type": "string",
16+
"value": "previewSession"
17+
}
18+
},
19+
"_createdAt": {
20+
"type": "objectAttribute",
21+
"value": {
22+
"type": "string"
23+
}
24+
},
25+
"_updatedAt": {
26+
"type": "objectAttribute",
27+
"value": {
28+
"type": "string"
29+
}
30+
},
31+
"_rev": {
32+
"type": "objectAttribute",
33+
"value": {
34+
"type": "string"
35+
}
36+
},
37+
"token": {
38+
"type": "objectAttribute",
39+
"value": {
40+
"type": "string"
41+
},
42+
"optional": true
43+
},
44+
"documentId": {
45+
"type": "objectAttribute",
46+
"value": {
47+
"type": "string"
48+
},
49+
"optional": true
50+
},
51+
"expiresAt": {
52+
"type": "objectAttribute",
53+
"value": {
54+
"type": "string"
55+
},
56+
"optional": true
57+
}
58+
}
59+
},
260
{
361
"name": "youtubeUpdateTask",
462
"type": "document",

0 commit comments

Comments
 (0)