create-atsdc-stack 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/.claude/settings.local.json +9 -0
- package/CONTRIBUTING.md +342 -0
- package/INSTALLATION.md +359 -0
- package/LICENSE +201 -0
- package/README.md +405 -0
- package/app/.astro/settings.json +5 -0
- package/app/.astro/types.d.ts +1 -0
- package/app/.env.example +17 -0
- package/app/README.md +251 -0
- package/app/astro.config.mjs +83 -0
- package/app/drizzle.config.ts +16 -0
- package/app/package.json +52 -0
- package/app/public/manifest.webmanifest +36 -0
- package/app/src/components/Card.astro +36 -0
- package/app/src/db/initialize.ts +107 -0
- package/app/src/db/schema.ts +72 -0
- package/app/src/db/validations.ts +158 -0
- package/app/src/env.d.ts +1 -0
- package/app/src/layouts/Layout.astro +63 -0
- package/app/src/lib/config.ts +36 -0
- package/app/src/lib/content-converter.ts +141 -0
- package/app/src/lib/dom-utils.ts +230 -0
- package/app/src/lib/exa-search.ts +269 -0
- package/app/src/pages/api/chat.ts +91 -0
- package/app/src/pages/api/posts.ts +350 -0
- package/app/src/pages/index.astro +87 -0
- package/app/src/styles/components/button.scss +152 -0
- package/app/src/styles/components/card.scss +180 -0
- package/app/src/styles/components/form.scss +240 -0
- package/app/src/styles/global.scss +141 -0
- package/app/src/styles/pages/index.scss +80 -0
- package/app/src/styles/reset.scss +83 -0
- package/app/src/styles/variables/globals.scss +96 -0
- package/app/src/styles/variables/mixins.scss +238 -0
- package/app/tsconfig.json +45 -0
- package/bin/cli.js +1138 -0
- package/package.json +37 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exa Search Integration
|
|
3
|
+
* Provides AI-powered search capabilities using Exa
|
|
4
|
+
* Documentation: https://docs.exa.ai
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import Exa from 'exa-js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Initialize Exa client
|
|
11
|
+
* Requires EXA_API_KEY environment variable
|
|
12
|
+
*/
|
|
13
|
+
function getExaClient(): Exa {
|
|
14
|
+
const apiKey = import.meta.env.EXA_API_KEY || process.env.EXA_API_KEY;
|
|
15
|
+
|
|
16
|
+
if (!apiKey) {
|
|
17
|
+
throw new Error('EXA_API_KEY environment variable is not set');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return new Exa(apiKey);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Search options for Exa
|
|
25
|
+
*/
|
|
26
|
+
export interface ExaSearchOptions {
|
|
27
|
+
/** Number of results to return (1-10, default: 10) */
|
|
28
|
+
numResults?: number;
|
|
29
|
+
|
|
30
|
+
/** Type of search: 'neural', 'keyword', or 'auto' (default: 'auto') */
|
|
31
|
+
type?: 'neural' | 'keyword' | 'auto';
|
|
32
|
+
|
|
33
|
+
/** Whether to use autoprompt to expand query (default: false) */
|
|
34
|
+
useAutoprompt?: boolean;
|
|
35
|
+
|
|
36
|
+
/** Category of content to search */
|
|
37
|
+
category?: 'company' | 'research paper' | 'news' | 'github' | 'tweet' | 'movie' | 'song' | 'personal site' | 'pdf';
|
|
38
|
+
|
|
39
|
+
/** Start crawl date (YYYY-MM-DD) */
|
|
40
|
+
startCrawlDate?: string;
|
|
41
|
+
|
|
42
|
+
/** End crawl date (YYYY-MM-DD) */
|
|
43
|
+
endCrawlDate?: string;
|
|
44
|
+
|
|
45
|
+
/** Start published date (YYYY-MM-DD) */
|
|
46
|
+
startPublishedDate?: string;
|
|
47
|
+
|
|
48
|
+
/** End published date (YYYY-MM-DD) */
|
|
49
|
+
endPublishedDate?: string;
|
|
50
|
+
|
|
51
|
+
/** Include domains (whitelist) */
|
|
52
|
+
includeDomains?: string[];
|
|
53
|
+
|
|
54
|
+
/** Exclude domains (blacklist) */
|
|
55
|
+
excludeDomains?: string[];
|
|
56
|
+
|
|
57
|
+
/** Whether to include page content */
|
|
58
|
+
contents?: {
|
|
59
|
+
text?: boolean | { maxCharacters?: number };
|
|
60
|
+
highlights?: boolean | { numSentences?: number; highlightsPerUrl?: number };
|
|
61
|
+
summary?: boolean | { query?: string };
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Exa search result
|
|
67
|
+
*/
|
|
68
|
+
export interface ExaResult {
|
|
69
|
+
url: string;
|
|
70
|
+
title: string;
|
|
71
|
+
publishedDate?: string;
|
|
72
|
+
author?: string;
|
|
73
|
+
score?: number;
|
|
74
|
+
id: string;
|
|
75
|
+
text?: string;
|
|
76
|
+
highlights?: string[];
|
|
77
|
+
summary?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Search the web using Exa
|
|
82
|
+
* @param query - The search query
|
|
83
|
+
* @param options - Search options
|
|
84
|
+
* @returns Array of search results
|
|
85
|
+
*/
|
|
86
|
+
export async function searchWeb(
|
|
87
|
+
query: string,
|
|
88
|
+
options: ExaSearchOptions = {}
|
|
89
|
+
): Promise<ExaResult[]> {
|
|
90
|
+
try {
|
|
91
|
+
const exa = getExaClient();
|
|
92
|
+
|
|
93
|
+
const response = await exa.searchAndContents(query, {
|
|
94
|
+
numResults: options.numResults || 10,
|
|
95
|
+
type: options.type || 'auto',
|
|
96
|
+
useAutoprompt: options.useAutoprompt || false,
|
|
97
|
+
category: options.category,
|
|
98
|
+
startCrawlDate: options.startCrawlDate,
|
|
99
|
+
endCrawlDate: options.endCrawlDate,
|
|
100
|
+
startPublishedDate: options.startPublishedDate,
|
|
101
|
+
endPublishedDate: options.endPublishedDate,
|
|
102
|
+
includeDomains: options.includeDomains,
|
|
103
|
+
excludeDomains: options.excludeDomains,
|
|
104
|
+
text: options.contents?.text,
|
|
105
|
+
highlights: options.contents?.highlights,
|
|
106
|
+
summary: options.contents?.summary,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return response.results.map((result: any) => ({
|
|
110
|
+
url: result.url,
|
|
111
|
+
title: result.title,
|
|
112
|
+
publishedDate: result.publishedDate,
|
|
113
|
+
author: result.author,
|
|
114
|
+
score: result.score,
|
|
115
|
+
id: result.id,
|
|
116
|
+
text: result.text,
|
|
117
|
+
highlights: result.highlights,
|
|
118
|
+
summary: result.summary,
|
|
119
|
+
}));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error('Exa search error:', error);
|
|
122
|
+
throw new Error('Failed to perform search');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Find similar content to a given URL
|
|
128
|
+
* @param url - The URL to find similar content for
|
|
129
|
+
* @param options - Search options
|
|
130
|
+
* @returns Array of similar results
|
|
131
|
+
*/
|
|
132
|
+
export async function findSimilar(
|
|
133
|
+
url: string,
|
|
134
|
+
options: Omit<ExaSearchOptions, 'type' | 'useAutoprompt'> = {}
|
|
135
|
+
): Promise<ExaResult[]> {
|
|
136
|
+
try {
|
|
137
|
+
const exa = getExaClient();
|
|
138
|
+
|
|
139
|
+
const response = await exa.findSimilarAndContents(url, {
|
|
140
|
+
numResults: options.numResults || 10,
|
|
141
|
+
category: options.category,
|
|
142
|
+
startCrawlDate: options.startCrawlDate,
|
|
143
|
+
endCrawlDate: options.endCrawlDate,
|
|
144
|
+
startPublishedDate: options.startPublishedDate,
|
|
145
|
+
endPublishedDate: options.endPublishedDate,
|
|
146
|
+
includeDomains: options.includeDomains,
|
|
147
|
+
excludeDomains: options.excludeDomains,
|
|
148
|
+
text: options.contents?.text,
|
|
149
|
+
highlights: options.contents?.highlights,
|
|
150
|
+
summary: options.contents?.summary,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return response.results.map((result: any) => ({
|
|
154
|
+
url: result.url,
|
|
155
|
+
title: result.title,
|
|
156
|
+
publishedDate: result.publishedDate,
|
|
157
|
+
author: result.author,
|
|
158
|
+
score: result.score,
|
|
159
|
+
id: result.id,
|
|
160
|
+
text: result.text,
|
|
161
|
+
highlights: result.highlights,
|
|
162
|
+
summary: result.summary,
|
|
163
|
+
}));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error('Exa find similar error:', error);
|
|
166
|
+
throw new Error('Failed to find similar content');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get full page content for a list of URLs
|
|
172
|
+
* @param urls - Array of URLs to get content for
|
|
173
|
+
* @param options - Content options
|
|
174
|
+
* @returns Array of results with content
|
|
175
|
+
*/
|
|
176
|
+
export async function getContents(
|
|
177
|
+
urls: string[],
|
|
178
|
+
options: ExaSearchOptions['contents'] = {}
|
|
179
|
+
): Promise<ExaResult[]> {
|
|
180
|
+
try {
|
|
181
|
+
const exa = getExaClient();
|
|
182
|
+
|
|
183
|
+
const response = await exa.getContents(urls, {
|
|
184
|
+
text: options.text,
|
|
185
|
+
highlights: options.highlights,
|
|
186
|
+
summary: options.summary,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return response.results.map((result: any) => ({
|
|
190
|
+
url: result.url,
|
|
191
|
+
title: result.title,
|
|
192
|
+
publishedDate: result.publishedDate,
|
|
193
|
+
author: result.author,
|
|
194
|
+
id: result.id,
|
|
195
|
+
text: result.text,
|
|
196
|
+
highlights: result.highlights,
|
|
197
|
+
summary: result.summary,
|
|
198
|
+
}));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error('Exa get contents error:', error);
|
|
201
|
+
throw new Error('Failed to get contents');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Search for recent news articles
|
|
207
|
+
* @param query - The search query
|
|
208
|
+
* @param daysBack - Number of days to look back (default: 7)
|
|
209
|
+
* @returns Array of news results
|
|
210
|
+
*/
|
|
211
|
+
export async function searchNews(query: string, daysBack: number = 7): Promise<ExaResult[]> {
|
|
212
|
+
const endDate = new Date();
|
|
213
|
+
const startDate = new Date();
|
|
214
|
+
startDate.setDate(startDate.getDate() - daysBack);
|
|
215
|
+
|
|
216
|
+
return searchWeb(query, {
|
|
217
|
+
category: 'news',
|
|
218
|
+
startPublishedDate: startDate.toISOString().split('T')[0],
|
|
219
|
+
endPublishedDate: endDate.toISOString().split('T')[0],
|
|
220
|
+
numResults: 10,
|
|
221
|
+
contents: {
|
|
222
|
+
text: { maxCharacters: 1000 },
|
|
223
|
+
highlights: { numSentences: 3 },
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Search for research papers
|
|
230
|
+
* @param query - The search query
|
|
231
|
+
* @param options - Additional search options
|
|
232
|
+
* @returns Array of research paper results
|
|
233
|
+
*/
|
|
234
|
+
export async function searchResearch(
|
|
235
|
+
query: string,
|
|
236
|
+
options: ExaSearchOptions = {}
|
|
237
|
+
): Promise<ExaResult[]> {
|
|
238
|
+
return searchWeb(query, {
|
|
239
|
+
...options,
|
|
240
|
+
category: 'research paper',
|
|
241
|
+
contents: {
|
|
242
|
+
text: { maxCharacters: 2000 },
|
|
243
|
+
summary: true,
|
|
244
|
+
...options.contents,
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Search GitHub repositories
|
|
251
|
+
* @param query - The search query
|
|
252
|
+
* @param options - Additional search options
|
|
253
|
+
* @returns Array of GitHub results
|
|
254
|
+
*/
|
|
255
|
+
export async function searchGitHub(
|
|
256
|
+
query: string,
|
|
257
|
+
options: ExaSearchOptions = {}
|
|
258
|
+
): Promise<ExaResult[]> {
|
|
259
|
+
return searchWeb(query, {
|
|
260
|
+
...options,
|
|
261
|
+
category: 'github',
|
|
262
|
+
includeDomains: ['github.com'],
|
|
263
|
+
contents: {
|
|
264
|
+
text: { maxCharacters: 1500 },
|
|
265
|
+
highlights: { numSentences: 5 },
|
|
266
|
+
...options.contents,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { streamText } from 'ai';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vercel AI SDK Chat API Route
|
|
7
|
+
* Uses AI Gateway pattern - no provider-specific packages needed!
|
|
8
|
+
* Just pass model strings like 'openai/gpt-4o' or 'anthropic/claude-3-5-sonnet-20241022'
|
|
9
|
+
*
|
|
10
|
+
* Environment variables required:
|
|
11
|
+
* - OPENAI_API_KEY: Your OpenAI API key (or other provider keys)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Validate request schema
|
|
15
|
+
const requestSchema = z.object({
|
|
16
|
+
messages: z.array(
|
|
17
|
+
z.object({
|
|
18
|
+
role: z.enum(['user', 'assistant', 'system']),
|
|
19
|
+
content: z.string(),
|
|
20
|
+
})
|
|
21
|
+
),
|
|
22
|
+
model: z.string().optional().default('openai/gpt-4o'),
|
|
23
|
+
temperature: z.number().min(0).max(2).optional().default(0.7),
|
|
24
|
+
maxTokens: z.number().positive().optional().default(1000),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
28
|
+
try {
|
|
29
|
+
// Validate API key
|
|
30
|
+
if (!import.meta.env.OPENAI_API_KEY && !process.env.OPENAI_API_KEY) {
|
|
31
|
+
return new Response(
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
error: 'OPENAI_API_KEY is not configured',
|
|
34
|
+
}),
|
|
35
|
+
{
|
|
36
|
+
status: 500,
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Parse and validate request body
|
|
45
|
+
const body = await request.json();
|
|
46
|
+
const validatedData = requestSchema.parse(body);
|
|
47
|
+
|
|
48
|
+
// Stream the response using Vercel AI SDK with AI Gateway
|
|
49
|
+
const result = streamText({
|
|
50
|
+
model: validatedData.model, // Use model string directly (e.g., 'openai/gpt-4o')
|
|
51
|
+
messages: validatedData.messages,
|
|
52
|
+
temperature: validatedData.temperature,
|
|
53
|
+
maxTokens: validatedData.maxTokens,
|
|
54
|
+
apiKey: import.meta.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Return the stream response
|
|
58
|
+
return result.toDataStreamResponse();
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// Handle Zod validation errors
|
|
61
|
+
if (error instanceof z.ZodError) {
|
|
62
|
+
return new Response(
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
error: 'Validation error',
|
|
65
|
+
details: error.errors,
|
|
66
|
+
}),
|
|
67
|
+
{
|
|
68
|
+
status: 400,
|
|
69
|
+
headers: {
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle other errors
|
|
77
|
+
console.error('Chat API error:', error);
|
|
78
|
+
return new Response(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
error: 'Internal server error',
|
|
81
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
82
|
+
}),
|
|
83
|
+
{
|
|
84
|
+
status: 500,
|
|
85
|
+
headers: {
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { db } from '@/db/initialize';
|
|
3
|
+
import { posts } from '@/db/schema';
|
|
4
|
+
import {
|
|
5
|
+
createPostSchema,
|
|
6
|
+
updatePostSchema,
|
|
7
|
+
postQuerySchema,
|
|
8
|
+
type CreatePostInput,
|
|
9
|
+
type UpdatePostInput,
|
|
10
|
+
} from '@/db/validations';
|
|
11
|
+
import { eq, desc, and, ilike, or } from 'drizzle-orm';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Posts API Routes
|
|
16
|
+
* Demonstrates CRUD operations with Drizzle ORM, Zod validation, and NanoID
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// GET /api/posts - List posts with pagination and filtering
|
|
20
|
+
export const GET: APIRoute = async ({ request, url }) => {
|
|
21
|
+
try {
|
|
22
|
+
// Parse and validate query parameters
|
|
23
|
+
const queryParams = {
|
|
24
|
+
page: url.searchParams.get('page'),
|
|
25
|
+
limit: url.searchParams.get('limit'),
|
|
26
|
+
published: url.searchParams.get('published'),
|
|
27
|
+
featured: url.searchParams.get('featured'),
|
|
28
|
+
authorId: url.searchParams.get('authorId'),
|
|
29
|
+
search: url.searchParams.get('search'),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const validated = postQuerySchema.parse(queryParams);
|
|
33
|
+
const offset = (validated.page - 1) * validated.limit;
|
|
34
|
+
|
|
35
|
+
// Build query conditions
|
|
36
|
+
const conditions = [];
|
|
37
|
+
|
|
38
|
+
if (validated.published !== undefined) {
|
|
39
|
+
conditions.push(eq(posts.published, validated.published));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (validated.featured !== undefined) {
|
|
43
|
+
conditions.push(eq(posts.featured, validated.featured));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (validated.authorId) {
|
|
47
|
+
conditions.push(eq(posts.authorId, validated.authorId));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (validated.search) {
|
|
51
|
+
conditions.push(
|
|
52
|
+
or(
|
|
53
|
+
ilike(posts.title, `%${validated.search}%`),
|
|
54
|
+
ilike(posts.content, `%${validated.search}%`)
|
|
55
|
+
)!
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fetch posts with filtering and pagination
|
|
60
|
+
const results = await db
|
|
61
|
+
.select()
|
|
62
|
+
.from(posts)
|
|
63
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
64
|
+
.orderBy(desc(posts.createdAt))
|
|
65
|
+
.limit(validated.limit)
|
|
66
|
+
.offset(offset);
|
|
67
|
+
|
|
68
|
+
return new Response(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
data: results,
|
|
71
|
+
meta: {
|
|
72
|
+
page: validated.page,
|
|
73
|
+
limit: validated.limit,
|
|
74
|
+
total: results.length,
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
{
|
|
78
|
+
status: 200,
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof z.ZodError) {
|
|
86
|
+
return new Response(
|
|
87
|
+
JSON.stringify({
|
|
88
|
+
error: 'Validation error',
|
|
89
|
+
details: error.errors,
|
|
90
|
+
}),
|
|
91
|
+
{
|
|
92
|
+
status: 400,
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.error('GET /api/posts error:', error);
|
|
101
|
+
return new Response(
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
error: 'Internal server error',
|
|
104
|
+
}),
|
|
105
|
+
{
|
|
106
|
+
status: 500,
|
|
107
|
+
headers: {
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// POST /api/posts - Create a new post
|
|
116
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
117
|
+
try {
|
|
118
|
+
const body = await request.json();
|
|
119
|
+
|
|
120
|
+
// Validate request body with Zod
|
|
121
|
+
const validated: CreatePostInput = createPostSchema.parse(body);
|
|
122
|
+
|
|
123
|
+
// Insert post into database (NanoID is auto-generated)
|
|
124
|
+
const [newPost] = await db
|
|
125
|
+
.insert(posts)
|
|
126
|
+
.values({
|
|
127
|
+
...validated,
|
|
128
|
+
publishedAt: validated.published ? new Date() : null,
|
|
129
|
+
})
|
|
130
|
+
.returning();
|
|
131
|
+
|
|
132
|
+
return new Response(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
data: newPost,
|
|
135
|
+
message: 'Post created successfully',
|
|
136
|
+
}),
|
|
137
|
+
{
|
|
138
|
+
status: 201,
|
|
139
|
+
headers: {
|
|
140
|
+
'Content-Type': 'application/json',
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof z.ZodError) {
|
|
146
|
+
return new Response(
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
error: 'Validation error',
|
|
149
|
+
details: error.errors,
|
|
150
|
+
}),
|
|
151
|
+
{
|
|
152
|
+
status: 400,
|
|
153
|
+
headers: {
|
|
154
|
+
'Content-Type': 'application/json',
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.error('POST /api/posts error:', error);
|
|
161
|
+
return new Response(
|
|
162
|
+
JSON.stringify({
|
|
163
|
+
error: 'Internal server error',
|
|
164
|
+
}),
|
|
165
|
+
{
|
|
166
|
+
status: 500,
|
|
167
|
+
headers: {
|
|
168
|
+
'Content-Type': 'application/json',
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// PUT /api/posts - Update an existing post
|
|
176
|
+
export const PUT: APIRoute = async ({ request }) => {
|
|
177
|
+
try {
|
|
178
|
+
const body = await request.json();
|
|
179
|
+
|
|
180
|
+
// Validate request body with Zod
|
|
181
|
+
const validated: UpdatePostInput = updatePostSchema.parse(body);
|
|
182
|
+
const { id, ...updateData } = validated;
|
|
183
|
+
|
|
184
|
+
if (!id) {
|
|
185
|
+
return new Response(
|
|
186
|
+
JSON.stringify({
|
|
187
|
+
error: 'Post ID is required',
|
|
188
|
+
}),
|
|
189
|
+
{
|
|
190
|
+
status: 400,
|
|
191
|
+
headers: {
|
|
192
|
+
'Content-Type': 'application/json',
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Update post in database
|
|
199
|
+
const [updatedPost] = await db
|
|
200
|
+
.update(posts)
|
|
201
|
+
.set({
|
|
202
|
+
...updateData,
|
|
203
|
+
updatedAt: new Date(),
|
|
204
|
+
publishedAt:
|
|
205
|
+
updateData.published !== undefined
|
|
206
|
+
? updateData.published
|
|
207
|
+
? new Date()
|
|
208
|
+
: null
|
|
209
|
+
: undefined,
|
|
210
|
+
})
|
|
211
|
+
.where(eq(posts.id, id))
|
|
212
|
+
.returning();
|
|
213
|
+
|
|
214
|
+
if (!updatedPost) {
|
|
215
|
+
return new Response(
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
error: 'Post not found',
|
|
218
|
+
}),
|
|
219
|
+
{
|
|
220
|
+
status: 404,
|
|
221
|
+
headers: {
|
|
222
|
+
'Content-Type': 'application/json',
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return new Response(
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
data: updatedPost,
|
|
231
|
+
message: 'Post updated successfully',
|
|
232
|
+
}),
|
|
233
|
+
{
|
|
234
|
+
status: 200,
|
|
235
|
+
headers: {
|
|
236
|
+
'Content-Type': 'application/json',
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error instanceof z.ZodError) {
|
|
242
|
+
return new Response(
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
error: 'Validation error',
|
|
245
|
+
details: error.errors,
|
|
246
|
+
}),
|
|
247
|
+
{
|
|
248
|
+
status: 400,
|
|
249
|
+
headers: {
|
|
250
|
+
'Content-Type': 'application/json',
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.error('PUT /api/posts error:', error);
|
|
257
|
+
return new Response(
|
|
258
|
+
JSON.stringify({
|
|
259
|
+
error: 'Internal server error',
|
|
260
|
+
}),
|
|
261
|
+
{
|
|
262
|
+
status: 500,
|
|
263
|
+
headers: {
|
|
264
|
+
'Content-Type': 'application/json',
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// DELETE /api/posts - Delete a post
|
|
272
|
+
export const DELETE: APIRoute = async ({ request, url }) => {
|
|
273
|
+
try {
|
|
274
|
+
const id = url.searchParams.get('id');
|
|
275
|
+
|
|
276
|
+
if (!id) {
|
|
277
|
+
return new Response(
|
|
278
|
+
JSON.stringify({
|
|
279
|
+
error: 'Post ID is required',
|
|
280
|
+
}),
|
|
281
|
+
{
|
|
282
|
+
status: 400,
|
|
283
|
+
headers: {
|
|
284
|
+
'Content-Type': 'application/json',
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Validate ID format (NanoID is 21 characters)
|
|
291
|
+
if (id.length !== 21) {
|
|
292
|
+
return new Response(
|
|
293
|
+
JSON.stringify({
|
|
294
|
+
error: 'Invalid post ID format',
|
|
295
|
+
}),
|
|
296
|
+
{
|
|
297
|
+
status: 400,
|
|
298
|
+
headers: {
|
|
299
|
+
'Content-Type': 'application/json',
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Delete post from database
|
|
306
|
+
const [deletedPost] = await db
|
|
307
|
+
.delete(posts)
|
|
308
|
+
.where(eq(posts.id, id))
|
|
309
|
+
.returning();
|
|
310
|
+
|
|
311
|
+
if (!deletedPost) {
|
|
312
|
+
return new Response(
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
error: 'Post not found',
|
|
315
|
+
}),
|
|
316
|
+
{
|
|
317
|
+
status: 404,
|
|
318
|
+
headers: {
|
|
319
|
+
'Content-Type': 'application/json',
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return new Response(
|
|
326
|
+
JSON.stringify({
|
|
327
|
+
message: 'Post deleted successfully',
|
|
328
|
+
}),
|
|
329
|
+
{
|
|
330
|
+
status: 200,
|
|
331
|
+
headers: {
|
|
332
|
+
'Content-Type': 'application/json',
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error('DELETE /api/posts error:', error);
|
|
338
|
+
return new Response(
|
|
339
|
+
JSON.stringify({
|
|
340
|
+
error: 'Internal server error',
|
|
341
|
+
}),
|
|
342
|
+
{
|
|
343
|
+
status: 500,
|
|
344
|
+
headers: {
|
|
345
|
+
'Content-Type': 'application/json',
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
};
|