add file storage, local and prod
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,3 +41,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# uploads
|
||||||
|
public/uploads
|
||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -68,6 +68,7 @@ src/
|
|||||||
├── auth-client.ts # Better Auth client hooks
|
├── auth-client.ts # Better Auth client hooks
|
||||||
├── db.ts # Database connection
|
├── db.ts # Database connection
|
||||||
├── schema.ts # Drizzle schema (users, sessions, etc.)
|
├── schema.ts # Drizzle schema (users, sessions, etc.)
|
||||||
|
├── storage.ts # File storage abstraction (Vercel Blob / local)
|
||||||
└── utils.ts # Utility functions (cn, etc.)
|
└── utils.ts # Utility functions (cn, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,6 +93,9 @@ OPENROUTER_MODEL=openai/gpt-5-mini # or any model from openrouter.ai/models
|
|||||||
|
|
||||||
# App
|
# App
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# File Storage (optional)
|
||||||
|
BLOB_READ_WRITE_TOKEN= # Leave empty for local dev, set for Vercel Blob in production
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
@@ -162,14 +166,22 @@ The project includes technical documentation in `docs/`:
|
|||||||
- Always run migrations after schema changes
|
- Always run migrations after schema changes
|
||||||
- PostgreSQL is the database (not SQLite, MySQL, etc.)
|
- PostgreSQL is the database (not SQLite, MySQL, etc.)
|
||||||
|
|
||||||
7. **Component Creation**
|
7. **File Storage**
|
||||||
|
|
||||||
|
- Use the storage abstraction from `@/lib/storage`
|
||||||
|
- Automatically uses local storage (dev) or Vercel Blob (production)
|
||||||
|
- Import: `import { upload, deleteFile } from "@/lib/storage"`
|
||||||
|
- Example: `const result = await upload(buffer, "avatar.png", "avatars")`
|
||||||
|
- Storage switches based on `BLOB_READ_WRITE_TOKEN` environment variable
|
||||||
|
|
||||||
|
8. **Component Creation**
|
||||||
|
|
||||||
- Use existing shadcn/ui components when possible
|
- Use existing shadcn/ui components when possible
|
||||||
- Follow the established patterns in `src/components/ui/`
|
- Follow the established patterns in `src/components/ui/`
|
||||||
- Support both light and dark modes
|
- Support both light and dark modes
|
||||||
- Use TypeScript with proper types
|
- Use TypeScript with proper types
|
||||||
|
|
||||||
8. **API Routes**
|
9. **API Routes**
|
||||||
- Follow Next.js 15 App Router conventions
|
- Follow Next.js 15 App Router conventions
|
||||||
- Use Route Handlers (route.ts files)
|
- Use Route Handlers (route.ts files)
|
||||||
- Return Response objects
|
- Return Response objects
|
||||||
@@ -217,6 +229,14 @@ The project includes technical documentation in `docs/`:
|
|||||||
3. Reference streaming docs: `docs/technical/ai/streaming.md`
|
3. Reference streaming docs: `docs/technical/ai/streaming.md`
|
||||||
4. Remember to use OpenRouter, not direct OpenAI
|
4. Remember to use OpenRouter, not direct OpenAI
|
||||||
|
|
||||||
|
**Working with file storage:**
|
||||||
|
|
||||||
|
1. Import storage functions: `import { upload, deleteFile } from "@/lib/storage"`
|
||||||
|
2. Upload files: `const result = await upload(fileBuffer, "filename.png", "folder")`
|
||||||
|
3. Delete files: `await deleteFile(result.url)`
|
||||||
|
4. Storage automatically uses local filesystem in dev, Vercel Blob in production
|
||||||
|
5. Local files are saved to `public/uploads/` and served at `/uploads/`
|
||||||
|
|
||||||
## Package Manager
|
## Package Manager
|
||||||
|
|
||||||
This project uses **pnpm** (see `pnpm-lock.yaml`). When running commands:
|
This project uses **pnpm** (see `pnpm-lock.yaml`). When running commands:
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -7,6 +7,7 @@ A complete agentic coding boilerplate with authentication, PostgreSQL database,
|
|||||||
- **🔐 Authentication**: Better Auth with Google OAuth integration
|
- **🔐 Authentication**: Better Auth with Google OAuth integration
|
||||||
- **🗃️ Database**: Drizzle ORM with PostgreSQL
|
- **🗃️ Database**: Drizzle ORM with PostgreSQL
|
||||||
- **🤖 AI Integration**: Vercel AI SDK with OpenRouter (access to 100+ AI models)
|
- **🤖 AI Integration**: Vercel AI SDK with OpenRouter (access to 100+ AI models)
|
||||||
|
- **📁 File Storage**: Automatic local/Vercel Blob storage with seamless switching
|
||||||
- **🎨 UI Components**: shadcn/ui with Tailwind CSS
|
- **🎨 UI Components**: shadcn/ui with Tailwind CSS
|
||||||
- **⚡ Modern Stack**: Next.js 15, React 19, TypeScript
|
- **⚡ Modern Stack**: Next.js 15, React 19, TypeScript
|
||||||
- **📱 Responsive**: Mobile-first design approach
|
- **📱 Responsive**: Mobile-first design approach
|
||||||
@@ -113,6 +114,11 @@ OPENROUTER_MODEL="openai/gpt-5-mini"
|
|||||||
|
|
||||||
# App URL (for production deployments)
|
# App URL (for production deployments)
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# File Storage (Optional - for file upload functionality)
|
||||||
|
# Leave empty to use local storage (public/uploads/) in development
|
||||||
|
# Set to enable Vercel Blob storage in production
|
||||||
|
BLOB_READ_WRITE_TOKEN=""
|
||||||
```
|
```
|
||||||
|
|
||||||
**4. Database Setup**
|
**4. Database Setup**
|
||||||
@@ -163,6 +169,25 @@ Your application will be available at [http://localhost:3000](http://localhost:3
|
|||||||
5. Copy the API key and add it to your `.env` file as `OPENROUTER_API_KEY`
|
5. Copy the API key and add it to your `.env` file as `OPENROUTER_API_KEY`
|
||||||
6. Browse available models at <a href="https://openrouter.ai/models" target="_blank">OpenRouter Models</a>
|
6. Browse available models at <a href="https://openrouter.ai/models" target="_blank">OpenRouter Models</a>
|
||||||
|
|
||||||
|
### File Storage Configuration
|
||||||
|
|
||||||
|
The project includes a flexible storage abstraction that automatically switches between local filesystem storage (development) and Vercel Blob storage (production).
|
||||||
|
|
||||||
|
**For Development (Local Storage):**
|
||||||
|
- Leave `BLOB_READ_WRITE_TOKEN` empty or unset in your `.env` file
|
||||||
|
- Files are automatically stored in `public/uploads/`
|
||||||
|
- Files are served at `/uploads/` URL path
|
||||||
|
- No external service or configuration needed
|
||||||
|
|
||||||
|
**For Production (Vercel Blob):**
|
||||||
|
1. Go to <a href="https://vercel.com/dashboard" target="_blank">Vercel Dashboard</a>
|
||||||
|
2. Navigate to your project → **Storage** tab
|
||||||
|
3. Click **Create** → **Blob**
|
||||||
|
4. Copy the `BLOB_READ_WRITE_TOKEN` from the integration
|
||||||
|
5. Add it to your production environment variables
|
||||||
|
|
||||||
|
The storage service automatically detects which backend to use based on the presence of the `BLOB_READ_WRITE_TOKEN` environment variable.
|
||||||
|
|
||||||
## 🗂️ Project Structure
|
## 🗂️ Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -182,6 +207,7 @@ src/
|
|||||||
├── auth-client.ts # Client-side auth utilities
|
├── auth-client.ts # Client-side auth utilities
|
||||||
├── db.ts # Database connection
|
├── db.ts # Database connection
|
||||||
├── schema.ts # Database schema
|
├── schema.ts # Database schema
|
||||||
|
├── storage.ts # File storage abstraction
|
||||||
└── utils.ts # General utilities
|
└── utils.ts # General utilities
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -236,6 +262,7 @@ Ensure these are set in your production environment:
|
|||||||
- `OPENROUTER_API_KEY` - OpenRouter API key (optional, for AI chat functionality)
|
- `OPENROUTER_API_KEY` - OpenRouter API key (optional, for AI chat functionality)
|
||||||
- `OPENROUTER_MODEL` - Model name from OpenRouter (optional, defaults to openai/gpt-5-mini)
|
- `OPENROUTER_MODEL` - Model name from OpenRouter (optional, defaults to openai/gpt-5-mini)
|
||||||
- `NEXT_PUBLIC_APP_URL` - Your production domain
|
- `NEXT_PUBLIC_APP_URL` - Your production domain
|
||||||
|
- `BLOB_READ_WRITE_TOKEN` - Vercel Blob token (optional, uses local storage if not set)
|
||||||
|
|
||||||
## 🎥 Tutorial Video
|
## 🎥 Tutorial Video
|
||||||
|
|
||||||
|
|||||||
4
create-agentic-app/package-lock.json
generated
4
create-agentic-app/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "create-agentic-app",
|
"name": "create-agentic-app",
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "create-agentic-app",
|
"name": "create-agentic-app",
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "create-agentic-app",
|
"name": "create-agentic-app",
|
||||||
"version": "1.1.17",
|
"version": "1.1.18",
|
||||||
"description": "Scaffold a new agentic AI application with Next.js, Better Auth, and AI SDK",
|
"description": "Scaffold a new agentic AI application with Next.js, Better Auth, and AI SDK",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ src/
|
|||||||
├── auth-client.ts # Better Auth client hooks
|
├── auth-client.ts # Better Auth client hooks
|
||||||
├── db.ts # Database connection
|
├── db.ts # Database connection
|
||||||
├── schema.ts # Drizzle schema (users, sessions, etc.)
|
├── schema.ts # Drizzle schema (users, sessions, etc.)
|
||||||
|
├── storage.ts # File storage abstraction (Vercel Blob / local)
|
||||||
└── utils.ts # Utility functions (cn, etc.)
|
└── utils.ts # Utility functions (cn, etc.)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,6 +93,9 @@ OPENROUTER_MODEL=openai/gpt-5-mini # or any model from openrouter.ai/models
|
|||||||
|
|
||||||
# App
|
# App
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# File Storage (optional)
|
||||||
|
BLOB_READ_WRITE_TOKEN= # Leave empty for local dev, set for Vercel Blob in production
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
@@ -162,14 +166,22 @@ The project includes technical documentation in `docs/`:
|
|||||||
- Always run migrations after schema changes
|
- Always run migrations after schema changes
|
||||||
- PostgreSQL is the database (not SQLite, MySQL, etc.)
|
- PostgreSQL is the database (not SQLite, MySQL, etc.)
|
||||||
|
|
||||||
7. **Component Creation**
|
7. **File Storage**
|
||||||
|
|
||||||
|
- Use the storage abstraction from `@/lib/storage`
|
||||||
|
- Automatically uses local storage (dev) or Vercel Blob (production)
|
||||||
|
- Import: `import { upload, deleteFile } from "@/lib/storage"`
|
||||||
|
- Example: `const result = await upload(buffer, "avatar.png", "avatars")`
|
||||||
|
- Storage switches based on `BLOB_READ_WRITE_TOKEN` environment variable
|
||||||
|
|
||||||
|
8. **Component Creation**
|
||||||
|
|
||||||
- Use existing shadcn/ui components when possible
|
- Use existing shadcn/ui components when possible
|
||||||
- Follow the established patterns in `src/components/ui/`
|
- Follow the established patterns in `src/components/ui/`
|
||||||
- Support both light and dark modes
|
- Support both light and dark modes
|
||||||
- Use TypeScript with proper types
|
- Use TypeScript with proper types
|
||||||
|
|
||||||
8. **API Routes**
|
9. **API Routes**
|
||||||
- Follow Next.js 15 App Router conventions
|
- Follow Next.js 15 App Router conventions
|
||||||
- Use Route Handlers (route.ts files)
|
- Use Route Handlers (route.ts files)
|
||||||
- Return Response objects
|
- Return Response objects
|
||||||
@@ -217,6 +229,14 @@ The project includes technical documentation in `docs/`:
|
|||||||
3. Reference streaming docs: `docs/technical/ai/streaming.md`
|
3. Reference streaming docs: `docs/technical/ai/streaming.md`
|
||||||
4. Remember to use OpenRouter, not direct OpenAI
|
4. Remember to use OpenRouter, not direct OpenAI
|
||||||
|
|
||||||
|
**Working with file storage:**
|
||||||
|
|
||||||
|
1. Import storage functions: `import { upload, deleteFile } from "@/lib/storage"`
|
||||||
|
2. Upload files: `const result = await upload(fileBuffer, "filename.png", "folder")`
|
||||||
|
3. Delete files: `await deleteFile(result.url)`
|
||||||
|
4. Storage automatically uses local filesystem in dev, Vercel Blob in production
|
||||||
|
5. Local files are saved to `public/uploads/` and served at `/uploads/`
|
||||||
|
|
||||||
## Package Manager
|
## Package Manager
|
||||||
|
|
||||||
This project uses **pnpm** (see `pnpm-lock.yaml`). When running commands:
|
This project uses **pnpm** (see `pnpm-lock.yaml`). When running commands:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ A complete agentic coding boilerplate with authentication, PostgreSQL database,
|
|||||||
- **🔐 Authentication**: Better Auth with Google OAuth integration
|
- **🔐 Authentication**: Better Auth with Google OAuth integration
|
||||||
- **🗃️ Database**: Drizzle ORM with PostgreSQL
|
- **🗃️ Database**: Drizzle ORM with PostgreSQL
|
||||||
- **🤖 AI Integration**: Vercel AI SDK with OpenRouter (access to 100+ AI models)
|
- **🤖 AI Integration**: Vercel AI SDK with OpenRouter (access to 100+ AI models)
|
||||||
|
- **📁 File Storage**: Automatic local/Vercel Blob storage with seamless switching
|
||||||
- **🎨 UI Components**: shadcn/ui with Tailwind CSS
|
- **🎨 UI Components**: shadcn/ui with Tailwind CSS
|
||||||
- **⚡ Modern Stack**: Next.js 15, React 19, TypeScript
|
- **⚡ Modern Stack**: Next.js 15, React 19, TypeScript
|
||||||
- **📱 Responsive**: Mobile-first design approach
|
- **📱 Responsive**: Mobile-first design approach
|
||||||
@@ -113,6 +114,11 @@ OPENROUTER_MODEL="openai/gpt-5-mini"
|
|||||||
|
|
||||||
# App URL (for production deployments)
|
# App URL (for production deployments)
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# File Storage (Optional - for file upload functionality)
|
||||||
|
# Leave empty to use local storage (public/uploads/) in development
|
||||||
|
# Set to enable Vercel Blob storage in production
|
||||||
|
BLOB_READ_WRITE_TOKEN=""
|
||||||
```
|
```
|
||||||
|
|
||||||
**4. Database Setup**
|
**4. Database Setup**
|
||||||
@@ -163,6 +169,25 @@ Your application will be available at [http://localhost:3000](http://localhost:3
|
|||||||
5. Copy the API key and add it to your `.env` file as `OPENROUTER_API_KEY`
|
5. Copy the API key and add it to your `.env` file as `OPENROUTER_API_KEY`
|
||||||
6. Browse available models at <a href="https://openrouter.ai/models" target="_blank">OpenRouter Models</a>
|
6. Browse available models at <a href="https://openrouter.ai/models" target="_blank">OpenRouter Models</a>
|
||||||
|
|
||||||
|
### File Storage Configuration
|
||||||
|
|
||||||
|
The project includes a flexible storage abstraction that automatically switches between local filesystem storage (development) and Vercel Blob storage (production).
|
||||||
|
|
||||||
|
**For Development (Local Storage):**
|
||||||
|
- Leave `BLOB_READ_WRITE_TOKEN` empty or unset in your `.env` file
|
||||||
|
- Files are automatically stored in `public/uploads/`
|
||||||
|
- Files are served at `/uploads/` URL path
|
||||||
|
- No external service or configuration needed
|
||||||
|
|
||||||
|
**For Production (Vercel Blob):**
|
||||||
|
1. Go to <a href="https://vercel.com/dashboard" target="_blank">Vercel Dashboard</a>
|
||||||
|
2. Navigate to your project → **Storage** tab
|
||||||
|
3. Click **Create** → **Blob**
|
||||||
|
4. Copy the `BLOB_READ_WRITE_TOKEN` from the integration
|
||||||
|
5. Add it to your production environment variables
|
||||||
|
|
||||||
|
The storage service automatically detects which backend to use based on the presence of the `BLOB_READ_WRITE_TOKEN` environment variable.
|
||||||
|
|
||||||
## 🗂️ Project Structure
|
## 🗂️ Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -182,6 +207,7 @@ src/
|
|||||||
├── auth-client.ts # Client-side auth utilities
|
├── auth-client.ts # Client-side auth utilities
|
||||||
├── db.ts # Database connection
|
├── db.ts # Database connection
|
||||||
├── schema.ts # Database schema
|
├── schema.ts # Database schema
|
||||||
|
├── storage.ts # File storage abstraction
|
||||||
└── utils.ts # General utilities
|
└── utils.ts # General utilities
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -236,6 +262,7 @@ Ensure these are set in your production environment:
|
|||||||
- `OPENROUTER_API_KEY` - OpenRouter API key (optional, for AI chat functionality)
|
- `OPENROUTER_API_KEY` - OpenRouter API key (optional, for AI chat functionality)
|
||||||
- `OPENROUTER_MODEL` - Model name from OpenRouter (optional, defaults to openai/gpt-5-mini)
|
- `OPENROUTER_MODEL` - Model name from OpenRouter (optional, defaults to openai/gpt-5-mini)
|
||||||
- `NEXT_PUBLIC_APP_URL` - Your production domain
|
- `NEXT_PUBLIC_APP_URL` - Your production domain
|
||||||
|
- `BLOB_READ_WRITE_TOKEN` - Vercel Blob token (optional, uses local storage if not set)
|
||||||
|
|
||||||
## 🎥 Tutorial Video
|
## 🎥 Tutorial Video
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# uploads
|
||||||
|
public/uploads
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@vercel/blob": "^2.0.0",
|
||||||
"ai": "^5.0.86",
|
"ai": "^5.0.86",
|
||||||
"better-auth": "^1.3.34",
|
"better-auth": "^1.3.34",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ interface DiagnosticsResponse {
|
|||||||
ai: {
|
ai: {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
};
|
};
|
||||||
|
storage: {
|
||||||
|
configured: boolean;
|
||||||
|
type: "local" | "remote";
|
||||||
|
};
|
||||||
overallStatus: StatusLevel;
|
overallStatus: StatusLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +119,10 @@ export async function GET(req: Request) {
|
|||||||
env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET;
|
env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET;
|
||||||
const aiConfigured = env.OPENROUTER_API_KEY; // We avoid live-calling the AI provider here
|
const aiConfigured = env.OPENROUTER_API_KEY; // We avoid live-calling the AI provider here
|
||||||
|
|
||||||
|
// Storage configuration check
|
||||||
|
const storageConfigured = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
|
||||||
|
const storageType: "local" | "remote" = storageConfigured ? "remote" : "local";
|
||||||
|
|
||||||
const overallStatus: StatusLevel = (() => {
|
const overallStatus: StatusLevel = (() => {
|
||||||
if (!env.POSTGRES_URL || !dbConnected || !schemaApplied) return "error";
|
if (!env.POSTGRES_URL || !dbConnected || !schemaApplied) return "error";
|
||||||
if (!authConfigured) return "error";
|
if (!authConfigured) return "error";
|
||||||
@@ -138,6 +146,10 @@ export async function GET(req: Request) {
|
|||||||
ai: {
|
ai: {
|
||||||
configured: aiConfigured,
|
configured: aiConfigured,
|
||||||
},
|
},
|
||||||
|
storage: {
|
||||||
|
configured: storageConfigured,
|
||||||
|
type: storageType,
|
||||||
|
},
|
||||||
overallStatus,
|
overallStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function Home() {
|
|||||||
<div className="relative pb-[56.25%] h-0 overflow-hidden rounded-lg border">
|
<div className="relative pb-[56.25%] h-0 overflow-hidden rounded-lg border">
|
||||||
<iframe
|
<iframe
|
||||||
className="absolute top-0 left-0 w-full h-full"
|
className="absolute top-0 left-0 w-full h-full"
|
||||||
src="https://www.youtube.com/embed/T0zFZsr_d0Q"
|
src="https://www.youtube.com/embed/JQ86N3WOAh4"
|
||||||
title="Agentic Coding Boilerplate Tutorial"
|
title="Agentic Coding Boilerplate Tutorial"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ type DiagnosticsResponse = {
|
|||||||
ai: {
|
ai: {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
};
|
};
|
||||||
|
storage: {
|
||||||
|
configured: boolean;
|
||||||
|
type: "local" | "remote";
|
||||||
|
};
|
||||||
overallStatus: "ok" | "warn" | "error";
|
overallStatus: "ok" | "warn" | "error";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,6 +106,16 @@ export function SetupChecklist() {
|
|||||||
? "Set OPENROUTER_API_KEY for AI chat"
|
? "Set OPENROUTER_API_KEY for AI chat"
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "storage",
|
||||||
|
label: "File storage (optional)",
|
||||||
|
ok: true, // Always considered "ok" since local storage works
|
||||||
|
detail: data?.storage
|
||||||
|
? data.storage.type === "remote"
|
||||||
|
? "Using Vercel Blob storage"
|
||||||
|
: "Using local storage (public/uploads/)"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const completed = steps.filter((s) => s.ok).length;
|
const completed = steps.filter((s) => s.ok).length;
|
||||||
|
|||||||
102
create-agentic-app/template/src/lib/storage.ts
Normal file
102
create-agentic-app/template/src/lib/storage.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { put, del } from "@vercel/blob";
|
||||||
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from uploading a file to storage
|
||||||
|
*/
|
||||||
|
export interface StorageResult {
|
||||||
|
url: string; // Public URL to access the file
|
||||||
|
pathname: string; // Path/key of the stored file
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a file to storage (Vercel Blob or local filesystem)
|
||||||
|
*
|
||||||
|
* @param buffer - File contents as a Buffer
|
||||||
|
* @param filename - Name of the file (e.g., "image.png")
|
||||||
|
* @param folder - Optional folder/prefix (e.g., "avatars")
|
||||||
|
* @returns StorageResult with url and pathname
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const result = await upload(fileBuffer, "avatar.png", "avatars");
|
||||||
|
* console.log(result.url); // https://blob.vercel.io/... or /uploads/avatars/avatar.png
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function upload(
|
||||||
|
buffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
folder?: string
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
|
||||||
|
|
||||||
|
if (hasVercelBlob) {
|
||||||
|
// Use Vercel Blob storage
|
||||||
|
const pathname = folder ? `${folder}/${filename}` : filename;
|
||||||
|
const blob = await put(pathname, buffer, {
|
||||||
|
access: "public",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: blob.url,
|
||||||
|
pathname: blob.pathname,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Use local filesystem storage
|
||||||
|
const uploadsDir = join(process.cwd(), "public", "uploads");
|
||||||
|
const targetDir = folder ? join(uploadsDir, folder) : uploadsDir;
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
if (!existsSync(targetDir)) {
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the file
|
||||||
|
const filepath = join(targetDir, filename);
|
||||||
|
await writeFile(filepath, buffer);
|
||||||
|
|
||||||
|
// Return local URL
|
||||||
|
const pathname = folder ? `${folder}/${filename}` : filename;
|
||||||
|
const url = `/uploads/${pathname}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file from storage
|
||||||
|
*
|
||||||
|
* @param url - The URL of the file to delete
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await deleteFile("https://blob.vercel.io/...");
|
||||||
|
* // or
|
||||||
|
* await deleteFile("/uploads/avatars/avatar.png");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function deleteFile(url: string): Promise<void> {
|
||||||
|
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
|
||||||
|
|
||||||
|
if (hasVercelBlob) {
|
||||||
|
// Delete from Vercel Blob
|
||||||
|
await del(url);
|
||||||
|
} else {
|
||||||
|
// Delete from local filesystem
|
||||||
|
// Extract pathname from URL (e.g., /uploads/avatars/avatar.png -> avatars/avatar.png)
|
||||||
|
const pathname = url.replace(/^\/uploads\//, "");
|
||||||
|
const filepath = join(process.cwd(), "public", "uploads", pathname);
|
||||||
|
|
||||||
|
// Only attempt to delete if file exists
|
||||||
|
if (existsSync(filepath)) {
|
||||||
|
const { unlink } = await import("fs/promises");
|
||||||
|
await unlink(filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@vercel/blob": "^2.0.0",
|
||||||
"ai": "^5.0.86",
|
"ai": "^5.0.86",
|
||||||
"better-auth": "^1.3.34",
|
"better-auth": "^1.3.34",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@@ -33,6 +33,9 @@ importers:
|
|||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.3
|
specifier: ^1.2.3
|
||||||
version: 1.2.3(@types/react@19.2.5)(react@19.2.0)
|
version: 1.2.3(@types/react@19.2.5)(react@19.2.0)
|
||||||
|
'@vercel/blob':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
ai:
|
ai:
|
||||||
specifier: ^5.0.86
|
specifier: ^5.0.86
|
||||||
version: 5.0.86(zod@4.1.12)
|
version: 5.0.86(zod@4.1.12)
|
||||||
@@ -662,6 +665,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@fastify/busboy@2.1.1':
|
||||||
|
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@floating-ui/core@1.7.3':
|
'@floating-ui/core@1.7.3':
|
||||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||||
|
|
||||||
@@ -1663,6 +1670,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@vercel/blob@2.0.0':
|
||||||
|
resolution: {integrity: sha512-oAj7Pdy83YKSwIaMFoM7zFeLYWRc+qUpW3PiDSblxQMnGFb43qs4bmfq7dr/+JIfwhs6PTwe1o2YBwKhyjWxXw==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@vercel/oidc@3.0.3':
|
'@vercel/oidc@3.0.3':
|
||||||
resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==}
|
resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
@@ -1768,6 +1779,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
async-retry@1.3.3:
|
||||||
|
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2728,6 +2742,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-buffer@2.0.5:
|
||||||
|
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
is-bun-module@2.0.0:
|
is-bun-module@2.0.0:
|
||||||
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
|
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
|
||||||
|
|
||||||
@@ -3622,6 +3640,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
retry@0.13.1:
|
||||||
|
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
|
||||||
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
rettime@0.7.0:
|
rettime@0.7.0:
|
||||||
resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==}
|
resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==}
|
||||||
|
|
||||||
@@ -3999,6 +4021,10 @@ packages:
|
|||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
undici@5.29.0:
|
||||||
|
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
|
||||||
|
engines: {node: '>=14.0'}
|
||||||
|
|
||||||
unicorn-magic@0.3.0:
|
unicorn-magic@0.3.0:
|
||||||
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
|
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4665,6 +4691,8 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@fastify/busboy@2.1.1': {}
|
||||||
|
|
||||||
'@floating-ui/core@1.7.3':
|
'@floating-ui/core@1.7.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.10
|
'@floating-ui/utils': 0.2.10
|
||||||
@@ -5629,6 +5657,14 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@vercel/blob@2.0.0':
|
||||||
|
dependencies:
|
||||||
|
async-retry: 1.3.3
|
||||||
|
is-buffer: 2.0.5
|
||||||
|
is-node-process: 1.2.0
|
||||||
|
throttleit: 2.1.0
|
||||||
|
undici: 5.29.0
|
||||||
|
|
||||||
'@vercel/oidc@3.0.3': {}
|
'@vercel/oidc@3.0.3': {}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
@@ -5758,6 +5794,10 @@ snapshots:
|
|||||||
|
|
||||||
async-function@1.0.0: {}
|
async-function@1.0.0: {}
|
||||||
|
|
||||||
|
async-retry@1.3.3:
|
||||||
|
dependencies:
|
||||||
|
retry: 0.13.1
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
@@ -6842,6 +6882,8 @@ snapshots:
|
|||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
|
|
||||||
|
is-buffer@2.0.5: {}
|
||||||
|
|
||||||
is-bun-module@2.0.0:
|
is-bun-module@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
@@ -7840,6 +7882,8 @@ snapshots:
|
|||||||
onetime: 7.0.0
|
onetime: 7.0.0
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
|
retry@0.13.1: {}
|
||||||
|
|
||||||
rettime@0.7.0: {}
|
rettime@0.7.0: {}
|
||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
@@ -8330,6 +8374,10 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
undici@5.29.0:
|
||||||
|
dependencies:
|
||||||
|
'@fastify/busboy': 2.1.1
|
||||||
|
|
||||||
unicorn-magic@0.3.0: {}
|
unicorn-magic@0.3.0: {}
|
||||||
|
|
||||||
unified@11.0.5:
|
unified@11.0.5:
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ interface DiagnosticsResponse {
|
|||||||
ai: {
|
ai: {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
};
|
};
|
||||||
|
storage: {
|
||||||
|
configured: boolean;
|
||||||
|
type: "local" | "remote";
|
||||||
|
};
|
||||||
overallStatus: StatusLevel;
|
overallStatus: StatusLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +119,10 @@ export async function GET(req: Request) {
|
|||||||
env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET;
|
env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET;
|
||||||
const aiConfigured = env.OPENROUTER_API_KEY; // We avoid live-calling the AI provider here
|
const aiConfigured = env.OPENROUTER_API_KEY; // We avoid live-calling the AI provider here
|
||||||
|
|
||||||
|
// Storage configuration check
|
||||||
|
const storageConfigured = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
|
||||||
|
const storageType: "local" | "remote" = storageConfigured ? "remote" : "local";
|
||||||
|
|
||||||
const overallStatus: StatusLevel = (() => {
|
const overallStatus: StatusLevel = (() => {
|
||||||
if (!env.POSTGRES_URL || !dbConnected || !schemaApplied) return "error";
|
if (!env.POSTGRES_URL || !dbConnected || !schemaApplied) return "error";
|
||||||
if (!authConfigured) return "error";
|
if (!authConfigured) return "error";
|
||||||
@@ -138,6 +146,10 @@ export async function GET(req: Request) {
|
|||||||
ai: {
|
ai: {
|
||||||
configured: aiConfigured,
|
configured: aiConfigured,
|
||||||
},
|
},
|
||||||
|
storage: {
|
||||||
|
configured: storageConfigured,
|
||||||
|
type: storageType,
|
||||||
|
},
|
||||||
overallStatus,
|
overallStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function Home() {
|
|||||||
<div className="relative pb-[56.25%] h-0 overflow-hidden rounded-lg border">
|
<div className="relative pb-[56.25%] h-0 overflow-hidden rounded-lg border">
|
||||||
<iframe
|
<iframe
|
||||||
className="absolute top-0 left-0 w-full h-full"
|
className="absolute top-0 left-0 w-full h-full"
|
||||||
src="https://www.youtube.com/embed/T0zFZsr_d0Q"
|
src="https://www.youtube.com/embed/JQ86N3WOAh4"
|
||||||
title="Agentic Coding Boilerplate Tutorial"
|
title="Agentic Coding Boilerplate Tutorial"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ type DiagnosticsResponse = {
|
|||||||
ai: {
|
ai: {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
};
|
};
|
||||||
|
storage: {
|
||||||
|
configured: boolean;
|
||||||
|
type: "local" | "remote";
|
||||||
|
};
|
||||||
overallStatus: "ok" | "warn" | "error";
|
overallStatus: "ok" | "warn" | "error";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,6 +106,16 @@ export function SetupChecklist() {
|
|||||||
? "Set OPENROUTER_API_KEY for AI chat"
|
? "Set OPENROUTER_API_KEY for AI chat"
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "storage",
|
||||||
|
label: "File storage (optional)",
|
||||||
|
ok: true, // Always considered "ok" since local storage works
|
||||||
|
detail: data?.storage
|
||||||
|
? data.storage.type === "remote"
|
||||||
|
? "Using Vercel Blob storage"
|
||||||
|
: "Using local storage (public/uploads/)"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const completed = steps.filter((s) => s.ok).length;
|
const completed = steps.filter((s) => s.ok).length;
|
||||||
|
|||||||
102
src/lib/storage.ts
Normal file
102
src/lib/storage.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { put, del } from "@vercel/blob";
|
||||||
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from uploading a file to storage
|
||||||
|
*/
|
||||||
|
export interface StorageResult {
|
||||||
|
url: string; // Public URL to access the file
|
||||||
|
pathname: string; // Path/key of the stored file
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a file to storage (Vercel Blob or local filesystem)
|
||||||
|
*
|
||||||
|
* @param buffer - File contents as a Buffer
|
||||||
|
* @param filename - Name of the file (e.g., "image.png")
|
||||||
|
* @param folder - Optional folder/prefix (e.g., "avatars")
|
||||||
|
* @returns StorageResult with url and pathname
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const result = await upload(fileBuffer, "avatar.png", "avatars");
|
||||||
|
* console.log(result.url); // https://blob.vercel.io/... or /uploads/avatars/avatar.png
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function upload(
|
||||||
|
buffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
folder?: string
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
|
||||||
|
|
||||||
|
if (hasVercelBlob) {
|
||||||
|
// Use Vercel Blob storage
|
||||||
|
const pathname = folder ? `${folder}/${filename}` : filename;
|
||||||
|
const blob = await put(pathname, buffer, {
|
||||||
|
access: "public",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: blob.url,
|
||||||
|
pathname: blob.pathname,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Use local filesystem storage
|
||||||
|
const uploadsDir = join(process.cwd(), "public", "uploads");
|
||||||
|
const targetDir = folder ? join(uploadsDir, folder) : uploadsDir;
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
if (!existsSync(targetDir)) {
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the file
|
||||||
|
const filepath = join(targetDir, filename);
|
||||||
|
await writeFile(filepath, buffer);
|
||||||
|
|
||||||
|
// Return local URL
|
||||||
|
const pathname = folder ? `${folder}/${filename}` : filename;
|
||||||
|
const url = `/uploads/${pathname}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file from storage
|
||||||
|
*
|
||||||
|
* @param url - The URL of the file to delete
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await deleteFile("https://blob.vercel.io/...");
|
||||||
|
* // or
|
||||||
|
* await deleteFile("/uploads/avatars/avatar.png");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function deleteFile(url: string): Promise<void> {
|
||||||
|
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
|
||||||
|
|
||||||
|
if (hasVercelBlob) {
|
||||||
|
// Delete from Vercel Blob
|
||||||
|
await del(url);
|
||||||
|
} else {
|
||||||
|
// Delete from local filesystem
|
||||||
|
// Extract pathname from URL (e.g., /uploads/avatars/avatar.png -> avatars/avatar.png)
|
||||||
|
const pathname = url.replace(/^\/uploads\//, "");
|
||||||
|
const filepath = join(process.cwd(), "public", "uploads", pathname);
|
||||||
|
|
||||||
|
// Only attempt to delete if file exists
|
||||||
|
if (existsSync(filepath)) {
|
||||||
|
const { unlink } = await import("fs/promises");
|
||||||
|
await unlink(filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user