Compare commits
No commits in common. "377a875eb8d693a857f3e7641695e8c0e29ad96b" and "866398e52e549429ac8bfeee604c8c9b0b24e45c" have entirely different histories.
377a875eb8
...
866398e52e
3
.cursorindexingignore
Normal file
3
.cursorindexingignore
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
|
||||
.specstory/**
|
||||
7
.env.local
Normal file
7
.env.local
Normal file
@ -0,0 +1,7 @@
|
||||
AGENT_1_URL=https://n8n.biohazardvfx.com/webhook/d2ab4653-a107-412c-a905-ccd80e5b76cd
|
||||
AGENT_1_NAME=Repoguide
|
||||
AGENT_1_DESCRIPTION=Documenting the development process.
|
||||
|
||||
AGENT_2_URL=https://n8n.biohazardvfx.com/webhook/0884bd10-256d-441c-971c-b9f1c8506fdf
|
||||
AGENT_2_NAME=Morgan
|
||||
AGENT_2_DESCRIPTION=System Prompt Designer
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
### Dependencies
|
||||
node_modules/
|
||||
|
||||
### Next.js build output
|
||||
.next/
|
||||
|
||||
### OpenNext build artifacts
|
||||
.open-next/
|
||||
dist/
|
||||
|
||||
### Environment files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
### Logs & diagnostics
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
### OS artifacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
4
.specstory/.gitignore
vendored
Normal file
4
.specstory/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
# SpecStory project identity file
|
||||
/.project.json
|
||||
# SpecStory explanation file
|
||||
/.what-is-this.md
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
234
AGENTS.md
Normal file
234
AGENTS.md
Normal file
@ -0,0 +1,234 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Multi-Agent Chat Interface** - A Next.js-based AI chat platform that supports multiple agents configured via environment variables. Users select an agent from a menu on first visit, then chat with that agent. The application integrates with n8n workflows via webhooks, features markdown rendering with syntax highlighting, image uploads, and an interactive diff tool for code changes.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start Next.js development server (http://localhost:3000)
|
||||
npm run build # Create production build
|
||||
npm start # Run production server
|
||||
npm run lint # Run ESLint checks
|
||||
```
|
||||
|
||||
**Note:** No testing framework is currently configured. Tests should be added when needed.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend:** Next.js 15.5.4 (App Router), React 19, TypeScript 5
|
||||
- **Styling:** Tailwind CSS 4.1.9 with PostCSS
|
||||
- **UI Components:** shadcn/ui (Radix UI primitives), Lucide icons
|
||||
- **Forms & Validation:** React Hook Form + Zod
|
||||
- **Markdown:** react-markdown with remark-gfm and rehype-highlight for code syntax
|
||||
- **Diffs:** Custom pipeline using `diff` library with Highlight.js for colored output
|
||||
- **Deployment:** Cloudflare Workers/Pages via OpenNextJS + Wrangler
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── agents/route.ts # GET endpoint - returns available agents from env vars
|
||||
│ │ └── chat/route.ts # POST endpoint - routes to selected agent's webhook
|
||||
│ ├── layout.tsx # Root layout with theme provider
|
||||
│ ├── page.tsx # Home page - conditionally renders AgentSelector or ChatInterface
|
||||
│ └── globals.css # Tailwind global styles
|
||||
├── components/
|
||||
│ ├── agent-selector.tsx # Agent selection menu (card-based UI)
|
||||
│ ├── chat-interface.tsx # Main chat UI component (client-side, per-agent)
|
||||
│ ├── markdown-renderer.tsx # Parses markdown + extracts diff-tool blocks
|
||||
│ ├── diff-display.tsx # Renders diffs with syntax highlighting
|
||||
│ ├── diff-tool.tsx # Diff tool wrapper component
|
||||
│ ├── header.tsx # App header with agent name and switch button
|
||||
│ ├── mode-toggle.tsx # Dark/light theme toggle
|
||||
│ ├── theme-provider.tsx # Theme context setup
|
||||
│ ├── DIFF_TOOL_USAGE.md # In-component documentation
|
||||
│ └── ui/ # shadcn/ui component library
|
||||
└── lib/
|
||||
├── types.ts # TypeScript types and interfaces (Agent, Message, ChatRequest, etc.)
|
||||
└── utils.ts # Utility functions (cn() for classname merging)
|
||||
```
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
### Agent Selection Flow
|
||||
```
|
||||
User visits site
|
||||
↓
|
||||
page.tsx checks localStorage for selected agent
|
||||
↓
|
||||
If no agent: Show AgentSelector
|
||||
│ - Fetches agents from GET /api/agents
|
||||
│ - Displays agent cards with name + description
|
||||
│ - On selection: saves agent to localStorage and shows ChatInterface
|
||||
↓
|
||||
If agent exists: Show ChatInterface with that agent
|
||||
```
|
||||
|
||||
### Multi-Agent API Pattern
|
||||
**GET /api/agents**
|
||||
- Reads environment variables `AGENT_1_URL`, `AGENT_1_NAME`, `AGENT_1_DESCRIPTION`, `AGENT_2_URL`, etc.
|
||||
- Returns array of available agents: `{ agents: Agent[] }`
|
||||
|
||||
**POST /api/chat**
|
||||
- **Request:** `{ message, timestamp, sessionId, agentId, images? }`
|
||||
- **Processing:**
|
||||
1. Validates agentId is provided
|
||||
2. Looks up webhook URL using `getAgentWebhookUrl(agentId)`
|
||||
3. Proxies request to agent's specific n8n webhook
|
||||
4. Forwards images (base64) if provided
|
||||
- **Response Format:** Newline-delimited JSON with two message types:
|
||||
- `"item"` - Text content rendered directly
|
||||
- `"tool_call"` - Structured tools like `{ name: "show_diff", input: {...} }`
|
||||
|
||||
### Diff Tool Pipeline
|
||||
```
|
||||
n8n webhook response (tool_call: show_diff)
|
||||
↓
|
||||
/api/chat/route.ts (converts to markdown code block format)
|
||||
↓
|
||||
MarkdownRenderer (regex extracts diff-tool code blocks)
|
||||
↓
|
||||
DiffDisplay (renders diffs with Highlight.js syntax highlighting)
|
||||
```
|
||||
|
||||
### Client-Side Architecture
|
||||
- **State Management:** React hooks (useState, useRef) + localStorage for persistence
|
||||
- **Agent Persistence:** Selected agent stored in `selected-agent` and `selected-agent-id`
|
||||
- **Session ID:** Per-agent format `chat-session-{agentId}` stored as `chat-session-{agentId}`
|
||||
- **Message Storage:** Per-agent messages stored as `chat-messages-{agentId}` in localStorage
|
||||
- **Image Handling:** Images converted to base64 and included in message payload
|
||||
- **Auto-scroll:** Maintains scroll position at latest message
|
||||
|
||||
### Markdown Processing Details
|
||||
- Custom regex parser in `MarkdownRenderer` extracts ` ```diff-tool ... ``` ` code blocks
|
||||
- Replaces diff-tool blocks with placeholders during markdown rendering
|
||||
- After markdown is rendered, dynamically inserts `<DiffDisplay>` components
|
||||
- Supports remark-gfm (GitHub-flavored markdown) and rehype-highlight for syntax coloring
|
||||
|
||||
## Key Files & Responsibilities
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/types.ts` | Centralized TypeScript types: Agent, Message, ChatRequest, ChatResponse, etc. |
|
||||
| `src/app/api/agents/route.ts` | GET endpoint - parses env vars and returns available agents |
|
||||
| `src/app/api/chat/route.ts` | POST endpoint - looks up agent webhook URL and proxies requests |
|
||||
| `src/app/page.tsx` | Home page - manages agent selection state, conditionally renders AgentSelector or ChatInterface |
|
||||
| `src/components/agent-selector.tsx` | Agent selection UI - fetches agents, displays cards, handles selection |
|
||||
| `src/components/chat-interface.tsx` | Main chat UI - handles per-agent messages, image uploads, streaming |
|
||||
| `src/components/header.tsx` | App header - displays agent name and "Switch Agent" button |
|
||||
| `src/components/markdown-renderer.tsx` | Parses markdown and extracts diff-tool blocks for custom rendering |
|
||||
| `src/components/diff-display.tsx` | Renders side-by-side diffs with syntax highlighting using Highlight.js |
|
||||
| `AGENT_DIFF_TOOL_SETUP.md` | Comprehensive guide for configuring n8n agents to output diff tools |
|
||||
| `src/components/DIFF_TOOL_USAGE.md` | In-component documentation for the diff tool feature |
|
||||
|
||||
## Theme & Styling
|
||||
|
||||
- **System:** Tailwind CSS 4 with CSS custom properties and OKLch color space
|
||||
- **Colors:** Warm light mode (cream/beige) and pure black dark mode
|
||||
- **Implementation:** `next-themes` with "light" and "dark" variants
|
||||
- **Toggle:** Mode toggle button in header
|
||||
- **Global Styles:** `src/app/globals.css` contains theme definitions and Tailwind imports
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- `tsconfig.json` - TypeScript strict mode, bundler module resolution, `@/*` alias to `src/`
|
||||
- `next.config.ts` - Next.js configuration
|
||||
- `open-next.config.ts` - Cloudflare-specific Next.js configuration
|
||||
- `wrangler.jsonc` - Wrangler (Cloudflare CLI) configuration
|
||||
- `eslint.config.mjs` - ESLint with Next.js core-web-vitals and TypeScript support
|
||||
- `components.json` - shadcn/ui component library configuration
|
||||
- `postcss.config.mjs` - Tailwind CSS plugin configuration
|
||||
|
||||
## n8n Webhook Integration
|
||||
|
||||
The chat endpoint routes to an n8n workflow via webhook. The workflow should:
|
||||
|
||||
1. Accept message input and context from the client
|
||||
2. Return newline-delimited JSON with messages in one of two formats:
|
||||
- `{ "type": "item", "content": "text content" }`
|
||||
- `{ "type": "tool_call", "name": "show_diff", "input": { "before": "...", "after": "...", "language": "..." } }`
|
||||
|
||||
**Note:** See `AGENT_DIFF_TOOL_SETUP.md` for detailed instructions on configuring n8n agents to use the diff tool feature.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Configuring Agents via Environment Variables
|
||||
**Local Development (.env.local):**
|
||||
```
|
||||
AGENT_1_URL=https://n8n.example.com/webhook/agent-1-uuid
|
||||
AGENT_1_NAME=Creative Writer
|
||||
AGENT_1_DESCRIPTION=An AI assistant for creative writing and brainstorming
|
||||
|
||||
AGENT_2_URL=https://n8n.example.com/webhook/agent-2-uuid
|
||||
AGENT_2_NAME=Code Reviewer
|
||||
AGENT_2_DESCRIPTION=An AI assistant for code review and refactoring
|
||||
```
|
||||
|
||||
**Cloudflare Deployment:**
|
||||
- Go to Cloudflare dashboard → Workers & Pages → your-project → Settings → Environment variables
|
||||
- Add the same AGENT_* variables above
|
||||
- Deploy to apply changes
|
||||
|
||||
### Adding a New Agent
|
||||
1. Add three environment variables:
|
||||
- `AGENT_N_URL` - webhook URL for the agent
|
||||
- `AGENT_N_NAME` - display name
|
||||
- `AGENT_N_DESCRIPTION` - short description
|
||||
2. On next page reload, new agent appears in AgentSelector
|
||||
3. No code changes needed
|
||||
|
||||
### Modifying Chat Messages or Display
|
||||
- **Chat UI:** `src/components/chat-interface.tsx`
|
||||
- **Rendering:** `src/components/markdown-renderer.tsx`
|
||||
- **State:** Message list stored in component state, persisted to localStorage per agent
|
||||
- **Per-agent persistence:** Messages saved as `chat-messages-{agentId}`
|
||||
|
||||
### Changing Theme Colors
|
||||
- Edit CSS custom properties in `src/app/globals.css`
|
||||
- Uses OKLch color space (perceptually uniform)
|
||||
- Dark mode variant defined with `@custom-variant dark`
|
||||
|
||||
### Adding New Tool Types (beyond show_diff)
|
||||
- **Step 1:** Handle in `/api/chat/route.ts` - convert tool_call to markdown format
|
||||
- **Step 2:** Add detection logic in `markdown-renderer.tsx` to extract new code block type
|
||||
- **Step 3:** Create new component similar to `DiffDisplay` and render dynamically
|
||||
|
||||
### Switching Between Agents
|
||||
- Users click "Switch Agent" button in header
|
||||
- Returns to AgentSelector menu
|
||||
- Previously selected agents/messages are preserved in localStorage per agent
|
||||
- No data loss when switching
|
||||
|
||||
## Notes for Future Development
|
||||
|
||||
### Multi-Agent Features
|
||||
- **Agent Switching:** Currently requires page reload to agent selector. Could add inline dropdown in header.
|
||||
- **Agent Management:** UI for adding/editing agents without env vars could improve UX
|
||||
- **Agent Verification:** Add health checks to verify agents' webhook URLs are reachable
|
||||
- **Agent Categories:** Could group agents by category/type for better organization
|
||||
|
||||
### Image Upload Enhancements
|
||||
- **Image Storage:** Currently base64 in memory; consider Cloudflare R2 for large images
|
||||
- **Image Preview:** Add full-screen image viewer for uploaded images
|
||||
- **Multi-file Upload:** Support multiple file types beyond images
|
||||
|
||||
### Performance & Scaling
|
||||
- **Message Virtualization:** Chat interface uses `useRef` for scroll - consider virtualizing long message lists
|
||||
- **Storage Size:** localStorage has 5-10MB limit; consider IndexedDB for longer conversations
|
||||
- **Streaming:** Current implementation reads entire response before parsing; consider streaming HTML as it arrives
|
||||
|
||||
### Testing & Quality
|
||||
- **Testing:** Consider adding Jest or Vitest for component and API testing
|
||||
- **Error Handling:** Enhance with retry logic for failed webhook calls and better error messages
|
||||
- **Logging:** Add structured logging for debugging multi-agent interactions
|
||||
|
||||
### Accessibility & UX
|
||||
- **Keyboard Navigation:** Verify keyboard navigation on agent selector cards and custom diff tool
|
||||
- **ARIA Labels:** Add aria-labels for screen readers on interactive elements
|
||||
- **Mobile Responsive:** Test agent selector and chat interface on mobile devices
|
||||
161
AGENT_DIFF_TOOL_SETUP.md
Normal file
161
AGENT_DIFF_TOOL_SETUP.md
Normal file
@ -0,0 +1,161 @@
|
||||
# Agent Diff Tool Setup Guide
|
||||
|
||||
This guide explains how to configure your agent (n8n workflow) to use the diff tool functionality.
|
||||
|
||||
## How It Works
|
||||
|
||||
The chat interface now supports diff tool calls that get converted into beautiful, interactive diff displays. When the agent wants to show code changes, it can call the `show_diff` tool, which will be automatically converted to the proper markdown format.
|
||||
|
||||
## Agent Tool Call Format
|
||||
|
||||
Your agent should return tool calls in this JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "show_diff",
|
||||
"args": {
|
||||
"oldCode": "function hello() {\n console.log('Hello, World!');\n}",
|
||||
"newCode": "function hello(name = 'World') {\n console.log(`Hello, ${name}!`);\n}",
|
||||
"title": "Updated hello function",
|
||||
"language": "javascript"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
- **oldCode** (required): The original code as a string with `\n` for line breaks
|
||||
- **newCode** (required): The modified code as a string with `\n` for line breaks
|
||||
- **title** (optional): A descriptive title for the diff (defaults to "Code Changes")
|
||||
- **language** (optional): The programming language for syntax highlighting (defaults to "text")
|
||||
|
||||
## n8n Workflow Configuration
|
||||
|
||||
### Option 1: Direct Tool Call Response
|
||||
|
||||
In your n8n workflow, when you want to show a diff, return:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "show_diff",
|
||||
"args": {
|
||||
"oldCode": "const x = 1;",
|
||||
"newCode": "const x = 1;\nconst y = 2;",
|
||||
"title": "Added new variable",
|
||||
"language": "javascript"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Mixed Response with Tool Calls
|
||||
|
||||
You can also mix regular text with tool calls:
|
||||
|
||||
```json
|
||||
{"type": "item", "content": "Here are the changes I made:\n\n"}
|
||||
|
||||
{"type": "tool_call", "name": "show_diff", "args": {"oldCode": "old code", "newCode": "new code", "title": "Changes"}}
|
||||
|
||||
{"type": "item", "content": "\n\nThese changes improve the functionality by..."}
|
||||
```
|
||||
|
||||
### Option 3: Inline Tool Call
|
||||
|
||||
You can also return a single JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "show_diff",
|
||||
"args": {
|
||||
"oldCode": "function example() {\n return 'old';\n}",
|
||||
"newCode": "function example() {\n return 'new and improved';\n}",
|
||||
"title": "Function improvement",
|
||||
"language": "javascript"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### 1. Code Refactoring
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "show_diff",
|
||||
"args": {
|
||||
"oldCode": "if (user) {\n return user.name;\n}",
|
||||
"newCode": "if (user && user.name) {\n return user.name;\n} else {\n return 'Anonymous';\n}",
|
||||
"title": "Added null safety check",
|
||||
"language": "javascript"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Bug Fix
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "show_diff",
|
||||
"args": {
|
||||
"oldCode": "const result = a + b;",
|
||||
"newCode": "const result = Number(a) + Number(b);",
|
||||
"title": "Fixed type coercion bug",
|
||||
"language": "javascript"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Feature Addition
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "show_diff",
|
||||
"args": {
|
||||
"oldCode": "function calculate(x) {\n return x * 2;\n}",
|
||||
"newCode": "function calculate(x, multiplier = 2) {\n return x * multiplier;\n}",
|
||||
"title": "Added configurable multiplier",
|
||||
"language": "javascript"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## n8n Node Configuration
|
||||
|
||||
In your n8n workflow, use a "Respond to Webhook" node with:
|
||||
|
||||
1. **Response Body**: Set to the JSON format above
|
||||
2. **Response Headers**: Set `Content-Type` to `application/json`
|
||||
3. **Response Code**: Set to `200`
|
||||
|
||||
## Testing
|
||||
|
||||
To test the diff tool:
|
||||
|
||||
1. Send a message to your agent asking for code changes
|
||||
2. Configure your agent to return a `show_diff` tool call
|
||||
3. The chat interface will automatically render the diff
|
||||
|
||||
## Error Handling
|
||||
|
||||
If the tool call is malformed, the API will return an error message instead of breaking. Make sure to:
|
||||
|
||||
- Include both `oldCode` and `newCode`
|
||||
- Use proper JSON formatting
|
||||
- Escape newlines as `\n`
|
||||
- Use valid JSON structure
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
You can also combine multiple tool calls in a single response by returning multiple JSON objects on separate lines:
|
||||
|
||||
```
|
||||
{"type": "item", "content": "Here's the first change:\n\n"}
|
||||
{"type": "tool_call", "name": "show_diff", "args": {...}}
|
||||
{"type": "item", "content": "\n\nAnd here's the second change:\n\n"}
|
||||
{"type": "tool_call", "name": "show_diff", "args": {...}}
|
||||
```
|
||||
|
||||
This allows you to show multiple diffs in a single response.
|
||||
134
BUILD.md
Normal file
134
BUILD.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Build & Deployment Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v18 or higher)
|
||||
- pnpm package manager
|
||||
- Cloudflare account with Wrangler CLI configured
|
||||
|
||||
## Development
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:3000`
|
||||
|
||||
## Building for Production
|
||||
|
||||
### 1. Build Next.js Application
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This runs `next build` and creates an optimized production build in `.next/`
|
||||
|
||||
### 2. Build for Cloudflare with OpenNext
|
||||
|
||||
```bash
|
||||
npx @opennextjs/cloudflare build
|
||||
```
|
||||
|
||||
This command:
|
||||
- Runs the Next.js build (if not already built)
|
||||
- Converts the Next.js output to Cloudflare Workers format
|
||||
- Creates the deployment-ready bundle in `.open-next/`
|
||||
- Generates the worker script and static assets
|
||||
|
||||
**Note:** You must run this step before deploying to Cloudflare. The standard `next build` alone is not sufficient.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy to Cloudflare Workers
|
||||
|
||||
```bash
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
Or specify an environment explicitly:
|
||||
|
||||
```bash
|
||||
npx wrangler deploy --env="" # Deploy to default environment
|
||||
```
|
||||
|
||||
The deployment will:
|
||||
- Upload new/modified static assets
|
||||
- Deploy the worker script
|
||||
- Provide a live URL (e.g., `https://inspiration-repo-agent.nicholaivogelfilms.workers.dev`)
|
||||
|
||||
## Complete Build & Deploy Workflow
|
||||
|
||||
```bash
|
||||
# 1. Build Next.js app and prepare for Cloudflare
|
||||
npx @opennextjs/cloudflare build
|
||||
|
||||
# 2. Deploy to Cloudflare
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Local Development
|
||||
|
||||
Create a `.env.local` file in the project root:
|
||||
|
||||
```env
|
||||
AGENT_1_URL=https://n8n.example.com/webhook/agent-1-uuid
|
||||
AGENT_1_NAME=Agent Name
|
||||
AGENT_1_DESCRIPTION=Description of the agent
|
||||
|
||||
AGENT_2_URL=https://n8n.example.com/webhook/agent-2-uuid
|
||||
AGENT_2_NAME=Another Agent
|
||||
AGENT_2_DESCRIPTION=Description of another agent
|
||||
```
|
||||
|
||||
### Production (Cloudflare)
|
||||
|
||||
Set environment variables in the Cloudflare dashboard:
|
||||
1. Go to Workers & Pages → `inspiration-repo-agent` → Settings → Environment Variables
|
||||
2. Add the same `AGENT_*` variables as above
|
||||
3. Redeploy for changes to take effect
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No updated asset files to upload"
|
||||
|
||||
This means the OpenNext build hasn't been run or hasn't picked up your changes. Run:
|
||||
|
||||
```bash
|
||||
npx @opennextjs/cloudflare build
|
||||
```
|
||||
|
||||
Then deploy again.
|
||||
|
||||
### ESLint Configuration Error
|
||||
|
||||
If you see ESLint errors during build, the build will still complete successfully. The linting step can be skipped if needed.
|
||||
|
||||
### Multiple Environments Warning
|
||||
|
||||
If you see a warning about multiple environments, explicitly specify the environment:
|
||||
|
||||
```bash
|
||||
npx wrangler deploy --env=""
|
||||
```
|
||||
|
||||
## Package Manager
|
||||
|
||||
This project uses **pnpm** (not npm). Always use `pnpm` commands:
|
||||
|
||||
- `pnpm install` - Install dependencies
|
||||
- `pnpm dev` - Start dev server
|
||||
- `pnpm build` - Build Next.js app
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 15.5.4 (App Router)
|
||||
- **Runtime:** Cloudflare Workers
|
||||
- **Adapter:** @opennextjs/cloudflare
|
||||
- **Styling:** Tailwind CSS 4.1.9
|
||||
- **Package Manager:** pnpm
|
||||
|
||||
234
CLAUDE.md
Normal file
234
CLAUDE.md
Normal file
@ -0,0 +1,234 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Multi-Agent Chat Interface** - A Next.js-based AI chat platform that supports multiple agents configured via environment variables. Users select an agent from a menu on first visit, then chat with that agent. The application integrates with n8n workflows via webhooks, features markdown rendering with syntax highlighting, image uploads, and an interactive diff tool for code changes.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start Next.js development server (http://localhost:3000)
|
||||
npm run build # Create production build
|
||||
npm start # Run production server
|
||||
npm run lint # Run ESLint checks
|
||||
```
|
||||
|
||||
**Note:** No testing framework is currently configured. Tests should be added when needed.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend:** Next.js 15.5.4 (App Router), React 19, TypeScript 5
|
||||
- **Styling:** Tailwind CSS 4.1.9 with PostCSS
|
||||
- **UI Components:** shadcn/ui (Radix UI primitives), Lucide icons
|
||||
- **Forms & Validation:** React Hook Form + Zod
|
||||
- **Markdown:** react-markdown with remark-gfm and rehype-highlight for code syntax
|
||||
- **Diffs:** Custom pipeline using `diff` library with Highlight.js for colored output
|
||||
- **Deployment:** Cloudflare Workers/Pages via OpenNextJS + Wrangler
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── agents/route.ts # GET endpoint - returns available agents from env vars
|
||||
│ │ └── chat/route.ts # POST endpoint - routes to selected agent's webhook
|
||||
│ ├── layout.tsx # Root layout with theme provider
|
||||
│ ├── page.tsx # Home page - conditionally renders AgentSelector or ChatInterface
|
||||
│ └── globals.css # Tailwind global styles
|
||||
├── components/
|
||||
│ ├── agent-selector.tsx # Agent selection menu (card-based UI)
|
||||
│ ├── chat-interface.tsx # Main chat UI component (client-side, per-agent)
|
||||
│ ├── markdown-renderer.tsx # Parses markdown + extracts diff-tool blocks
|
||||
│ ├── diff-display.tsx # Renders diffs with syntax highlighting
|
||||
│ ├── diff-tool.tsx # Diff tool wrapper component
|
||||
│ ├── header.tsx # App header with agent name and switch button
|
||||
│ ├── mode-toggle.tsx # Dark/light theme toggle
|
||||
│ ├── theme-provider.tsx # Theme context setup
|
||||
│ ├── DIFF_TOOL_USAGE.md # In-component documentation
|
||||
│ └── ui/ # shadcn/ui component library
|
||||
└── lib/
|
||||
├── types.ts # TypeScript types and interfaces (Agent, Message, ChatRequest, etc.)
|
||||
└── utils.ts # Utility functions (cn() for classname merging)
|
||||
```
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
### Agent Selection Flow
|
||||
```
|
||||
User visits site
|
||||
↓
|
||||
page.tsx checks localStorage for selected agent
|
||||
↓
|
||||
If no agent: Show AgentSelector
|
||||
│ - Fetches agents from GET /api/agents
|
||||
│ - Displays agent cards with name + description
|
||||
│ - On selection: saves agent to localStorage and shows ChatInterface
|
||||
↓
|
||||
If agent exists: Show ChatInterface with that agent
|
||||
```
|
||||
|
||||
### Multi-Agent API Pattern
|
||||
**GET /api/agents**
|
||||
- Reads environment variables `AGENT_1_URL`, `AGENT_1_NAME`, `AGENT_1_DESCRIPTION`, `AGENT_2_URL`, etc.
|
||||
- Returns array of available agents: `{ agents: Agent[] }`
|
||||
|
||||
**POST /api/chat**
|
||||
- **Request:** `{ message, timestamp, sessionId, agentId, images? }`
|
||||
- **Processing:**
|
||||
1. Validates agentId is provided
|
||||
2. Looks up webhook URL using `getAgentWebhookUrl(agentId)`
|
||||
3. Proxies request to agent's specific n8n webhook
|
||||
4. Forwards images (base64) if provided
|
||||
- **Response Format:** Newline-delimited JSON with two message types:
|
||||
- `"item"` - Text content rendered directly
|
||||
- `"tool_call"` - Structured tools like `{ name: "show_diff", input: {...} }`
|
||||
|
||||
### Diff Tool Pipeline
|
||||
```
|
||||
n8n webhook response (tool_call: show_diff)
|
||||
↓
|
||||
/api/chat/route.ts (converts to markdown code block format)
|
||||
↓
|
||||
MarkdownRenderer (regex extracts diff-tool code blocks)
|
||||
↓
|
||||
DiffDisplay (renders diffs with Highlight.js syntax highlighting)
|
||||
```
|
||||
|
||||
### Client-Side Architecture
|
||||
- **State Management:** React hooks (useState, useRef) + localStorage for persistence
|
||||
- **Agent Persistence:** Selected agent stored in `selected-agent` and `selected-agent-id`
|
||||
- **Session ID:** Per-agent format `chat-session-{agentId}` stored as `chat-session-{agentId}`
|
||||
- **Message Storage:** Per-agent messages stored as `chat-messages-{agentId}` in localStorage
|
||||
- **Image Handling:** Images converted to base64 and included in message payload
|
||||
- **Auto-scroll:** Maintains scroll position at latest message
|
||||
|
||||
### Markdown Processing Details
|
||||
- Custom regex parser in `MarkdownRenderer` extracts ` ```diff-tool ... ``` ` code blocks
|
||||
- Replaces diff-tool blocks with placeholders during markdown rendering
|
||||
- After markdown is rendered, dynamically inserts `<DiffDisplay>` components
|
||||
- Supports remark-gfm (GitHub-flavored markdown) and rehype-highlight for syntax coloring
|
||||
|
||||
## Key Files & Responsibilities
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/types.ts` | Centralized TypeScript types: Agent, Message, ChatRequest, ChatResponse, etc. |
|
||||
| `src/app/api/agents/route.ts` | GET endpoint - parses env vars and returns available agents |
|
||||
| `src/app/api/chat/route.ts` | POST endpoint - looks up agent webhook URL and proxies requests |
|
||||
| `src/app/page.tsx` | Home page - manages agent selection state, conditionally renders AgentSelector or ChatInterface |
|
||||
| `src/components/agent-selector.tsx` | Agent selection UI - fetches agents, displays cards, handles selection |
|
||||
| `src/components/chat-interface.tsx` | Main chat UI - handles per-agent messages, image uploads, streaming |
|
||||
| `src/components/header.tsx` | App header - displays agent name and "Switch Agent" button |
|
||||
| `src/components/markdown-renderer.tsx` | Parses markdown and extracts diff-tool blocks for custom rendering |
|
||||
| `src/components/diff-display.tsx` | Renders side-by-side diffs with syntax highlighting using Highlight.js |
|
||||
| `AGENT_DIFF_TOOL_SETUP.md` | Comprehensive guide for configuring n8n agents to output diff tools |
|
||||
| `src/components/DIFF_TOOL_USAGE.md` | In-component documentation for the diff tool feature |
|
||||
|
||||
## Theme & Styling
|
||||
|
||||
- **System:** Tailwind CSS 4 with CSS custom properties and OKLch color space
|
||||
- **Colors:** Warm light mode (cream/beige) and pure black dark mode
|
||||
- **Implementation:** `next-themes` with "light" and "dark" variants
|
||||
- **Toggle:** Mode toggle button in header
|
||||
- **Global Styles:** `src/app/globals.css` contains theme definitions and Tailwind imports
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- `tsconfig.json` - TypeScript strict mode, bundler module resolution, `@/*` alias to `src/`
|
||||
- `next.config.ts` - Next.js configuration
|
||||
- `open-next.config.ts` - Cloudflare-specific Next.js configuration
|
||||
- `wrangler.jsonc` - Wrangler (Cloudflare CLI) configuration
|
||||
- `eslint.config.mjs` - ESLint with Next.js core-web-vitals and TypeScript support
|
||||
- `components.json` - shadcn/ui component library configuration
|
||||
- `postcss.config.mjs` - Tailwind CSS plugin configuration
|
||||
|
||||
## n8n Webhook Integration
|
||||
|
||||
The chat endpoint routes to an n8n workflow via webhook. The workflow should:
|
||||
|
||||
1. Accept message input and context from the client
|
||||
2. Return newline-delimited JSON with messages in one of two formats:
|
||||
- `{ "type": "item", "content": "text content" }`
|
||||
- `{ "type": "tool_call", "name": "show_diff", "input": { "before": "...", "after": "...", "language": "..." } }`
|
||||
|
||||
**Note:** See `AGENT_DIFF_TOOL_SETUP.md` for detailed instructions on configuring n8n agents to use the diff tool feature.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Configuring Agents via Environment Variables
|
||||
**Local Development (.env.local):**
|
||||
```
|
||||
AGENT_1_URL=https://n8n.example.com/webhook/agent-1-uuid
|
||||
AGENT_1_NAME=Creative Writer
|
||||
AGENT_1_DESCRIPTION=An AI assistant for creative writing and brainstorming
|
||||
|
||||
AGENT_2_URL=https://n8n.example.com/webhook/agent-2-uuid
|
||||
AGENT_2_NAME=Code Reviewer
|
||||
AGENT_2_DESCRIPTION=An AI assistant for code review and refactoring
|
||||
```
|
||||
|
||||
**Cloudflare Deployment:**
|
||||
- Go to Cloudflare dashboard → Workers & Pages → your-project → Settings → Environment variables
|
||||
- Add the same AGENT_* variables above
|
||||
- Deploy to apply changes
|
||||
|
||||
### Adding a New Agent
|
||||
1. Add three environment variables:
|
||||
- `AGENT_N_URL` - webhook URL for the agent
|
||||
- `AGENT_N_NAME` - display name
|
||||
- `AGENT_N_DESCRIPTION` - short description
|
||||
2. On next page reload, new agent appears in AgentSelector
|
||||
3. No code changes needed
|
||||
|
||||
### Modifying Chat Messages or Display
|
||||
- **Chat UI:** `src/components/chat-interface.tsx`
|
||||
- **Rendering:** `src/components/markdown-renderer.tsx`
|
||||
- **State:** Message list stored in component state, persisted to localStorage per agent
|
||||
- **Per-agent persistence:** Messages saved as `chat-messages-{agentId}`
|
||||
|
||||
### Changing Theme Colors
|
||||
- Edit CSS custom properties in `src/app/globals.css`
|
||||
- Uses OKLch color space (perceptually uniform)
|
||||
- Dark mode variant defined with `@custom-variant dark`
|
||||
|
||||
### Adding New Tool Types (beyond show_diff)
|
||||
- **Step 1:** Handle in `/api/chat/route.ts` - convert tool_call to markdown format
|
||||
- **Step 2:** Add detection logic in `markdown-renderer.tsx` to extract new code block type
|
||||
- **Step 3:** Create new component similar to `DiffDisplay` and render dynamically
|
||||
|
||||
### Switching Between Agents
|
||||
- Users click "Switch Agent" button in header
|
||||
- Returns to AgentSelector menu
|
||||
- Previously selected agents/messages are preserved in localStorage per agent
|
||||
- No data loss when switching
|
||||
|
||||
## Notes for Future Development
|
||||
|
||||
### Multi-Agent Features
|
||||
- **Agent Switching:** Currently requires page reload to agent selector. Could add inline dropdown in header.
|
||||
- **Agent Management:** UI for adding/editing agents without env vars could improve UX
|
||||
- **Agent Verification:** Add health checks to verify agents' webhook URLs are reachable
|
||||
- **Agent Categories:** Could group agents by category/type for better organization
|
||||
|
||||
### Image Upload Enhancements
|
||||
- **Image Storage:** Currently base64 in memory; consider Cloudflare R2 for large images
|
||||
- **Image Preview:** Add full-screen image viewer for uploaded images
|
||||
- **Multi-file Upload:** Support multiple file types beyond images
|
||||
|
||||
### Performance & Scaling
|
||||
- **Message Virtualization:** Chat interface uses `useRef` for scroll - consider virtualizing long message lists
|
||||
- **Storage Size:** localStorage has 5-10MB limit; consider IndexedDB for longer conversations
|
||||
- **Streaming:** Current implementation reads entire response before parsing; consider streaming HTML as it arrives
|
||||
|
||||
### Testing & Quality
|
||||
- **Testing:** Consider adding Jest or Vitest for component and API testing
|
||||
- **Error Handling:** Enhance with retry logic for failed webhook calls and better error messages
|
||||
- **Logging:** Add structured logging for debugging multi-agent interactions
|
||||
|
||||
### Accessibility & UX
|
||||
- **Keyboard Navigation:** Verify keyboard navigation on agent selector cards and custom diff tool
|
||||
- **ARIA Labels:** Add aria-labels for screen readers on interactive elements
|
||||
- **Mobile Responsive:** Test agent selector and chat interface on mobile devices
|
||||
46
CONTRIBUTING.md
Normal file
46
CONTRIBUTING.md
Normal file
@ -0,0 +1,46 @@
|
||||
Contributing Workflow
|
||||
=====================
|
||||
|
||||
Use this guidance when you spin up a new project from the template or accept contributions from collaborators.
|
||||
|
||||
Branching model
|
||||
---------------
|
||||
- Work from short-lived topic branches cut from `main`.
|
||||
- Prefix branches with the work type: `feat/`, `fix/`, `docs/`, `chore/`, `refactor/`, `test/`.
|
||||
- Keep branch names descriptive but short (e.g. `feat/billing-invoices`, `fix/auth-timeout`).
|
||||
|
||||
Commit messages
|
||||
---------------
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
```
|
||||
Examples:
|
||||
- `feat(api): add artist listing endpoint`
|
||||
- `fix(infra): handle wrangler env missing`
|
||||
- `docs(adr): record storage strategy`
|
||||
|
||||
Pull request checklist
|
||||
----------------------
|
||||
1. Rebase onto `main` before opening the PR.
|
||||
2. Fill out `.gitea/pull_request_template.md` so reviewers know how to test.
|
||||
3. Ensure automated checks pass locally:
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm lint
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
Adjust the commands if your project uses a different toolchain.
|
||||
4. Link issues with `Fixes #id` or `Refs #id` as appropriate.
|
||||
5. Squash-merge once approved to keep history clean (use the Conventional Commit format for the squash message).
|
||||
|
||||
Quality expectations
|
||||
--------------------
|
||||
- Keep docs current. Update the README, edge-case catalogue, or stack decisions when behaviour changes.
|
||||
- Add or update tests alongside your changes—tests are treated as executable documentation.
|
||||
- Avoid committing secrets or large binaries; rely on `.env` files, secret managers, or storage buckets instead.
|
||||
|
||||
Questions?
|
||||
----------
|
||||
Open an issue or start a draft PR and document what you are unsure about. Future readers will thank you for the breadcrumbs.
|
||||
241
README.md
241
README.md
@ -1,3 +1,240 @@
|
||||
# Correspondents
|
||||
<div align="center">
|
||||
|
||||
Correspondents is an opinionated web chat interface built specifically for my own custom agents, local inference and other AI/ML related projects.
|
||||
<!-- DEPLOYMENT COMMAND -->
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 10px; margin-bottom: 30px;">
|
||||
<h3 style="color: white; margin: 0;">STARTER COMMAND</h3>
|
||||
<code style="color: #ffd700; font-size: 16px; font-weight: bold;">./scripts/bootstrap-template.sh</code>
|
||||
</div>
|
||||
|
||||
<a id="readme-top"></a>
|
||||
|
||||
<!-- PROJECT SHIELDS -->
|
||||
[![Contributors][contributors-shield]][contributors-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
[![Stargazers][stars-shield]][stars-url]
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![LinkedIn][linkedin-shield]][linkedin-url]
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://git.biohazardvfx.com/nicholai/template">
|
||||
<img src="public/template-logo.png" alt="Template Logo" width="400">
|
||||
</a>
|
||||
|
||||
<h1 align="center" style="font-size: 48px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Development Project Template</h1>
|
||||
|
||||
<p align="center" style="font-size: 18px; max-width: 680px;">
|
||||
Opinionated starter kit for new projects deployed through my self-hosted Gitea.<br />
|
||||
<strong>Documentation-first • Edge-case aware • Automation ready</strong>
|
||||
<br />
|
||||
<br />
|
||||
<a href="#getting-started"><strong>Quick Start »</strong></a>
|
||||
·
|
||||
<a href="https://git.biohazardvfx.com/nicholai/template/issues/new?labels=enhancement">Suggest Improvement</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
<details open>
|
||||
<summary><h2>Table of Contents</h2></summary>
|
||||
<ol>
|
||||
<li><a href="#about-the-template">About The Template</a>
|
||||
<ul>
|
||||
<li><a href="#why-this-exists">Why This Exists</a></li>
|
||||
<li><a href="#core-principles">Core Principles</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#tech-stack">Tech Stack</a></li>
|
||||
<li><a href="#architecture">Architecture</a></li>
|
||||
<li><a href="#getting-started">Getting Started</a>
|
||||
<ul>
|
||||
<li><a href="#prerequisites">Prerequisites</a></li>
|
||||
<li><a href="#installation">Installation</a></li>
|
||||
<li><a href="#environment-variables">Environment Variables</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#development">Development</a>
|
||||
<ul>
|
||||
<li><a href="#common-commands">Common Commands</a></li>
|
||||
<li><a href="#docs--checklists">Docs & Checklists</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#edge-cases">Edge Cases</a></li>
|
||||
<li><a href="#testing">Testing</a></li>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
<li><a href="#license">License</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
</div>
|
||||
|
||||
## About The Template
|
||||
|
||||
<div align="center">
|
||||
<img src="public/template-dashboard.png" alt="Template Dashboard Mock" width="800" style="border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.3);">
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
This repository is the baseline I use when starting a new product or service. It keeps the process consistent, reduces the time spent wiring boilerplate, and reminds me to account for the edge cases that usually appear late in a project.
|
||||
|
||||
### Why This Exists
|
||||
|
||||
- **Primed documentation:** Every project starts with a README, stack decision log, bootstrapping checklist, and edge-case catalogue.
|
||||
- **Automation on day one:** `scripts/` holds helpers to rename the project, configure remotes, and clean example assets.
|
||||
- **Testing blueprints:** Example Vitest suites (`__tests__/`) demonstrate how to structure API, component, flag, hook, and library tests.
|
||||
- **Gitea ready:** Pull request templates, Conventional Commit guidance, and workflows match my self-hosted setup.
|
||||
|
||||
### Core Principles
|
||||
|
||||
| Principle | What it means |
|
||||
| --- | --- |
|
||||
| Documentation-first | Write down intent and constraints before diving into code. |
|
||||
| Edge-case aware | Capture the failure scenarios that repeatedly cause incidents. |
|
||||
| Reproducible setup | Every project can be re-created from scratch via scripts and docs. |
|
||||
| Automation ready | Scripts and CI pipelines are easy to adapt or extend. |
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js + TypeScript (adjust as needed)
|
||||
- **Testing**: Vitest + Testing Library
|
||||
- **Styling**: Tailwind CSS or CSS Modules (pick one per project)
|
||||
- **Database**: PostgreSQL (Supabase/Neon friendly)
|
||||
- **Storage**: S3-compatible providers (AWS S3, Cloudflare R2)
|
||||
- **Auth**: NextAuth.js or custom token flows
|
||||
- **Deployment**: Wrangler + Cloudflare Pages/Workers (swap for your platform)
|
||||
|
||||
Document any deviations in `docs/stack-decisions.md`.
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Client] -->|HTTP| B[Next.js App]
|
||||
B -->|API Routes| C[(PostgreSQL)]
|
||||
B -->|Edge Functions| D[Cloudflare Workers]
|
||||
B -->|Auth| E[Identity Provider]
|
||||
B -->|Storage SDK| F[(S3/R2 Bucket)]
|
||||
D -->|Feature Flags| G[Config Service]
|
||||
```
|
||||
|
||||
- Keep infrastructure definitions under `infra/` once you create them.
|
||||
- Capture architectural decisions and trade-offs in `docs/stack-decisions.md`.
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm (preferred) or your package manager of choice
|
||||
- `jq` (optional, used by bootstrap script)
|
||||
- Git & access to your Gitea instance
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone / duplicate the template**
|
||||
```bash
|
||||
git clone git@git.biohazardvfx.com:nicholai/template.git my-new-project
|
||||
cd my-new-project
|
||||
```
|
||||
2. **Bootstrap**
|
||||
```bash
|
||||
./scripts/bootstrap-template.sh
|
||||
```
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
4. **Follow the checklist**
|
||||
- Open `docs/bootstrapping.md` and complete each item.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Copy `.env.example` to `.env` and fill only the sections you need. The file is structured by concern (database, auth, storage, observability) so you can strip unused parts.
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## Development
|
||||
|
||||
### Common Commands
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `pnpm dev` | Start the Next.js dev server. |
|
||||
| `pnpm lint` | Run ESLint / formatting checks. |
|
||||
| `pnpm test` | Execute the Vitest suites. |
|
||||
| `pnpm build` | Generate a production build. |
|
||||
|
||||
### Docs & Checklists
|
||||
|
||||
- `docs/bootstrapping.md` — tasks to run through when spinning up a new project.
|
||||
- `docs/edge-cases.md` — prompts for the weird scenarios that usually break things.
|
||||
- `docs/stack-decisions.md` — record “why” for each notable tech choice.
|
||||
- `docs/testing-blueprints.md` — guidance for adapting the example tests.
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## Edge Cases
|
||||
|
||||
Edge-case awareness is built into the template:
|
||||
|
||||
- Feature flags default to safe behaviour when providers fail.
|
||||
- Auth, storage, scheduling, and third-party integrations each have dedicated prompts.
|
||||
- The example tests in `__tests__/flags/` and `__tests__/lib/` show how to assert defensive behaviour.
|
||||
|
||||
Add new lessons learned back into `docs/edge-cases.md` so the template evolves with every incident.
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## Testing
|
||||
|
||||
- Tests are organised by domain: `api/`, `components/`, `hooks/`, `flags/`, `lib/`.
|
||||
- Each suite mocks external dependencies and asserts on both happy-path and failure scenarios.
|
||||
- See `docs/testing-blueprints.md` for tips on customising them to your project.
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## Contributing
|
||||
|
||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for branching conventions, commit style, and review expectations.
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## License
|
||||
|
||||
Use, remix, or extract any portion of this template for your own projects. Attribution is appreciated but not required.
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
## Contact
|
||||
|
||||
Nicholai — [@biohazardvfx](https://linkedin.com/in/biohazardvfx) — nicholai@biohazardvfx.com
|
||||
|
||||
Project Link: [https://git.biohazardvfx.com/nicholai/template-repo](https://git.biohazardvfx.com/nicholai/template-repo)
|
||||
|
||||
<p align="right"><a href="#readme-top">back to top ↑</a></p>
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
<!-- shields -->
|
||||
[contributors-shield]: https://img.shields.io/gitea/contributors/nicholai/template?style=for-the-badge
|
||||
[contributors-url]: https://git.biohazardvfx.com/nicholai/template/graphs/contributors
|
||||
[forks-shield]: https://img.shields.io/gitea/forks/nicholai/template?style=for-the-badge
|
||||
[forks-url]: https://git.biohazardvfx.com/nicholai/template/network/members
|
||||
[stars-shield]: https://img.shields.io/gitea/stars/nicholai/template?style=for-the-badge
|
||||
[stars-url]: https://git.biohazardvfx.com/nicholai/template/stars
|
||||
[issues-shield]: https://img.shields.io/gitea/issues/nicholai/template?style=for-the-badge
|
||||
[issues-url]: https://git.biohazardvfx.com/nicholai/template/issues
|
||||
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
|
||||
[linkedin-url]: https://linkedin.com/in/biohazardvfx
|
||||
|
||||
11
__tests__/README.md
Normal file
11
__tests__/README.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Test Suite Overview
|
||||
|
||||
The tests in this directory act as executable specifications. They were copied from real projects and are meant to be adapted, not run verbatim.
|
||||
|
||||
How to use them:
|
||||
- Rename folders to match the first features you build.
|
||||
- Replace imports from `@/...` with your actual modules once they exist.
|
||||
- Trim scenarios that do not apply and add new ones that cover risky behaviours or integrations you care about.
|
||||
- Keep the error-handling and edge-case checks—they are the reason these suites exist.
|
||||
|
||||
Once your implementation is in place, run `pnpm test` (or your preferred command) and fix failing specs until everything passes. The goal is to evolve these tests into living documentation for the application you are building off this template.
|
||||
119
__tests__/api/artists.test.ts
Normal file
119
__tests__/api/artists.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { GET } from '@/app/api/artists/route'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// Mock the database functions
|
||||
vi.mock('@/lib/db', () => ({
|
||||
getPublicArtists: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getPublicArtists } from '@/lib/db'
|
||||
|
||||
describe('GET /api/artists', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return artists successfully', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'test-artist',
|
||||
name: 'Test Artist',
|
||||
bio: 'Test bio',
|
||||
specialties: ['Traditional', 'Realism'],
|
||||
instagramHandle: '@testartist',
|
||||
portfolioImages: [],
|
||||
isActive: true,
|
||||
hourlyRate: 150,
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(getPublicArtists).mockResolvedValue(mockArtists)
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/artists')
|
||||
const response = await GET(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.artists).toHaveLength(1)
|
||||
expect(data.artists[0].name).toBe('Test Artist')
|
||||
})
|
||||
|
||||
it('should apply specialty filter', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'traditional-artist',
|
||||
name: 'Traditional Artist',
|
||||
bio: 'Test bio',
|
||||
specialties: ['Traditional'],
|
||||
portfolioImages: [],
|
||||
isActive: true,
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(getPublicArtists).mockResolvedValue(mockArtists)
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/artists?specialty=Traditional')
|
||||
await GET(request)
|
||||
|
||||
expect(getPublicArtists).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
specialty: 'Traditional',
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should apply search filter', async () => {
|
||||
vi.mocked(getPublicArtists).mockResolvedValue([])
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/artists?search=John')
|
||||
await GET(request)
|
||||
|
||||
expect(getPublicArtists).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
search: 'John',
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should apply pagination', async () => {
|
||||
vi.mocked(getPublicArtists).mockResolvedValue([])
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/artists?limit=10&page=2')
|
||||
await GET(request)
|
||||
|
||||
expect(getPublicArtists).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
limit: 10,
|
||||
offset: 10, // page 2 with limit 10 = offset 10
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
vi.mocked(getPublicArtists).mockRejectedValue(new Error('Database error'))
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/artists')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should return empty array when no artists found', async () => {
|
||||
vi.mocked(getPublicArtists).mockResolvedValue([])
|
||||
|
||||
const request = new NextRequest('http://localhost:3000/api/artists')
|
||||
const response = await GET(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.artists).toEqual([])
|
||||
})
|
||||
})
|
||||
82
__tests__/components/aftercare-page.test.tsx
Normal file
82
__tests__/components/aftercare-page.test.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { AftercarePage } from '@/components/aftercare-page'
|
||||
|
||||
describe('AftercarePage ShadCN UI Consistency', () => {
|
||||
it('uses ShadCN design tokens and primitives correctly', () => {
|
||||
render(<AftercarePage />)
|
||||
|
||||
// Verify main container uses ShadCN background tokens
|
||||
const mainContainer = document.querySelector('.min-h-screen')
|
||||
expect(mainContainer).toHaveClass('bg-background', 'text-foreground')
|
||||
|
||||
// Verify Tabs primitives are present
|
||||
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: /general tattoo aftercare/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: /transparent bandage aftercare/i })).toBeInTheDocument()
|
||||
|
||||
// Verify Alert primitives are present (there are multiple alerts)
|
||||
const alerts = screen.getAllByRole('alert')
|
||||
expect(alerts.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify Card primitives are present (multiple cards should exist)
|
||||
const cards = document.querySelectorAll('[data-slot="card"]')
|
||||
expect(cards.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify no ad-hoc color classes are used (specifically no text-white)
|
||||
const htmlContent = document.documentElement.innerHTML
|
||||
expect(htmlContent).not.toContain('text-white')
|
||||
|
||||
// Verify ShadCN design tokens are used
|
||||
expect(htmlContent).toContain('text-muted-foreground')
|
||||
expect(htmlContent).toContain('bg-background')
|
||||
expect(htmlContent).toContain('text-foreground')
|
||||
})
|
||||
|
||||
it('uses consistent ShadCN component structure', () => {
|
||||
render(<AftercarePage />)
|
||||
|
||||
// Verify TabsList has proper ShadCN structure
|
||||
const tabsList = screen.getByRole('tablist')
|
||||
expect(tabsList).toHaveClass('grid', 'w-full', 'grid-cols-2', 'bg-muted', 'border')
|
||||
|
||||
// Verify Alert uses ShadCN structure with proper icon placement
|
||||
const alerts = screen.getAllByRole('alert')
|
||||
expect(alerts[0]).toHaveAttribute('data-slot', 'alert')
|
||||
|
||||
// Verify Cards use proper ShadCN structure
|
||||
const cardHeaders = document.querySelectorAll('[data-slot="card-header"]')
|
||||
expect(cardHeaders.length).toBeGreaterThan(0)
|
||||
|
||||
const cardContents = document.querySelectorAll('[data-slot="card-content"]')
|
||||
expect(cardContents.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('maintains consistent typography and spacing scales', () => {
|
||||
render(<AftercarePage />)
|
||||
|
||||
// Verify heading uses consistent font classes
|
||||
const mainHeading = screen.getByText('Tattoo Aftercare')
|
||||
expect(mainHeading).toHaveClass('font-playfair')
|
||||
|
||||
// Verify muted text uses consistent token
|
||||
const mutedElements = document.querySelectorAll('.text-muted-foreground')
|
||||
expect(mutedElements.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify consistent spacing classes are used
|
||||
const htmlContent = document.documentElement.innerHTML
|
||||
expect(htmlContent).toContain('space-y-')
|
||||
expect(htmlContent).toContain('gap-')
|
||||
expect(htmlContent).toContain('px-8')
|
||||
expect(htmlContent).toContain('py-6') // Cards use py-6, not py-8
|
||||
})
|
||||
|
||||
it('applies motion classes with reduced-motion safeguard', () => {
|
||||
render(<AftercarePage />)
|
||||
const html = document.documentElement.innerHTML
|
||||
expect(html).toContain('animate-in')
|
||||
expect(html).toContain('motion-reduce:animate-none')
|
||||
})
|
||||
})
|
||||
99
__tests__/components/artist-portfolio.test.tsx
Normal file
99
__tests__/components/artist-portfolio.test.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { ArtistPortfolio } from '@/components/artist-portfolio'
|
||||
|
||||
// Mock requestAnimationFrame / cancel
|
||||
global.requestAnimationFrame = vi.fn((cb) => setTimeout(cb, 0) as unknown as number)
|
||||
global.cancelAnimationFrame = vi.fn((id) => clearTimeout(id as unknown as number))
|
||||
|
||||
// Default matchMedia mock (no reduced motion)
|
||||
const createMatchMedia = (matches: boolean) =>
|
||||
vi.fn().mockImplementation((query) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
// Basic getBoundingClientRect mock for panels
|
||||
const defaultRect = {
|
||||
top: 0,
|
||||
bottom: 800,
|
||||
left: 0,
|
||||
right: 1200,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}
|
||||
|
||||
describe('ArtistPortfolio Split Hero', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// default to no reduced-motion preference
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: createMatchMedia(false),
|
||||
})
|
||||
|
||||
// Mock IntersectionObserver (class-like mock to satisfy TS typings)
|
||||
class MockIntersectionObserver {
|
||||
constructor(private cb?: IntersectionObserverCallback, private options?: IntersectionObserverInit) {}
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
takeRecords() { return [] }
|
||||
}
|
||||
// Assign the mock class for the test environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(global as any).IntersectionObserver = MockIntersectionObserver
|
||||
|
||||
// Mock getBoundingClientRect for all elements
|
||||
Element.prototype.getBoundingClientRect = vi.fn(() => defaultRect)
|
||||
})
|
||||
|
||||
it('initializes left/right panels with CSS var of 0 and transform style when motion allowed', () => {
|
||||
const { getByTestId } = render(<ArtistPortfolio artistId="1" />)
|
||||
|
||||
const left = getByTestId('artist-left-panel')
|
||||
const right = getByTestId('artist-right-panel')
|
||||
|
||||
expect(left).toBeInTheDocument()
|
||||
expect(right).toBeInTheDocument()
|
||||
|
||||
// CSS var should be initialized to 0px on mount
|
||||
expect(left.style.getPropertyValue('--parallax-offset')).toBe('0px')
|
||||
expect(right.style.getPropertyValue('--parallax-offset')).toBe('0px')
|
||||
|
||||
// When motion is allowed, the element should expose the translateY style (uses CSS var)
|
||||
expect(left).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
expect(right).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
})
|
||||
|
||||
it('does not apply parallax transform when prefers-reduced-motion is true', () => {
|
||||
// Mock reduced motion preference
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: createMatchMedia(true),
|
||||
})
|
||||
|
||||
const { getByTestId } = render(<ArtistPortfolio artistId="1" />)
|
||||
|
||||
const left = getByTestId('artist-left-panel')
|
||||
const right = getByTestId('artist-right-panel')
|
||||
|
||||
// With reduced motion, the hook should not add transform/willChange styles
|
||||
expect(left).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
expect(left).not.toHaveStyle({ willChange: 'transform' })
|
||||
|
||||
expect(right).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
expect(right).not.toHaveStyle({ willChange: 'transform' })
|
||||
})
|
||||
})
|
||||
202
__tests__/components/artists-grid.test.tsx
Normal file
202
__tests__/components/artists-grid.test.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { ArtistsGrid } from '@/components/artists-grid'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock the custom hook
|
||||
vi.mock('@/hooks/use-artist-data', () => ({
|
||||
useArtists: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useArtists } from '@/hooks/use-artist-data'
|
||||
|
||||
describe('ArtistsGrid Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should display loading state', () => {
|
||||
vi.mocked(useArtists).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
} as any)
|
||||
|
||||
render(<ArtistsGrid />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display artists when loaded', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'test-artist',
|
||||
name: 'Test Artist',
|
||||
bio: 'Test bio',
|
||||
specialties: ['Traditional', 'Realism'],
|
||||
instagramHandle: '@testartist',
|
||||
portfolioImages: [
|
||||
{
|
||||
id: '1',
|
||||
artistId: '1',
|
||||
url: 'https://example.com/image.jpg',
|
||||
caption: 'Test image',
|
||||
tags: ['Traditional'],
|
||||
isPublic: true,
|
||||
orderIndex: 0,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
isActive: true,
|
||||
hourlyRate: 150,
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(useArtists).mockReturnValue({
|
||||
data: mockArtists,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any)
|
||||
|
||||
render(<ArtistsGrid />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Artist')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Traditional, Realism/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Available')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display error state', () => {
|
||||
vi.mocked(useArtists).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to fetch'),
|
||||
} as any)
|
||||
|
||||
render(<ArtistsGrid />)
|
||||
|
||||
expect(screen.getByText(/Failed to load artists/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display empty state when no artists match filter', async () => {
|
||||
vi.mocked(useArtists).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any)
|
||||
|
||||
render(<ArtistsGrid />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No artists found/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display artist cards with portfolio images', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'artist-one',
|
||||
name: 'Artist One',
|
||||
bio: 'Bio one',
|
||||
specialties: ['Traditional'],
|
||||
portfolioImages: [
|
||||
{
|
||||
id: '1',
|
||||
artistId: '1',
|
||||
url: 'https://example.com/img1.jpg',
|
||||
tags: ['profile'],
|
||||
isPublic: true,
|
||||
orderIndex: 0,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
isActive: true,
|
||||
hourlyRate: 100,
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(useArtists).mockReturnValue({
|
||||
data: mockArtists,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any)
|
||||
|
||||
render(<ArtistsGrid />)
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for View Portfolio link
|
||||
const portfolioLink = screen.getByRole('link', { name: /View Portfolio/i })
|
||||
expect(portfolioLink).toHaveAttribute('href', '/artists/artist-one')
|
||||
|
||||
// Check for Book Now link
|
||||
const bookLink = screen.getByRole('link', { name: /Book Now/i })
|
||||
expect(bookLink).toHaveAttribute('href', '/book?artist=artist-one')
|
||||
|
||||
// Check for hourly rate display
|
||||
expect(screen.getByText(/\$100\/hr/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display specialties as badges', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'multi-specialty-artist',
|
||||
name: 'Multi Specialty Artist',
|
||||
bio: 'Expert in multiple styles',
|
||||
specialties: ['Traditional', 'Realism', 'Fine Line', 'Japanese'],
|
||||
portfolioImages: [],
|
||||
isActive: true,
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(useArtists).mockReturnValue({
|
||||
data: mockArtists,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any)
|
||||
|
||||
render(<ArtistsGrid />)
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show first 3 specialties
|
||||
expect(screen.getByText('Traditional')).toBeInTheDocument()
|
||||
expect(screen.getByText('Realism')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fine Line')).toBeInTheDocument()
|
||||
|
||||
// Should show "+1 more" badge for the 4th specialty
|
||||
expect(screen.getByText('+1 more')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show inactive badge for inactive artists', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
slug: 'inactive-artist',
|
||||
name: 'Inactive Artist',
|
||||
bio: 'Currently unavailable',
|
||||
specialties: ['Traditional'],
|
||||
portfolioImages: [],
|
||||
isActive: false,
|
||||
},
|
||||
]
|
||||
|
||||
vi.mocked(useArtists).mockReturnValue({
|
||||
data: mockArtists,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any)
|
||||
|
||||
render(<ArtistsGrid />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
132
__tests__/components/hero-section.test.tsx
Normal file
132
__tests__/components/hero-section.test.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { HeroSection } from '@/components/hero-section'
|
||||
|
||||
// Mock the feature flags provider
|
||||
vi.mock('@/components/feature-flags-provider', () => ({
|
||||
useFeatureFlag: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
// Mock the parallax hooks
|
||||
vi.mock('@/hooks/use-parallax', () => ({
|
||||
useMultiLayerParallax: vi.fn(() => ({
|
||||
background: {
|
||||
ref: { current: null },
|
||||
style: { transform: 'translateY(0px)' },
|
||||
},
|
||||
midground: {
|
||||
ref: { current: null },
|
||||
style: { transform: 'translateY(0px)' },
|
||||
},
|
||||
foreground: {
|
||||
ref: { current: null },
|
||||
style: { transform: 'translateY(0px)' },
|
||||
},
|
||||
})),
|
||||
useReducedMotion: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
describe('HeroSection Parallax Implementation', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("renders hero section with all layers", () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
// Check for main heading
|
||||
expect(screen.getByRole("heading", { name: /united tattoo/i })).toBeInTheDocument()
|
||||
|
||||
// Check for tagline
|
||||
expect(screen.getByText(/where artistry meets precision/i)).toBeInTheDocument()
|
||||
|
||||
// Check for CTA button
|
||||
expect(screen.getByRole("button", { name: /book consultation/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies reduced motion data attribute when reduced motion is preferred', async () => {
|
||||
const { useReducedMotion } = await import('@/hooks/use-parallax')
|
||||
vi.mocked(useReducedMotion).mockReturnValue(true)
|
||||
|
||||
render(<HeroSection />)
|
||||
|
||||
const section = document.querySelector('section')
|
||||
expect(section).toHaveAttribute('data-reduced-motion', 'true')
|
||||
})
|
||||
|
||||
it("has proper accessibility attributes for decorative images", () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
// Background and midground layers should be aria-hidden
|
||||
const decorativeElements = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(decorativeElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("uses proper semantic structure", () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
// Should have proper heading hierarchy
|
||||
const heading = screen.getByRole("heading", { name: /united tattoo/i })
|
||||
expect(heading.tagName).toBe("H1")
|
||||
|
||||
// Should have proper section structure
|
||||
const section = document.querySelector("section")
|
||||
expect(section).toHaveAttribute("id", "home")
|
||||
})
|
||||
|
||||
it("applies will-change-transform for performance optimization", () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
const transformElements = document.querySelectorAll(".will-change-transform")
|
||||
expect(transformElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('respects feature flag for advanced animations', async () => {
|
||||
const { useFeatureFlag } = await import('@/components/feature-flags-provider')
|
||||
const { useMultiLayerParallax } = await import('@/hooks/use-parallax')
|
||||
|
||||
// Test with feature flag disabled
|
||||
vi.mocked(useFeatureFlag).mockReturnValue(false)
|
||||
|
||||
render(<HeroSection />)
|
||||
|
||||
// Should pass disabled=true to parallax hook when feature flag is off
|
||||
expect(useMultiLayerParallax).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it("has responsive design classes", () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
const heading = screen.getByRole("heading", { name: /united tattoo/i })
|
||||
expect(heading).toHaveClass("text-5xl", "lg:text-7xl")
|
||||
|
||||
const tagline = screen.getByText(/where artistry meets precision/i)
|
||||
expect(tagline).toHaveClass("text-xl", "lg:text-2xl")
|
||||
})
|
||||
|
||||
it("initializes parallax transforms to 0 at mount", () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
// All parallax layers should initialize with 0px transform
|
||||
const backgroundLayer = document.querySelector('[style*="translateY(0px)"]')
|
||||
const midgroundLayer = document.querySelectorAll('[style*="translateY(0px)"]')[1]
|
||||
const foregroundLayer = document.querySelectorAll('[style*="translateY(0px)"]')[2]
|
||||
|
||||
expect(backgroundLayer).toBeInTheDocument()
|
||||
expect(midgroundLayer).toBeInTheDocument()
|
||||
expect(foregroundLayer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("disables parallax transforms when reduced motion is preferred", async () => {
|
||||
const { useReducedMotion } = await import('@/hooks/use-parallax')
|
||||
vi.mocked(useReducedMotion).mockReturnValue(true)
|
||||
|
||||
render(<HeroSection />)
|
||||
|
||||
// When reduced motion is preferred, parallax should be disabled
|
||||
const section = document.querySelector('section')
|
||||
expect(section).toHaveAttribute('data-reduced-motion', 'true')
|
||||
})
|
||||
})
|
||||
109
__tests__/components/privacy-page.test.tsx
Normal file
109
__tests__/components/privacy-page.test.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PrivacyPage } from '@/components/privacy-page'
|
||||
|
||||
describe('PrivacyPage ShadCN UI Consistency', () => {
|
||||
it('uses standardized heading and body scales with ShadCN primitives', () => {
|
||||
render(<PrivacyPage />)
|
||||
|
||||
// Verify main container uses ShadCN background tokens
|
||||
const mainContainer = document.querySelector('.min-h-screen')
|
||||
expect(mainContainer).toHaveClass('bg-background', 'text-foreground')
|
||||
|
||||
// Verify heading uses consistent font classes and scale
|
||||
const mainHeading = screen.getByText('Privacy Policy')
|
||||
expect(mainHeading).toHaveClass('font-playfair', 'text-5xl', 'lg:text-7xl')
|
||||
|
||||
// Verify body text uses consistent muted foreground token
|
||||
const bodyText = screen.getByText(/We respect your privacy/)
|
||||
expect(bodyText).toHaveClass('text-muted-foreground')
|
||||
|
||||
// Verify no ad-hoc color classes are used
|
||||
const htmlContent = document.documentElement.innerHTML
|
||||
expect(htmlContent).not.toContain('text-white')
|
||||
expect(htmlContent).not.toContain('text-gray-300')
|
||||
expect(htmlContent).not.toContain('bg-white/5')
|
||||
expect(htmlContent).not.toContain('border-white/10')
|
||||
|
||||
// Verify ShadCN design tokens are consistently used
|
||||
expect(htmlContent).toContain('text-muted-foreground')
|
||||
expect(htmlContent).toContain('bg-background')
|
||||
expect(htmlContent).toContain('text-foreground')
|
||||
})
|
||||
|
||||
it('uses ShadCN primitives correctly throughout the page', () => {
|
||||
render(<PrivacyPage />)
|
||||
|
||||
// Verify Alert primitive is present and properly structured
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveAttribute('data-slot', 'alert')
|
||||
|
||||
// Verify Badge primitive is present
|
||||
const badge = screen.getByText('Last updated: 2025-09-16')
|
||||
expect(badge).toBeInTheDocument()
|
||||
|
||||
// Verify Card primitives are present (multiple cards should exist)
|
||||
const cards = document.querySelectorAll('[data-slot="card"]')
|
||||
expect(cards.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify Card headers and content use proper ShadCN structure
|
||||
const cardHeaders = document.querySelectorAll('[data-slot="card-header"]')
|
||||
expect(cardHeaders.length).toBeGreaterThan(0)
|
||||
|
||||
const cardContents = document.querySelectorAll('[data-slot="card-content"]')
|
||||
expect(cardContents.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify all CardContent uses muted foreground token
|
||||
const cardContentElements = document.querySelectorAll('[data-slot="card-content"]')
|
||||
cardContentElements.forEach(element => {
|
||||
expect(element).toHaveClass('text-muted-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
it('maintains consistent spacing and typography patterns', () => {
|
||||
render(<PrivacyPage />)
|
||||
|
||||
// Verify consistent spacing classes are used
|
||||
const htmlContent = document.documentElement.innerHTML
|
||||
expect(htmlContent).toContain('space-y-3')
|
||||
expect(htmlContent).toContain('gap-6')
|
||||
expect(htmlContent).toContain('px-8')
|
||||
expect(htmlContent).toContain('lg:px-16')
|
||||
|
||||
// Verify consistent text sizing
|
||||
expect(htmlContent).toContain('text-xl')
|
||||
expect(htmlContent).toContain('leading-relaxed')
|
||||
|
||||
// Verify grid layout consistency
|
||||
expect(htmlContent).toContain('grid-cols-1')
|
||||
expect(htmlContent).toContain('lg:grid-cols-2')
|
||||
|
||||
// Verify responsive design patterns
|
||||
expect(htmlContent).toContain('max-w-4xl')
|
||||
expect(htmlContent).toContain('max-w-6xl')
|
||||
})
|
||||
|
||||
it('uses proper icon integration with ShadCN components', () => {
|
||||
render(<PrivacyPage />)
|
||||
|
||||
// Verify icons are properly integrated without ad-hoc color classes
|
||||
const infoIcon = document.querySelector('.lucide-info')
|
||||
expect(infoIcon).toBeInTheDocument()
|
||||
|
||||
// Verify icons use consistent sizing
|
||||
const htmlContent = document.documentElement.innerHTML
|
||||
expect(htmlContent).toContain('w-5 h-5')
|
||||
|
||||
// Verify icons don't have ad-hoc color overrides
|
||||
expect(htmlContent).not.toContain('text-white')
|
||||
})
|
||||
|
||||
it('applies motion classes with reduced-motion safeguard', () => {
|
||||
render(<PrivacyPage />)
|
||||
const html = document.documentElement.innerHTML
|
||||
expect(html).toContain('animate-in')
|
||||
expect(html).toContain('motion-reduce:animate-none')
|
||||
})
|
||||
})
|
||||
34
__tests__/flags/api-appointments-booking-disabled.test.ts
Normal file
34
__tests__/flags/api-appointments-booking-disabled.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/flags', () => ({
|
||||
Flags: { BOOKING_ENABLED: false },
|
||||
}))
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
vi.mock('next-auth', () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Booking appointments mutations with BOOKING_ENABLED=false', () => {
|
||||
it('POST returns 503 without invoking booking logic', async () => {
|
||||
const { POST } = await import('../../app/api/appointments/route')
|
||||
const response = await POST({} as any)
|
||||
expect(response.status).toBe(503)
|
||||
await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' })
|
||||
})
|
||||
|
||||
it('PUT returns 503 without invoking booking logic', async () => {
|
||||
const { PUT } = await import('../../app/api/appointments/route')
|
||||
const response = await PUT({} as any)
|
||||
expect(response.status).toBe(503)
|
||||
await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' })
|
||||
})
|
||||
|
||||
it('DELETE returns 503 without invoking booking logic', async () => {
|
||||
const { DELETE } = await import('../../app/api/appointments/route')
|
||||
const response = await DELETE({} as any)
|
||||
expect(response.status).toBe(503)
|
||||
await expect(response.json()).resolves.toEqual({ error: 'Booking disabled' })
|
||||
})
|
||||
})
|
||||
23
__tests__/flags/api-uploads-disabled.test.ts
Normal file
23
__tests__/flags/api-uploads-disabled.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/flags', () => ({
|
||||
Flags: { UPLOADS_ADMIN_ENABLED: false },
|
||||
}))
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
requireAuth: vi.fn(),
|
||||
}))
|
||||
vi.mock('next-auth', () => ({
|
||||
getServerSession: vi.fn(async () => null),
|
||||
}))
|
||||
|
||||
describe('Uploads admin disabled', () => {
|
||||
it('returns 503 for files bulk-delete when UPLOADS_ADMIN_ENABLED=false', async () => {
|
||||
const { POST } = await import('../../app/api/files/bulk-delete/route')
|
||||
const fakeReq: any = { json: async () => ({ fileIds: ['1'] }) }
|
||||
const res = await POST(fakeReq as any)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(503)
|
||||
expect(body).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
25
__tests__/flags/artists-section.static.test.tsx
Normal file
25
__tests__/flags/artists-section.static.test.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ArtistsSection } from '@/components/artists-section'
|
||||
import { FeatureFlagsProvider } from '@/components/feature-flags-provider'
|
||||
import { FLAG_DEFAULTS } from '@/lib/flags'
|
||||
|
||||
const disabledAnimationFlags = {
|
||||
...FLAG_DEFAULTS,
|
||||
ADVANCED_NAV_SCROLL_ANIMATIONS_ENABLED: false,
|
||||
} as typeof FLAG_DEFAULTS
|
||||
|
||||
describe('ArtistsSection static fallback when animations disabled', () => {
|
||||
it('renders cards visible without animation classes', () => {
|
||||
const html = renderToString(
|
||||
<FeatureFlagsProvider value={disabledAnimationFlags}>
|
||||
<ArtistsSection />
|
||||
</FeatureFlagsProvider>,
|
||||
)
|
||||
|
||||
expect(html).not.toContain('opacity-0 translate-y-8')
|
||||
expect(html).toContain('opacity-100 translate-y-0')
|
||||
})
|
||||
})
|
||||
22
__tests__/flags/booking-form.disabled.test.ts
Normal file
22
__tests__/flags/booking-form.disabled.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import React from "react"
|
||||
import { renderToString } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { BookingForm } from "@/components/booking-form"
|
||||
import { FeatureFlagsProvider } from "@/components/feature-flags-provider"
|
||||
import { FLAG_DEFAULTS } from "@/lib/flags"
|
||||
|
||||
const disabledFlags = { ...FLAG_DEFAULTS, BOOKING_ENABLED: false } as typeof FLAG_DEFAULTS
|
||||
|
||||
describe("BookingForm disabled mode (SSR string)", () => {
|
||||
it("includes disabled notice when BOOKING_ENABLED=false", () => {
|
||||
const html = renderToString(
|
||||
<FeatureFlagsProvider value={disabledFlags}>
|
||||
<BookingForm />
|
||||
</FeatureFlagsProvider>,
|
||||
)
|
||||
|
||||
expect(html).toContain("Online booking is temporarily unavailable")
|
||||
expect(html).toContain("contact the studio")
|
||||
})
|
||||
})
|
||||
199
__tests__/hooks/use-parallax.test.tsx
Normal file
199
__tests__/hooks/use-parallax.test.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import React from 'react'
|
||||
import { render, act } from '@testing-library/react'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { useParallax, useReducedMotion } from '@/hooks/use-parallax'
|
||||
|
||||
// Mock window methods
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Mock window properties
|
||||
Object.defineProperty(window, 'pageYOffset', {
|
||||
writable: true,
|
||||
value: 0,
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
writable: true,
|
||||
value: 800,
|
||||
})
|
||||
|
||||
// Mock requestAnimationFrame
|
||||
global.requestAnimationFrame = vi.fn(callback => setTimeout(callback, 0))
|
||||
global.cancelAnimationFrame = vi.fn(id => clearTimeout(id))
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock getBoundingClientRect
|
||||
Element.prototype.getBoundingClientRect = vi.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 100,
|
||||
left: 0,
|
||||
right: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}))
|
||||
|
||||
// Test component that uses the parallax hook
|
||||
const TestComponent = ({ depth = 0.1, disabled = false }: { depth?: number; disabled?: boolean }) => {
|
||||
const parallax = useParallax({ depth, disabled })
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parallax.ref}
|
||||
style={parallax.style}
|
||||
data-testid="parallax-element"
|
||||
>
|
||||
Test Element
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useParallax Hook', () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset window properties
|
||||
Object.defineProperty(window, 'pageYOffset', {
|
||||
writable: true,
|
||||
value: 0,
|
||||
})
|
||||
|
||||
// Reset mock implementations
|
||||
Element.prototype.getBoundingClientRect = vi.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 100,
|
||||
left: 0,
|
||||
right: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}))
|
||||
})
|
||||
|
||||
it('initializes CSS transform to 0 at mount', () => {
|
||||
render(<TestComponent />)
|
||||
|
||||
const element = document.querySelector('[data-testid="parallax-element"]')
|
||||
expect(element).toBeInTheDocument()
|
||||
|
||||
// Initially should have 0px transform via CSS variable
|
||||
expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
})
|
||||
|
||||
it('does not apply translation until scroll occurs', () => {
|
||||
render(<TestComponent depth={0.1} />)
|
||||
|
||||
const element = document.querySelector('[data-testid="parallax-element"]')
|
||||
expect(element).toBeInTheDocument()
|
||||
|
||||
// Initially should have 0px transform via CSS variable
|
||||
expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
|
||||
// Simulate scroll
|
||||
act(() => {
|
||||
Object.defineProperty(window, 'pageYOffset', {
|
||||
writable: true,
|
||||
value: 100,
|
||||
})
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
// After scroll, transform should still use CSS variable
|
||||
expect(element).toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
})
|
||||
|
||||
it('respects disabled prop and does not apply transforms', () => {
|
||||
render(<TestComponent depth={0.1} disabled={true} />)
|
||||
|
||||
const element = document.querySelector('[data-testid="parallax-element"]')
|
||||
expect(element).toBeInTheDocument()
|
||||
|
||||
// With disabled=true, should have no transform styles
|
||||
expect(element).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
expect(element).not.toHaveStyle({ willChange: 'transform' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useReducedMotion Hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with correct boolean value from prefersReducedMotion()', () => {
|
||||
// Mock matchMedia to return true for reduced motion
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
let reducedMotionValue: boolean
|
||||
const TestReducedMotionComponent = () => {
|
||||
reducedMotionValue = useReducedMotion()
|
||||
return <div>Test</div>
|
||||
}
|
||||
|
||||
render(<TestReducedMotionComponent />)
|
||||
|
||||
// Should be a boolean value, not a function reference
|
||||
expect(typeof reducedMotionValue).toBe('boolean')
|
||||
expect(reducedMotionValue).toBe(true)
|
||||
})
|
||||
|
||||
it('disables parallax transforms when reduced motion is preferred', () => {
|
||||
// Mock matchMedia to return true for reduced motion
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
render(<TestComponent depth={0.1} />)
|
||||
|
||||
const element = document.querySelector('[data-testid="parallax-element"]')
|
||||
expect(element).toBeInTheDocument()
|
||||
|
||||
// With reduced motion, should have no transform styles
|
||||
expect(element).not.toHaveStyle({ transform: 'translateY(var(--parallax-offset, 0px))' })
|
||||
expect(element).not.toHaveStyle({ willChange: 'transform' })
|
||||
})
|
||||
})
|
||||
144
__tests__/lib/data-migration.test.ts
Normal file
144
__tests__/lib/data-migration.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
|
||||
// Mock the database using proper Vitest patterns
|
||||
const mockStmt = {
|
||||
bind: vi.fn().mockReturnThis(),
|
||||
run: vi.fn().mockResolvedValue({ success: true, changes: 1 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockResolvedValue({ results: [] }),
|
||||
first: vi.fn().mockResolvedValue(null),
|
||||
}
|
||||
|
||||
const mockDB = {
|
||||
prepare: vi.fn().mockReturnValue(mockStmt),
|
||||
exec: vi.fn(),
|
||||
}
|
||||
|
||||
// Mock the entire lib/db module
|
||||
vi.mock('@/lib/db', () => ({
|
||||
getDB: vi.fn(() => mockDB),
|
||||
}))
|
||||
|
||||
// Mock the artists data with proper structure
|
||||
vi.mock('@/data/artists', () => ({
|
||||
artists: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Artist',
|
||||
bio: 'Test bio',
|
||||
styles: ['Traditional', 'Realism'],
|
||||
instagram: 'https://instagram.com/testartist',
|
||||
experience: '5 years',
|
||||
workImages: ['/test-image.jpg'],
|
||||
faceImage: '/test-face.jpg',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Another Artist',
|
||||
bio: 'Another bio',
|
||||
styles: ['Japanese', 'Blackwork'],
|
||||
instagram: 'https://instagram.com/anotherartist',
|
||||
experience: '8 years',
|
||||
workImages: [],
|
||||
faceImage: '/another-face.jpg',
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
describe('DataMigrator', () => {
|
||||
let DataMigrator: any
|
||||
let migrator: any
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mock implementations
|
||||
mockDB.prepare.mockReturnValue(mockStmt)
|
||||
mockStmt.first.mockResolvedValue(null)
|
||||
mockStmt.run.mockResolvedValue({ success: true, changes: 1 })
|
||||
|
||||
// Import the DataMigrator class after mocks are set up
|
||||
const module = await import('@/lib/data-migration')
|
||||
DataMigrator = module.DataMigrator
|
||||
migrator = new DataMigrator()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('isMigrationCompleted', () => {
|
||||
it('should return false when no artists exist', async () => {
|
||||
mockStmt.first.mockResolvedValueOnce({ count: 0 })
|
||||
|
||||
const isCompleted = await migrator.isMigrationCompleted()
|
||||
|
||||
expect(isCompleted).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when artists exist', async () => {
|
||||
mockStmt.first.mockResolvedValueOnce({ count: 2 })
|
||||
|
||||
const isCompleted = await migrator.isMigrationCompleted()
|
||||
|
||||
expect(isCompleted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateArtistData', () => {
|
||||
it('should migrate all artists successfully', async () => {
|
||||
await migrator.migrateArtistData()
|
||||
|
||||
// Verify user creation calls
|
||||
expect(mockDB.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT OR IGNORE INTO users')
|
||||
)
|
||||
|
||||
// Verify artist creation calls
|
||||
expect(mockDB.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT OR IGNORE INTO artists')
|
||||
)
|
||||
|
||||
// Verify portfolio image creation calls
|
||||
expect(mockDB.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT OR IGNORE INTO portfolio_images')
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockStmt.run.mockRejectedValueOnce(new Error('Database error'))
|
||||
|
||||
await expect(migrator.migrateArtistData()).rejects.toThrow('Database error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMigratedData', () => {
|
||||
it('should clear all data successfully', async () => {
|
||||
await migrator.clearMigratedData()
|
||||
|
||||
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM portfolio_images')
|
||||
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM artists')
|
||||
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM users WHERE role = "ARTIST"')
|
||||
})
|
||||
|
||||
it('should handle clear data errors', async () => {
|
||||
mockStmt.run.mockRejectedValueOnce(new Error('Clear error'))
|
||||
|
||||
await expect(migrator.clearMigratedData()).rejects.toThrow('Clear error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMigrationStats', () => {
|
||||
it('should return correct migration statistics', async () => {
|
||||
mockStmt.first
|
||||
.mockResolvedValueOnce({ count: 3 }) // total users
|
||||
.mockResolvedValueOnce({ count: 2 }) // total artists
|
||||
.mockResolvedValueOnce({ count: 1 }) // total portfolio images
|
||||
|
||||
const stats = await migrator.getMigrationStats()
|
||||
|
||||
expect(stats.totalUsers).toBe(3)
|
||||
expect(stats.totalArtists).toBe(2)
|
||||
expect(stats.totalPortfolioImages).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
269
__tests__/lib/db.test.ts
Normal file
269
__tests__/lib/db.test.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
getArtists,
|
||||
getArtistWithPortfolio,
|
||||
getPublicArtists,
|
||||
getArtistBySlug,
|
||||
updateArtist,
|
||||
addPortfolioImage,
|
||||
updatePortfolioImage,
|
||||
deletePortfolioImage,
|
||||
} from '@/lib/db'
|
||||
|
||||
// Mock D1 database
|
||||
const createMockD1 = () => ({
|
||||
prepare: vi.fn().mockReturnThis(),
|
||||
bind: vi.fn().mockReturnThis(),
|
||||
first: vi.fn(),
|
||||
all: vi.fn(),
|
||||
run: vi.fn(),
|
||||
})
|
||||
|
||||
describe('Database Functions', () => {
|
||||
let mockEnv: { DB: ReturnType<typeof createMockD1> }
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnv = {
|
||||
DB: createMockD1(),
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getArtists', () => {
|
||||
it('should fetch all artists and parse JSON fields', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Artist',
|
||||
bio: 'Test bio',
|
||||
specialties: '["Traditional","Realism"]',
|
||||
isActive: 1,
|
||||
},
|
||||
]
|
||||
|
||||
mockEnv.DB.all.mockResolvedValue({
|
||||
results: mockArtists,
|
||||
success: true,
|
||||
})
|
||||
|
||||
const result = await getArtists(mockEnv)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].specialties).toEqual(['Traditional', 'Realism'])
|
||||
expect(result[0].isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockEnv.DB.all.mockResolvedValue({
|
||||
results: [],
|
||||
success: true,
|
||||
})
|
||||
|
||||
const result = await getArtists(mockEnv)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
mockEnv.DB.all.mockRejectedValue(new Error('Database error'))
|
||||
|
||||
await expect(getArtists(mockEnv)).rejects.toThrow('Database error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getArtistWithPortfolio', () => {
|
||||
it('should fetch artist with portfolio images', async () => {
|
||||
const mockArtist = {
|
||||
id: '1',
|
||||
name: 'Test Artist',
|
||||
bio: 'Test bio',
|
||||
specialties: '["Traditional"]',
|
||||
isActive: 1,
|
||||
}
|
||||
|
||||
const mockImages = [
|
||||
{
|
||||
id: '1',
|
||||
artistId: '1',
|
||||
url: 'https://example.com/image.jpg',
|
||||
caption: 'Test image',
|
||||
tags: '["Traditional","Portrait"]',
|
||||
isPublic: 1,
|
||||
orderIndex: 0,
|
||||
},
|
||||
]
|
||||
|
||||
mockEnv.DB.first.mockResolvedValueOnce(mockArtist)
|
||||
mockEnv.DB.all.mockResolvedValueOnce({
|
||||
results: mockImages,
|
||||
success: true,
|
||||
})
|
||||
|
||||
const result = await getArtistWithPortfolio('1', mockEnv)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.name).toBe('Test Artist')
|
||||
expect(result?.portfolioImages).toHaveLength(1)
|
||||
expect(result?.portfolioImages[0].tags).toEqual(['Traditional', 'Portrait'])
|
||||
})
|
||||
|
||||
it('should return null for non-existent artist', async () => {
|
||||
mockEnv.DB.first.mockResolvedValue(null)
|
||||
|
||||
const result = await getArtistWithPortfolio('999', mockEnv)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPublicArtists', () => {
|
||||
it('should return only active artists with public images', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Active Artist',
|
||||
specialties: '["Traditional"]',
|
||||
isActive: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Inactive Artist',
|
||||
specialties: '["Realism"]',
|
||||
isActive: 0,
|
||||
},
|
||||
]
|
||||
|
||||
mockEnv.DB.all.mockResolvedValue({
|
||||
results: mockArtists.filter(a => a.isActive),
|
||||
success: true,
|
||||
})
|
||||
|
||||
const result = await getPublicArtists({}, mockEnv)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('Active Artist')
|
||||
})
|
||||
|
||||
it('should filter by specialty', async () => {
|
||||
const mockArtists = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Traditional Artist',
|
||||
specialties: '["Traditional"]',
|
||||
isActive: 1,
|
||||
},
|
||||
]
|
||||
|
||||
mockEnv.DB.all.mockResolvedValue({
|
||||
results: mockArtists,
|
||||
success: true,
|
||||
})
|
||||
|
||||
await getPublicArtists({ specialty: 'Traditional' }, mockEnv)
|
||||
|
||||
// Verify the bind was called (specialty filter applied)
|
||||
expect(mockEnv.DB.bind).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getArtistBySlug', () => {
|
||||
it('should fetch artist by slug', async () => {
|
||||
const mockArtist = {
|
||||
id: '1',
|
||||
slug: 'test-artist',
|
||||
name: 'Test Artist',
|
||||
specialties: '["Traditional"]',
|
||||
}
|
||||
|
||||
mockEnv.DB.first.mockResolvedValue(mockArtist)
|
||||
mockEnv.DB.all.mockResolvedValue({
|
||||
results: [],
|
||||
success: true,
|
||||
})
|
||||
|
||||
const result = await getArtistBySlug('test-artist', mockEnv)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.slug).toBe('test-artist')
|
||||
expect(mockEnv.DB.bind).toHaveBeenCalledWith('test-artist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateArtist', () => {
|
||||
it('should update artist and stringify JSON fields', async () => {
|
||||
const updateData = {
|
||||
id: '1',
|
||||
name: 'Updated Name',
|
||||
bio: 'Updated bio',
|
||||
specialties: ['Traditional', 'Realism'],
|
||||
hourlyRate: 150,
|
||||
}
|
||||
|
||||
mockEnv.DB.run.mockResolvedValue({
|
||||
success: true,
|
||||
meta: { changes: 1 },
|
||||
})
|
||||
|
||||
await updateArtist('1', updateData, mockEnv)
|
||||
|
||||
// Verify the update was called
|
||||
expect(mockEnv.DB.run).toHaveBeenCalled()
|
||||
expect(mockEnv.DB.bind).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Portfolio Image Operations', () => {
|
||||
it('should add portfolio image', async () => {
|
||||
const imageData = {
|
||||
url: 'https://example.com/image.jpg',
|
||||
caption: 'Test caption',
|
||||
tags: ['Traditional'],
|
||||
isPublic: true,
|
||||
orderIndex: 0,
|
||||
}
|
||||
|
||||
mockEnv.DB.run.mockResolvedValue({
|
||||
success: true,
|
||||
meta: { last_row_id: 1 },
|
||||
})
|
||||
|
||||
mockEnv.DB.first.mockResolvedValue({
|
||||
id: '1',
|
||||
...imageData,
|
||||
artistId: '1',
|
||||
tags: JSON.stringify(imageData.tags),
|
||||
})
|
||||
|
||||
const result = await addPortfolioImage('1', imageData, mockEnv)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result.caption).toBe('Test caption')
|
||||
})
|
||||
|
||||
it('should update portfolio image', async () => {
|
||||
const updateData = {
|
||||
caption: 'Updated caption',
|
||||
tags: ['Traditional', 'Portrait'],
|
||||
isPublic: false,
|
||||
}
|
||||
|
||||
mockEnv.DB.run.mockResolvedValue({
|
||||
success: true,
|
||||
meta: { changes: 1 },
|
||||
})
|
||||
|
||||
await updatePortfolioImage('1', updateData, mockEnv)
|
||||
|
||||
expect(mockEnv.DB.run).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should delete portfolio image', async () => {
|
||||
mockEnv.DB.run.mockResolvedValue({
|
||||
success: true,
|
||||
meta: { changes: 1 },
|
||||
})
|
||||
|
||||
await deletePortfolioImage('1', mockEnv)
|
||||
|
||||
expect(mockEnv.DB.run).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
92
__tests__/lib/flags.test.ts
Normal file
92
__tests__/lib/flags.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
FLAG_DEFAULTS,
|
||||
Flags,
|
||||
getFlags,
|
||||
registerRuntimeFlags,
|
||||
resetFlagsCache,
|
||||
parseBool,
|
||||
} from "@/lib/flags"
|
||||
|
||||
type FlagName = keyof typeof FLAG_DEFAULTS
|
||||
const flagKeys = Object.keys(FLAG_DEFAULTS) as FlagName[]
|
||||
|
||||
const originalEnv: Partial<Record<FlagName, string | undefined>> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
resetFlagsCache()
|
||||
for (const key of flagKeys) {
|
||||
if (!(key in originalEnv)) {
|
||||
originalEnv[key] = process.env[key]
|
||||
}
|
||||
delete process.env[key]
|
||||
}
|
||||
delete (globalThis as Record<string, unknown>).__UNITED_TATTOO_RUNTIME_FLAGS__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetFlagsCache()
|
||||
for (const key of flagKeys) {
|
||||
const value = originalEnv[key]
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
delete (globalThis as Record<string, unknown>).__UNITED_TATTOO_RUNTIME_FLAGS__
|
||||
})
|
||||
|
||||
describe("parseBool", () => {
|
||||
it("handles string coercion and defaults", () => {
|
||||
expect(parseBool("true", false)).toBe(true)
|
||||
expect(parseBool(" FALSE ", true)).toBe(false)
|
||||
expect(parseBool("1", false)).toBe(true)
|
||||
expect(parseBool(undefined, true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getFlags", () => {
|
||||
it("falls back to defaults and logs missing keys", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
|
||||
const snapshot = getFlags({ refresh: true })
|
||||
|
||||
expect(snapshot).toMatchObject(FLAG_DEFAULTS)
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("honours environment overrides", () => {
|
||||
process.env.BOOKING_ENABLED = "false"
|
||||
process.env.PUBLIC_APPOINTMENT_REQUESTS_ENABLED = "true"
|
||||
|
||||
const snapshot = getFlags({ refresh: true })
|
||||
|
||||
expect(snapshot.BOOKING_ENABLED).toBe(false)
|
||||
expect(snapshot.PUBLIC_APPOINTMENT_REQUESTS_ENABLED).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("registerRuntimeFlags", () => {
|
||||
it("allows runtime overrides to take precedence", () => {
|
||||
process.env.BOOKING_ENABLED = "true"
|
||||
const override = { ...FLAG_DEFAULTS, BOOKING_ENABLED: false } as typeof FLAG_DEFAULTS
|
||||
|
||||
registerRuntimeFlags(override)
|
||||
const snapshot = getFlags()
|
||||
|
||||
expect(snapshot.BOOKING_ENABLED).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Flags proxy", () => {
|
||||
it("reflects current snapshot values", () => {
|
||||
process.env.ADMIN_ENABLED = "false"
|
||||
const snapshot = getFlags({ refresh: true })
|
||||
expect(snapshot.ADMIN_ENABLED).toBe(false)
|
||||
expect(Flags.ADMIN_ENABLED).toBe(false)
|
||||
})
|
||||
})
|
||||
92
__tests__/lib/validations.test.ts
Normal file
92
__tests__/lib/validations.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createArtistSchema, createAppointmentSchema } from '@/lib/validations'
|
||||
|
||||
describe('Validation Schemas', () => {
|
||||
describe('createArtistSchema', () => {
|
||||
it('should validate a valid artist object', () => {
|
||||
const validArtist = {
|
||||
name: 'John Doe',
|
||||
bio: 'Experienced tattoo artist',
|
||||
specialties: ['Traditional', 'Realism'],
|
||||
instagramHandle: 'johndoe',
|
||||
hourlyRate: 150,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const result = createArtistSchema.safeParse(validArtist)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject artist with invalid data', () => {
|
||||
const invalidArtist = {
|
||||
name: '', // Empty name should fail
|
||||
bio: 'Bio',
|
||||
specialties: [],
|
||||
hourlyRate: -50, // Negative rate should fail
|
||||
}
|
||||
|
||||
const result = createArtistSchema.safeParse(invalidArtist)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should require name field', () => {
|
||||
const artistWithoutName = {
|
||||
bio: 'Bio',
|
||||
specialties: ['Traditional'],
|
||||
hourlyRate: 150,
|
||||
}
|
||||
|
||||
const result = createArtistSchema.safeParse(artistWithoutName)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAppointmentSchema', () => {
|
||||
it('should validate a valid appointment object', () => {
|
||||
const validAppointment = {
|
||||
clientName: 'Jane Smith',
|
||||
clientEmail: 'jane@example.com',
|
||||
clientPhone: '+1234567890',
|
||||
artistId: 'artist-123',
|
||||
startTime: new Date('2024-12-01T10:00:00Z'),
|
||||
endTime: new Date('2024-12-01T12:00:00Z'),
|
||||
description: 'Traditional rose tattoo',
|
||||
estimatedPrice: 300,
|
||||
status: 'PENDING' as const,
|
||||
}
|
||||
|
||||
const result = createAppointmentSchema.safeParse(validAppointment)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject appointment with invalid email', () => {
|
||||
const invalidAppointment = {
|
||||
clientName: 'Jane Smith',
|
||||
clientEmail: 'invalid-email', // Invalid email format
|
||||
artistId: 'artist-123',
|
||||
startTime: new Date('2024-12-01T10:00:00Z'),
|
||||
endTime: new Date('2024-12-01T12:00:00Z'),
|
||||
description: 'Tattoo description',
|
||||
status: 'PENDING' as const,
|
||||
}
|
||||
|
||||
const result = createAppointmentSchema.safeParse(invalidAppointment)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject appointment with end time before start time', () => {
|
||||
const invalidAppointment = {
|
||||
clientName: 'Jane Smith',
|
||||
clientEmail: 'jane@example.com',
|
||||
artistId: 'artist-123',
|
||||
startTime: new Date('2024-12-01T12:00:00Z'),
|
||||
endTime: new Date('2024-12-01T10:00:00Z'), // End before start
|
||||
description: 'Tattoo description',
|
||||
status: 'PENDING' as const,
|
||||
}
|
||||
|
||||
const result = createAppointmentSchema.safeParse(invalidAppointment)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
144
app_root_backup/globals.css
Normal file
144
app_root_backup/globals.css
Normal file
@ -0,0 +1,144 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.15 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.68 0.19 45);
|
||||
--primary-foreground: oklch(0 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.68 0.19 45);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* optional: --font-sans, --font-serif, --font-mono if they are applied in the layout.tsx */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #ff8c00 #000000;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #ff8c00;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #ff9d1a;
|
||||
}
|
||||
}
|
||||
8342
cloudflare-env.d.ts
vendored
Normal file
8342
cloudflare-env.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
22
components.json
Normal file
22
components.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
46
docs/bootstrapping.md
Normal file
46
docs/bootstrapping.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Bootstrapping Checklist
|
||||
|
||||
This checklist walks you from cloning the template to having a runnable project with confident defaults. Keep it open while you initialise a new repo.
|
||||
|
||||
## 1. Template hygiene
|
||||
- [ ] Create a fresh repository (local or Gitea) and copy the template into it.
|
||||
- [ ] Run `./scripts/bootstrap-template.sh` to update the package name, git remotes, and README badges.
|
||||
- [ ] Remove example images or assets you do not plan to ship (`public/`, `docs/img/`, etc.).
|
||||
- [ ] Delete unused test suites so the CI noise floor stays low.
|
||||
|
||||
## 2. Runtime setup
|
||||
- [ ] Review `.env.example` and duplicate it to `.env` for local development.
|
||||
- [ ] Fill only the sections that match the integrations you intend to use (auth, storage, calendar, analytics).
|
||||
- [ ] Create secrets in your chosen manager (doppler, sops, 1Password CLI, environment repository) and document where they live.
|
||||
- [ ] Configure feature flags or toggles that gate optional modules; default to safe fallbacks.
|
||||
|
||||
## 3. Dependencies & tooling
|
||||
- [ ] Decide on a package manager (pnpm, npm, yarn) and lock it in the README + CI.
|
||||
- [ ] Install linting and formatting tools (`eslint`, `prettier`, `biome`, etc.) and wire them into `package.json` scripts.
|
||||
- [ ] Add base Git hooks (Husky, Lefthook, or pre-commit) if you rely on pre-push validation.
|
||||
- [ ] Configure TypeScript paths/aliases so the example tests resolve once you create real modules.
|
||||
|
||||
## 4. Infrastructure & services
|
||||
- [ ] Document deployment tooling (Wrangler, Vercel, Fly.io, Docker) in `docs/stack-decisions.md`.
|
||||
- [ ] Provision staging and production environments or capture the manual steps.
|
||||
- [ ] Outline database migration flow (Prisma, Drizzle, Kysely) and how to run it locally.
|
||||
- [ ] For third-party integrations (OAuth, storage, calendar), confirm rate limits and timeout behaviour.
|
||||
|
||||
## 5. CI/CD wiring
|
||||
- [ ] Choose a pipeline runner (Gitea Actions, Woodpecker, GitHub Actions, etc.).
|
||||
- [ ] Add jobs for lint, typecheck, unit tests, and build (if applicable).
|
||||
- [ ] Cache dependencies to keep pipelines fast.
|
||||
- [ ] Gate deployments on green checks and review status.
|
||||
|
||||
## 6. Documentation & knowledge
|
||||
- [ ] Update `README.md` with product-specific copy, screenshots, and deployment commands.
|
||||
- [ ] Record architectural decisions in `docs/stack-decisions.md` (lean ADRs are ideal).
|
||||
- [ ] Extend `docs/edge-cases.md` with anything unique to this project.
|
||||
- [ ] Share workflow instructions (branching, PR labels, release cadence) in `CONTRIBUTING.md`.
|
||||
|
||||
## 7. Launch readiness
|
||||
- [ ] Smoke-test the bootstrap by running `pnpm test` (or your equivalent) and ensuring the example specs fail because modules are missing—this keeps you honest.
|
||||
- [ ] Create a tracking issue or project board with tasks generated from the checklist.
|
||||
- [ ] Archive or export the checklist with completed items for future reference.
|
||||
|
||||
Repeat this ritual for every new project so you ship with fewer unknowns and more confidence.
|
||||
50
docs/edge-cases.md
Normal file
50
docs/edge-cases.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Edge Case Catalogue
|
||||
|
||||
Capture every non-happy-path scenario that has bitten you in past projects. Use these prompts when planning features, writing tests, and updating documentation.
|
||||
|
||||
## Authentication & Authorization
|
||||
- What happens when the identity provider is unavailable or rate-limited?
|
||||
- Can users authenticate with multiple providers? How do you reconcile identities?
|
||||
- Do access tokens expire mid-session? Plan silent refresh and forced logout flows.
|
||||
- Are admin-only routes guarded on the server, not just the client?
|
||||
- How do you roll keys or secrets without booting everyone?
|
||||
|
||||
## Feature Flags & Configuration
|
||||
- Can new features be disabled quickly without redeploying?
|
||||
- Are default values safe when the config service is unreachable?
|
||||
- What is logged when a flag evaluation fails?
|
||||
|
||||
## Data & Persistence
|
||||
- Are migrations idempotent? Can you roll them back?
|
||||
- Do background jobs tolerate partial failure or duplicate delivery?
|
||||
- What size assumptions exist for JSON payloads, binary blobs, or text fields?
|
||||
- How do you seed development data without leaking production secrets?
|
||||
|
||||
## Scheduling & Calendars
|
||||
- Do you store timestamps in UTC and render them with the user's offset?
|
||||
- How do you handle daylight saving transitions and leap seconds?
|
||||
- Can overlapping events be created? If not, validate and surface clear errors.
|
||||
- What is the source of truth when multiple calendars sync into one timeline?
|
||||
|
||||
## File & Asset Management
|
||||
- Maximum file size? Enforce both client and server-side.
|
||||
- Are uploads scanned, transcoded, or resized? Where is the queue?
|
||||
- How do you serve private files? Signed URLs, download proxies, expiring tokens?
|
||||
- What is the retention policy and deletion workflow?
|
||||
|
||||
## External Services
|
||||
- Plan for timeouts, retries, and rate limits on each integration.
|
||||
- If a vendor returns partial data, does your UI still render something helpful?
|
||||
- Document SLAs and fallbacks in `docs/stack-decisions.md`.
|
||||
|
||||
## Observability & Recovery
|
||||
- Which metrics, logs, and traces are mandatory before launch?
|
||||
- Do alerts route to a real person with enough context to act?
|
||||
- After an incident, what automated reports or scripts help recreate the scenario?
|
||||
|
||||
## Compliance & Privacy
|
||||
- How do you handle data export, erasure, and consent?
|
||||
- What environments carry production data? Are they encrypted at rest?
|
||||
- Which audit logs must be preserved, and where?
|
||||
|
||||
When a new surprise occurs, write the story here, then open a PR to harden the template so the next project benefits immediately.
|
||||
32
docs/stack-decisions.md
Normal file
32
docs/stack-decisions.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Stack Decisions Log
|
||||
|
||||
Use this document to capture the "why" behind platform and tooling choices. Treat each entry as a lightweight ADR (Architecture Decision Record).
|
||||
|
||||
## Template baseline
|
||||
- **Framework**: Next.js + TypeScript (edge-friendly, hybrid rendering, great DX)
|
||||
- **Testing**: Vitest (fast unit/integration runner with React ecosystem support)
|
||||
- **Styling**: Tailwind CSS or CSS Modules (choose one per project)
|
||||
- **Deployment**: Cloudflare Pages + Workers (immutable deploys, global edge)
|
||||
- **Database**: PostgreSQL (Supabase/Neon friendly), accessed via ORM of choice
|
||||
- **Storage**: S3-compatible buckets (AWS S3, Cloudflare R2)
|
||||
|
||||
## Recording a decision
|
||||
1. Title — short phrase (`Adopt Drizzle ORM`, `Switch CI to Woodpecker`)
|
||||
2. Context — what problem are you solving? Mention constraints, stakeholders, and trade-offs.
|
||||
3. Decision — what did you pick and why?
|
||||
4. Status — proposed, accepted, deprecated, superseded.
|
||||
5. Consequences — positive and negative effects, migrations required, follow-up work.
|
||||
|
||||
## Example entry
|
||||
```
|
||||
## Adopt Drizzle ORM
|
||||
Status: Accepted (2024-02-12)
|
||||
Context: Need a type-safe query builder that works in serverless environments without generating heavyweight clients.
|
||||
Decision: Replace Prisma with Drizzle ORM because it offers SQL-first migrations, small runtime footprint, and better edge compatibility.
|
||||
Consequences:
|
||||
- Rewrite existing Prisma migrations → Drizzle SQL migrations.
|
||||
- Update CI to run `drizzle-kit push` instead of `prisma migrate deploy`.
|
||||
- Developers need to learn the new query builder API.
|
||||
```
|
||||
|
||||
Keep this log close to the code. When you revisit a project months later, these notes will save hours of rediscovery.
|
||||
41
docs/testing-blueprints.md
Normal file
41
docs/testing-blueprints.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Testing Blueprints
|
||||
|
||||
The template ships with full Vitest suites under `__tests__/`. They currently reference the `@/` module alias from a Next.js project. Replace those imports with your actual modules as you build the app. Use this guide to adapt the patterns.
|
||||
|
||||
## API routes
|
||||
- File: `__tests__/api/*`
|
||||
- Focus: HTTP status codes, query parsing, pagination, error handling.
|
||||
- Mocks: database adapters, third-party SDKs, feature flags.
|
||||
- Tips: assert on both response body and the parameters passed into mocked dependencies.
|
||||
|
||||
## Components
|
||||
- File: `__tests__/components/*`
|
||||
- Focus: accessibility, copy, conditional rendering, navigation flows.
|
||||
- Mocks: Next.js router, contexts, external hooks.
|
||||
- Tips: render minimal UI tree, interact with `@testing-library/react` utilities, assert on semantics not implementation.
|
||||
|
||||
## Hooks
|
||||
- File: `__tests__/hooks/*`
|
||||
- Focus: lifecycle, browser APIs (scroll, resize), async behaviour.
|
||||
- Mocks: `window`, `document`, timers, intersection observers.
|
||||
- Tips: wrap `act` around updates, reset mocks between tests, include teardown coverage.
|
||||
|
||||
## Flags & configuration
|
||||
- File: `__tests__/flags/*`
|
||||
- Focus: toggling features on/off, server-side overrides, fallbacks.
|
||||
- Mocks: flag evaluation client, configuration store.
|
||||
- Tips: include “flag service offline” scenarios to enforce safe defaults.
|
||||
|
||||
## Libraries
|
||||
- File: `__tests__/lib/*`
|
||||
- Focus: data migration guards, validation, persistence abstractions.
|
||||
- Mocks: filesystem, database clients, clock.
|
||||
- Tips: write table-driven tests so new edge cases are easy to add.
|
||||
|
||||
### Making them yours
|
||||
1. Rename folders to match real domains (`users`, `billing`, `cms`).
|
||||
2. Swap module imports from `@/lib/...` to wherever your implementation lives.
|
||||
3. Keep the error-handling tests, even if you simplify the happy path—they are cheap insurance.
|
||||
4. Run `pnpm test` (or your equivalent) often; treat failures as documentation gaps.
|
||||
|
||||
These suites double as onboarding material. New contributors can read the tests to understand intent before diving into production code.
|
||||
16
eslint.config.mjs
Normal file
16
eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc/dist/eslintrc.cjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
21
next-steps.md
Normal file
21
next-steps.md
Normal file
@ -0,0 +1,21 @@
|
||||
Here are the new features that should be added/changes that should be made.
|
||||
|
||||
Firstly, this web application was originally made for a friend who was less code-savvy than myself
|
||||
and it allowed him to stay updated on our development project as it was ongoing (without having
|
||||
to bother me). Now, however, I've realized that I am in need of a good chat interface for my n8n
|
||||
powered agents. and I think this would make a good starting point.
|
||||
|
||||
The biggest changes are two things:
|
||||
1. The application needs to support connecting to multiple agents in different chats.
|
||||
2. I would like to deploy the application to a cloudflare worker, at the domain `agents.nicholai.work`.
|
||||
|
||||
The flow of the UI will be different, starting with what users are greeted with when they first visit the page.
|
||||
When a user visits the site, they should be greeted by a menu of agents (their names & descriptions), they will have the option to select an agent,
|
||||
and upon selection, the menu will close and their chat may begin.
|
||||
|
||||
I would like to be able to add new agents at any time, by adding environment variables in the cloudflare UI.
|
||||
|
||||
The chat needs to support rich text, which it mostly already does. And users should be able to pass images
|
||||
to the agents. (if the agent is compatible).
|
||||
|
||||
|
||||
11
next.config.ts
Normal file
11
next.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
|
||||
|
||||
// Initialize OpenNext for Cloudflare in development
|
||||
initOpenNextCloudflareForDev();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
9
open-next.config.ts
Normal file
9
open-next.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
export default defineCloudflareConfig({
|
||||
// Uncomment to enable R2 cache,
|
||||
// It should be imported as:
|
||||
// `import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";`
|
||||
// See https://opennext.js.org/cloudflare/caching for more details
|
||||
// incrementalCache: r2IncrementalCache,
|
||||
});
|
||||
32
opennext-deploy-debug.log
Normal file
32
opennext-deploy-debug.log
Normal file
@ -0,0 +1,32 @@
|
||||
|
||||
ΓöîΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÉ
|
||||
Γöé OpenNext ΓÇö Cloudflare deploy Γöé
|
||||
ΓööΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÿ
|
||||
|
||||
WARN OpenNext is not fully compatible with Windows.
|
||||
WARN For optimal performance, it is recommended to use Windows Subsystem for Linux (WSL).
|
||||
WARN While OpenNext may function on Windows, it could encounter unpredictable failures during runtime.
|
||||
opennextjs-cloudflare deploy
|
||||
|
||||
Deploy a built OpenNext app to Cloudflare Workers
|
||||
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
-c, --config Path to Wrangler configuration file [string]
|
||||
--configPath Path to Wrangler configuration file[deprecated] [string]
|
||||
-e, --env Wrangler environment to use for operations [string]
|
||||
--cacheChunkSize Number of entries per chunk when populating the cache
|
||||
[number]
|
||||
|
||||
Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'
|
||||
at throwIfUnsupportedURLScheme (node:internal/modules/esm/load:184:11)
|
||||
at defaultLoad (node:internal/modules/esm/load:82:3)
|
||||
at ModuleLoader.load (node:internal/modules/esm/loader:803:12)
|
||||
at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:582:43)
|
||||
at #createModuleJob (node:internal/modules/esm/loader:606:36)
|
||||
at #getJobFromResolveResult (node:internal/modules/esm/loader:340:34)
|
||||
at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:308:41)
|
||||
at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:646:25) {
|
||||
code: 'ERR_UNSUPPORTED_ESM_URL_SCHEME'
|
||||
}
|
||||
19626
package-lock.json
generated
Normal file
19626
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
82
package.json
Normal file
82
package.json
Normal file
@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "my-v0-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@opennextjs/cloudflare": "^1.12.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-context-menu": "2.2.4",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-hover-card": "1.1.4",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-menubar": "1.1.4",
|
||||
"@radix-ui/react-navigation-menu": "1.2.3",
|
||||
"@radix-ui/react-popover": "1.1.4",
|
||||
"@radix-ui/react-progress": "1.1.1",
|
||||
"@radix-ui/react-radio-group": "1.2.2",
|
||||
"@radix-ui/react-scroll-area": "1.2.2",
|
||||
"@radix-ui/react-select": "2.1.4",
|
||||
"@radix-ui/react-separator": "1.1.1",
|
||||
"@radix-ui/react-slider": "1.2.2",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-switch": "1.1.2",
|
||||
"@radix-ui/react-tabs": "1.1.2",
|
||||
"@radix-ui/react-toast": "1.2.4",
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@vercel/analytics": "1.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "4.1.0",
|
||||
"diff": "^8.0.2",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"geist": "^1.3.1",
|
||||
"input-otp": "1.4.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "15.5.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "9.8.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.9",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^9.39.1",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "^5",
|
||||
"wrangler": "^4.42.0"
|
||||
}
|
||||
}
|
||||
9468
pnpm-lock.yaml
generated
Normal file
9468
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
3
public/_headers
Normal file
3
public/_headers
Normal file
@ -0,0 +1,3 @@
|
||||
# https://developers.cloudflare.com/workers/static-assets/headers
|
||||
/_next/static/*
|
||||
Cache-Control: public,max-age=31536000,immutable
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
90
scripts/bootstrap-template.sh
Executable file
90
scripts/bootstrap-template.sh
Executable file
@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Bootstrap a fresh project from this template.
|
||||
# - Updates README heading with the new project name.
|
||||
# - Creates an initial commit if none exists.
|
||||
# - Optionally rewires git remotes.
|
||||
# - Touches package.json if you want to set the name field (only when present).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||
|
||||
echo "🚀 Template bootstrap"
|
||||
|
||||
read -r -p "Project name (e.g. \"Atlas Console\"): " project_name
|
||||
if [[ -z "${project_name}" ]]; then
|
||||
echo "Project name cannot be empty. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
default_slug="$(echo "${project_name}" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd '[:alnum:]-')"
|
||||
read -r -p "Project slug [${default_slug}]: " project_slug
|
||||
project_slug="${project_slug:-$default_slug}"
|
||||
|
||||
# Update README heading if it still contains the template title.
|
||||
readme="${repo_root}/README.md"
|
||||
if grep -q "^Development Project Template" "${readme}"; then
|
||||
echo "Updating README title..."
|
||||
tmp_readme="$(mktemp)"
|
||||
{
|
||||
echo "${project_name}"
|
||||
echo "${project_name//?/=}"
|
||||
tail -n +3 "${readme}"
|
||||
} > "${tmp_readme}"
|
||||
mv "${tmp_readme}" "${readme}"
|
||||
else
|
||||
echo "README already customised; skipping title update."
|
||||
fi
|
||||
|
||||
# Update package.json name if present.
|
||||
package_json="${repo_root}/package.json"
|
||||
if [[ -f "${package_json}" ]]; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
echo "Updating package.json name → ${project_slug}"
|
||||
tmp_package="$(mktemp)"
|
||||
jq --arg name "${project_slug}" '.name = $name' "${package_json}" > "${tmp_package}"
|
||||
mv "${tmp_package}" "${package_json}"
|
||||
else
|
||||
echo "jq is not installed; skipping package.json update. Install jq and rerun if needed."
|
||||
fi
|
||||
else
|
||||
echo "No package.json found; skipping package rename."
|
||||
fi
|
||||
|
||||
# Offer to update git origin remote.
|
||||
if git -C "${repo_root}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
current_remote="$(git -C "${repo_root}" remote get-url origin 2>/dev/null || true)"
|
||||
echo "Current git remote: ${current_remote:-<none>}"
|
||||
read -r -p "Update git remote? (y/N): " change_remote
|
||||
if [[ "${change_remote,,}" == "y" ]]; then
|
||||
read -r -p "New remote URL: " remote_url
|
||||
if git -C "${repo_root}" remote | grep -q "^origin$"; then
|
||||
git -C "${repo_root}" remote set-url origin "${remote_url}"
|
||||
else
|
||||
git -C "${repo_root}" remote add origin "${remote_url}"
|
||||
fi
|
||||
echo "Origin remote updated."
|
||||
else
|
||||
echo "Skipping remote update."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Stamp a .project-name file so scripts/tools can read the canonical name.
|
||||
echo "${project_name}" > "${repo_root}/.project-name"
|
||||
|
||||
cat <<EOT
|
||||
|
||||
✅ Bootstrap complete!
|
||||
- README updated (if the template title was untouched).
|
||||
- package.json name set to ${project_slug} (if package.json exists).
|
||||
- Project name written to .project-name.
|
||||
|
||||
Next steps:
|
||||
1. Review docs/bootstrapping.md and keep working through the checklist.
|
||||
2. Remove or adapt example tests under __tests__/.
|
||||
3. Replace template copy and assets with your project's branding.
|
||||
|
||||
Happy building!
|
||||
EOT
|
||||
66
src/app/api/agents/route.ts
Normal file
66
src/app/api/agents/route.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import type { Agent, AgentsResponse } from "@/lib/types"
|
||||
|
||||
/**
|
||||
* GET /api/agents
|
||||
* Returns list of available agents configured via environment variables
|
||||
*
|
||||
* Expected environment variables format:
|
||||
* - AGENT_1_URL, AGENT_1_NAME, AGENT_1_DESCRIPTION
|
||||
* - AGENT_2_URL, AGENT_2_NAME, AGENT_2_DESCRIPTION
|
||||
* - etc.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse<AgentsResponse>> {
|
||||
try {
|
||||
const agents: Agent[] = []
|
||||
|
||||
// Parse agent configurations from environment variables
|
||||
// Look for AGENT_N_URL, AGENT_N_NAME, AGENT_N_DESCRIPTION patterns
|
||||
let agentIndex = 1
|
||||
|
||||
while (true) {
|
||||
const urlKey = `AGENT_${agentIndex}_URL`
|
||||
const nameKey = `AGENT_${agentIndex}_NAME`
|
||||
const descriptionKey = `AGENT_${agentIndex}_DESCRIPTION`
|
||||
|
||||
const webhookUrl = process.env[urlKey]
|
||||
const name = process.env[nameKey]
|
||||
const description = process.env[descriptionKey]
|
||||
|
||||
// Stop if we don't find a URL for this index
|
||||
if (!webhookUrl) {
|
||||
break
|
||||
}
|
||||
|
||||
// Require at least URL and name
|
||||
if (!name) {
|
||||
console.warn(`[agents] Agent ${agentIndex} missing name, skipping`)
|
||||
agentIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
agents.push({
|
||||
id: `agent-${agentIndex}`,
|
||||
name,
|
||||
description: description || "",
|
||||
webhookUrl,
|
||||
})
|
||||
|
||||
agentIndex++
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
console.warn("[agents] No agents configured in environment variables")
|
||||
}
|
||||
|
||||
console.log(`[agents] Loaded ${agents.length} agents`)
|
||||
|
||||
return NextResponse.json({ agents })
|
||||
} catch (error) {
|
||||
console.error("[agents] Error loading agents:", error)
|
||||
return NextResponse.json(
|
||||
{ agents: [], error: "Failed to load agents" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
196
src/app/api/chat/route.ts
Normal file
196
src/app/api/chat/route.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import type { ChatRequest, ChatResponse } from "@/lib/types"
|
||||
|
||||
/**
|
||||
* Get webhook URL for a specific agent from environment variables
|
||||
* Format: AGENT_{agentIndex}_URL
|
||||
*/
|
||||
function getAgentWebhookUrl(agentId: string): string | null {
|
||||
// Extract agent index from agentId (format: "agent-1", "agent-2", etc.)
|
||||
const match = agentId.match(/agent-(\d+)/)
|
||||
if (!match) {
|
||||
console.error("[chat] Invalid agentId format:", agentId)
|
||||
return null
|
||||
}
|
||||
|
||||
const agentIndex = match[1]
|
||||
const urlKey = `AGENT_${agentIndex}_URL`
|
||||
const webhookUrl = process.env[urlKey]
|
||||
|
||||
if (!webhookUrl) {
|
||||
console.error(`[chat] No webhook URL configured for ${urlKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return webhookUrl
|
||||
}
|
||||
|
||||
// Helper function to convert diff tool call to markdown format
|
||||
function convertToDiffTool(args: any): string {
|
||||
try {
|
||||
const { oldCode, newCode, title, language } = args
|
||||
|
||||
if (!oldCode || !newCode) {
|
||||
return "Error: Missing oldCode or newCode in diff tool call"
|
||||
}
|
||||
|
||||
const diffToolCall = {
|
||||
oldCode: String(oldCode).replace(/\n/g, '\\n'),
|
||||
newCode: String(newCode).replace(/\n/g, '\\n'),
|
||||
title: title || "Code Changes",
|
||||
language: language || "text"
|
||||
}
|
||||
|
||||
return `\`\`\`diff-tool\n${JSON.stringify(diffToolCall, null, 2)}\n\`\`\``
|
||||
} catch (error) {
|
||||
console.error("[v0] Error converting diff tool:", error)
|
||||
return "Error: Failed to process diff tool call"
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse<ChatResponse>> {
|
||||
try {
|
||||
const body = await request.json()
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const { message, timestamp, sessionId, agentId, images } = body as ChatRequest
|
||||
|
||||
// Validate required fields
|
||||
if (!message || typeof message !== "string") {
|
||||
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!agentId || typeof agentId !== "string") {
|
||||
return NextResponse.json({ error: "Agent ID is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get webhook URL for the selected agent
|
||||
const webhookUrl = getAgentWebhookUrl(agentId)
|
||||
if (!webhookUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: `Agent ${agentId} is not properly configured` },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
console.log("[chat] Sending to webhook:", { agentId, message, timestamp, sessionId })
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
timestamp,
|
||||
sessionId,
|
||||
agentId,
|
||||
images: images && images.length > 0 ? images : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
console.log("[v0] Webhook response status:", response.status)
|
||||
|
||||
const responseText = await response.text()
|
||||
console.log("[v0] Webhook response body (first 200 chars):", responseText.substring(0, 200))
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse as JSON if possible, otherwise use text
|
||||
let errorData
|
||||
try {
|
||||
errorData = responseText ? JSON.parse(responseText) : {}
|
||||
} catch {
|
||||
errorData = { message: responseText || "Unknown error" }
|
||||
}
|
||||
|
||||
console.error("[v0] Webhook error:", errorData)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: errorData.message || "Failed to communicate with webhook",
|
||||
hint: errorData.hint,
|
||||
code: errorData.code,
|
||||
},
|
||||
{ status: response.status },
|
||||
)
|
||||
}
|
||||
|
||||
if (!responseText) {
|
||||
console.log("[v0] Empty response from webhook")
|
||||
return NextResponse.json({
|
||||
response:
|
||||
"The webhook received your message but didn't return a response. Please ensure your n8n workflow includes a 'Respond to Webhook' node that returns data.",
|
||||
hint: "Add a 'Respond to Webhook' node in your n8n workflow to send responses back to the chat.",
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// Split response by newlines to get individual JSON objects
|
||||
const lines = responseText.trim().split("\n")
|
||||
const chunks: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
try {
|
||||
const chunk = JSON.parse(line)
|
||||
|
||||
// Extract content from "item" type chunks
|
||||
if (chunk.type === "item" && chunk.content) {
|
||||
chunks.push(chunk.content)
|
||||
}
|
||||
|
||||
// Handle diff tool calls
|
||||
if (chunk.type === "tool_call" && chunk.name === "show_diff") {
|
||||
const diffTool = convertToDiffTool(chunk.args)
|
||||
chunks.push(diffTool)
|
||||
}
|
||||
} catch {
|
||||
console.log("[v0] Failed to parse line:", line)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all chunks into a single message
|
||||
if (chunks.length > 0) {
|
||||
const fullMessage = chunks.join("")
|
||||
console.log("[v0] Combined message from", chunks.length, "chunks")
|
||||
return NextResponse.json({ response: fullMessage })
|
||||
}
|
||||
|
||||
// If no chunks found, try parsing as regular JSON
|
||||
const data = JSON.parse(responseText)
|
||||
console.log("[v0] Parsed webhook data:", data)
|
||||
|
||||
// Check if this is a diff tool call
|
||||
if (data.type === "tool_call" && data.name === "show_diff") {
|
||||
const diffTool = convertToDiffTool(data.args)
|
||||
return NextResponse.json({ response: diffTool })
|
||||
}
|
||||
|
||||
// Extract the response from various possible fields
|
||||
let responseMessage = data.response || data.message || data.output || data.text
|
||||
|
||||
// If the response is an object, try to extract from nested fields
|
||||
if (typeof responseMessage === "object") {
|
||||
responseMessage =
|
||||
responseMessage.response || responseMessage.message || responseMessage.output || responseMessage.text
|
||||
}
|
||||
|
||||
// If still no message found, stringify the entire response
|
||||
if (!responseMessage) {
|
||||
responseMessage = JSON.stringify(data)
|
||||
}
|
||||
|
||||
return NextResponse.json({ response: responseMessage })
|
||||
} catch {
|
||||
console.log("[v0] Response is not JSON, returning as text")
|
||||
// If not JSON, return the text as the response
|
||||
return NextResponse.json({ response: responseText })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] API route error:", error)
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
971
src/app/globals.css
Normal file
971
src/app/globals.css
Normal file
@ -0,0 +1,971 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--burnt-orange: #e67e50;
|
||||
--terracotta: #d87850;
|
||||
--sage-concrete: #7a8b8b;
|
||||
--charcoal-ink: #2d2d2d;
|
||||
--sandstone: #f3e8d1;
|
||||
--ink-veil: #fdf9f4;
|
||||
|
||||
--background: #f6f2eb;
|
||||
--foreground: var(--charcoal-ink);
|
||||
--card: var(--sage-concrete);
|
||||
--card-foreground: var(--charcoal-ink);
|
||||
--popover: #fdfaf6;
|
||||
--popover-foreground: var(--charcoal-ink);
|
||||
--primary: var(--burnt-orange);
|
||||
--primary-foreground: #1b110a;
|
||||
--secondary: var(--terracotta);
|
||||
--secondary-foreground: #2a140c;
|
||||
--muted: #cbd2d2;
|
||||
--muted-foreground: #394646;
|
||||
--accent: var(--sandstone);
|
||||
--accent-foreground: var(--charcoal-ink);
|
||||
--destructive: #b3473b;
|
||||
--destructive-foreground: #fff5f2;
|
||||
--border: #5f6c6b;
|
||||
--input: #7b8c8c;
|
||||
--ring: var(--terracotta);
|
||||
--chart-1: var(--burnt-orange);
|
||||
--chart-2: var(--terracotta);
|
||||
--chart-3: #f1c6a2;
|
||||
--chart-4: #8c9898;
|
||||
--chart-5: var(--charcoal-ink);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: #f8f3ec;
|
||||
--sidebar-foreground: var(--charcoal-ink);
|
||||
--sidebar-primary: var(--burnt-orange);
|
||||
--sidebar-primary-foreground: #1f140c;
|
||||
--sidebar-accent: #d8c7b1;
|
||||
--sidebar-accent-foreground: var(--charcoal-ink);
|
||||
--sidebar-border: #d4dad8;
|
||||
--sidebar-ring: var(--terracotta);
|
||||
--panel-tint: transparent;
|
||||
--swatch-color: transparent;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #111111;
|
||||
--foreground: #f6ede0;
|
||||
--card: #202425;
|
||||
--card-foreground: #f6ede0;
|
||||
--popover: #161919;
|
||||
--popover-foreground: #f6ede0;
|
||||
--primary: var(--burnt-orange);
|
||||
--primary-foreground: #140b06;
|
||||
--secondary: color-mix(in srgb, var(--terracotta) 85%, #1a130f);
|
||||
--secondary-foreground: #f6ede0;
|
||||
--muted: #1f2626;
|
||||
--muted-foreground: #c4cdcd;
|
||||
--accent: #2c3233;
|
||||
--accent-foreground: #f6ede0;
|
||||
--destructive: #ff8f7f;
|
||||
--destructive-foreground: #2d0400;
|
||||
--border: #3d4444;
|
||||
--input: #394040;
|
||||
--ring: var(--burnt-orange);
|
||||
--chart-1: #ffb285;
|
||||
--chart-2: #f18d62;
|
||||
--chart-3: #fbd4b6;
|
||||
--chart-4: #4a5354;
|
||||
--chart-5: #f6ede0;
|
||||
--sidebar: #141717;
|
||||
--sidebar-foreground: #f6ede0;
|
||||
--sidebar-primary: #ff9a6c;
|
||||
--sidebar-primary-foreground: #1d0903;
|
||||
--sidebar-accent: #2c3233;
|
||||
--sidebar-accent-foreground: #f6ede0;
|
||||
--sidebar-border: #272c2c;
|
||||
--sidebar-ring: var(--terracotta);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* optional: --font-sans, --font-serif, --font-mono if they are applied in the layout.tsx */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-foreground;
|
||||
font-family: var(--font-body), "Space Grotesk", system-ui, sans-serif;
|
||||
background-color: var(--background);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 20%, rgba(230, 126, 80, 0.25), transparent 55%),
|
||||
radial-gradient(circle at 80% 0%, rgba(216, 120, 80, 0.18), transparent 45%),
|
||||
linear-gradient(135deg, rgba(243, 232, 209, 0.8), transparent 60%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.4), rgba(122, 139, 139, 0.28) 65%, rgba(122, 139, 139, 0.45));
|
||||
min-height: 100vh;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
.font-heading {
|
||||
font-family: var(--font-heading), "Playfair Display", "Times New Roman", serif;
|
||||
font-feature-settings: "liga", "clig";
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--charcoal-ink);
|
||||
}
|
||||
|
||||
small,
|
||||
.eyebrow {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--charcoal-ink) 70%, #fff 30%);
|
||||
}
|
||||
|
||||
/* Light mode scrollbar - hidden by default, show on hover */
|
||||
* {
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.3s ease;
|
||||
}
|
||||
|
||||
*:hover {
|
||||
scrollbar-color: var(--burnt-orange) #dcdede;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
*:hover::-webkit-scrollbar-track {
|
||||
background: #dcdede;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--burnt-orange);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #f29b6f;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar - hidden by default, show on hover */
|
||||
.dark * {
|
||||
scrollbar-color: transparent transparent;
|
||||
transition: scrollbar-color 0.3s ease;
|
||||
}
|
||||
|
||||
.dark *:hover {
|
||||
scrollbar-color: var(--burnt-orange) #000000;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.dark *:hover::-webkit-scrollbar-track {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.dark *:hover::-webkit-scrollbar-thumb {
|
||||
background: var(--burnt-orange);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Hidden scrollbar for chat input textarea */
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-slide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.gallery-shell {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.mobile-shell {
|
||||
transition: background 300ms ease;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
body {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.gallery-shell {
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-inline: clamp(0.75rem, 4vw, 1.5rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports (height: 100svh) {
|
||||
.gallery-shell {
|
||||
min-height: 100svh;
|
||||
height: 100svh;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.gallery-shell {
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-shell::after {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mobile-shell {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
border-radius: 1.75rem;
|
||||
background: linear-gradient(180deg, #f6ede0, #f3e8d1 100%);
|
||||
border: 1px solid rgba(243, 232, 209, 0.6);
|
||||
box-shadow: 0 25px 55px rgba(45, 45, 45, 0.15);
|
||||
padding: clamp(1.25rem, 4vw, 1.75rem) clamp(1rem, 4vw, 1.5rem);
|
||||
backdrop-filter: blur(18px);
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.mobile-feed {
|
||||
border-radius: 1.4rem;
|
||||
background: transparent;
|
||||
padding-inline: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-frame {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
border-radius: 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-bubble.user {
|
||||
background: rgba(200, 200, 200, 0.75);
|
||||
border: 1px solid rgba(180, 180, 180, 0.4);
|
||||
color: rgba(45, 45, 45, 0.95);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.message-bubble.assistant {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(45, 45, 45, 0.9);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mobile-hero-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
border-radius: 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mobile-hero-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(45, 45, 45, 0.12);
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.mobile-hero-icon span {
|
||||
display: block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--burnt-orange);
|
||||
box-shadow: 0 0 28px rgba(230, 126, 80, 0.4);
|
||||
}
|
||||
|
||||
.mobile-hero-heading {
|
||||
margin-top: 0.35rem;
|
||||
font-family: var(--font-heading), "Playfair Display", serif;
|
||||
font-size: 1.5rem;
|
||||
color: rgba(45, 45, 45, 0.85);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.mobile-hero-label {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(45, 45, 45, 0.6);
|
||||
}
|
||||
|
||||
.mobile-agent-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-agent-chip {
|
||||
width: 100%;
|
||||
border-radius: 1.1rem;
|
||||
padding: 0.95rem 1.25rem;
|
||||
border: 1px solid rgba(45, 45, 45, 0.1);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
text-align: left;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(45, 45, 45, 0.8);
|
||||
transition: transform 200ms ease, border-color 200ms ease, background 200ms ease;
|
||||
}
|
||||
|
||||
.mobile-agent-chip.is-active {
|
||||
border-color: rgba(45, 45, 45, 0.2);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
color: rgba(45, 45, 45, 0.95);
|
||||
box-shadow: 0 8px 20px rgba(45, 45, 45, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.chat-panel button[title="Start a fresh conversation"] {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(180, 180, 180, 0.4);
|
||||
background: rgba(200, 200, 200, 0.75);
|
||||
color: rgba(45, 45, 45, 0.9);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chat-panel button[title="Start a fresh conversation"]:hover {
|
||||
background: rgba(210, 210, 210, 0.85);
|
||||
border-color: rgba(190, 190, 190, 0.5);
|
||||
}
|
||||
|
||||
.chat-panel .text-white\/60,
|
||||
.chat-panel .text-white\/80,
|
||||
.chat-panel .text-white\/70 {
|
||||
color: rgba(45, 45, 45, 0.6) !important;
|
||||
}
|
||||
|
||||
.chat-panel .scroll-reveal {
|
||||
background: rgba(255, 255, 255, 0.6) !important;
|
||||
border-color: rgba(45, 45, 45, 0.15) !important;
|
||||
color: rgba(45, 45, 45, 0.9) !important;
|
||||
}
|
||||
|
||||
.chat-panel .scroll-reveal:hover {
|
||||
background: rgba(255, 255, 255, 0.75) !important;
|
||||
border-color: rgba(45, 45, 45, 0.25) !important;
|
||||
}
|
||||
|
||||
.chat-panel .text-charcoal {
|
||||
color: rgba(45, 45, 45, 0.9) !important;
|
||||
}
|
||||
|
||||
.chat-panel .text-muted-foreground {
|
||||
color: rgba(45, 45, 45, 0.7) !important;
|
||||
}
|
||||
|
||||
.chat-panel .border-white\/10 {
|
||||
border-color: rgba(45, 45, 45, 0.15) !important;
|
||||
}
|
||||
|
||||
.chat-panel .bg-white\/10 {
|
||||
background: rgba(255, 255, 255, 0.4) !important;
|
||||
}
|
||||
|
||||
.chat-panel .bg-white\/40 {
|
||||
background: rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
.chat-panel .text-white\/70 {
|
||||
color: rgba(45, 45, 45, 0.7) !important;
|
||||
}
|
||||
|
||||
.chat-panel .text-destructive {
|
||||
color: rgba(200, 50, 50, 0.9) !important;
|
||||
}
|
||||
|
||||
.composer-affix {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 0.65rem);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.composer-panel {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dark .composer-panel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mobile-composer {
|
||||
border-radius: 1.5rem;
|
||||
border: none;
|
||||
background: rgba(60, 60, 60, 0.95);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.mobile-composer .text-foreground,
|
||||
.mobile-composer .text-muted-foreground,
|
||||
.mobile-composer input,
|
||||
.mobile-composer textarea,
|
||||
.mobile-composer button {
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
}
|
||||
|
||||
.mobile-composer textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
|
||||
.mobile-composer .text-white,
|
||||
.mobile-composer .text-white\/70,
|
||||
.mobile-composer .text-white\/80 {
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
}
|
||||
|
||||
.mobile-composer .composer-send-button {
|
||||
background: var(--burnt-orange) !important;
|
||||
border-color: rgba(230, 126, 80, 0.8) !important;
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
box-shadow: 0 4px 12px rgba(230, 126, 80, 0.4) !important;
|
||||
}
|
||||
|
||||
.mobile-composer .composer-send-button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--burnt-orange) 90%, white) !important;
|
||||
border-color: var(--burnt-orange) !important;
|
||||
}
|
||||
|
||||
.mobile-composer .composer-action-button {
|
||||
background: rgba(180, 180, 180, 0.4) !important;
|
||||
border-color: rgba(160, 160, 160, 0.5) !important;
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
}
|
||||
|
||||
.mobile-composer .composer-action-button:hover {
|
||||
background: rgba(200, 200, 200, 0.5) !important;
|
||||
border-color: rgba(180, 180, 180, 0.6) !important;
|
||||
}
|
||||
|
||||
.mobile-composer .text-white\/90 {
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
}
|
||||
|
||||
.mobile-composer [role="menuitem"]:hover {
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
.mobile-composer [role="menuitem"]:hover * {
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
}
|
||||
|
||||
.dark .mobile-composer {
|
||||
border-color: rgba(80, 80, 80, 0.5);
|
||||
background: rgba(60, 60, 60, 0.95);
|
||||
box-shadow: 0 20px 35px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.composer-form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.composer-form .composer-panel {
|
||||
width: min(100%, 640px);
|
||||
padding: 1.25rem 1.35rem 1.4rem;
|
||||
}
|
||||
|
||||
.composer-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.composer-image-thumb {
|
||||
position: relative;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
border-radius: 0.9rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.composer-image-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.composer-image-remove {
|
||||
position: absolute;
|
||||
top: -0.4rem;
|
||||
right: -0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.composer-image-remove:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.composer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.composer-dropdown-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(45, 45, 45, 0.15);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background 180ms ease, border-color 180ms ease;
|
||||
color: var(--charcoal-ink);
|
||||
}
|
||||
|
||||
.composer-dropdown-trigger:hover {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.composer-action-button {
|
||||
width: 2.75rem !important;
|
||||
height: 2.75rem !important;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px solid rgba(45, 45, 45, 0.12);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: var(--charcoal-ink);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
transition: background 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.composer-action-button:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.composer-send-button {
|
||||
width: 3rem !important;
|
||||
height: 3rem !important;
|
||||
border-radius: 1rem;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, var(--burnt-orange), color-mix(in srgb, var(--burnt-orange) 70%, #ffffff));
|
||||
color: #1f0d06;
|
||||
box-shadow: 0 12px 25px rgba(230, 126, 80, 0.28);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.composer-send-button:disabled {
|
||||
opacity: 0.6;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.composer-send-button:not(:disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 25px rgba(230, 126, 80, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 15% 5%, rgba(230, 126, 80, 0.18), transparent 45%),
|
||||
radial-gradient(circle at 85% 20%, rgba(216, 120, 80, 0.17), transparent 50%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0));
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.palette-shell {
|
||||
position: relative;
|
||||
padding: 2.5rem;
|
||||
border-radius: 1.5rem;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0.32)),
|
||||
var(--card);
|
||||
border: 1px solid color-mix(in srgb, var(--charcoal-ink) 20%, transparent);
|
||||
box-shadow: 0 25px 45px rgba(45, 45, 45, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.palette-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 20% 0%, var(--panel-tint, transparent), transparent 55%);
|
||||
opacity: 0.85;
|
||||
transition: background 300ms ease, opacity 300ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.swatch-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid color-mix(in srgb, var(--charcoal-ink) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--sandstone) 40%, white 60%);
|
||||
backdrop-filter: blur(12px);
|
||||
transition: border-color 220ms ease, transform 220ms ease;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.swatch-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.35), transparent 70%);
|
||||
opacity: 0.4;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.swatch-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--swatch-color, var(--burnt-orange)) 18%, transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.swatch-card:hover,
|
||||
.swatch-card:focus-visible {
|
||||
transform: translateY(-4px);
|
||||
border-color: color-mix(in srgb, var(--swatch-color, var(--burnt-orange)) 45%, transparent);
|
||||
}
|
||||
|
||||
.swatch-card:hover::after,
|
||||
.swatch-card:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.palette-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.palette-chip {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.palette-chip:hover,
|
||||
.palette-chip:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.message-frame {
|
||||
animation: fade-slide 260ms ease-out both;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.message-avatar.assistant {
|
||||
background: linear-gradient(135deg, rgba(230, 126, 80, 0.9), rgba(45, 45, 45, 0.9));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-avatar.user {
|
||||
background: linear-gradient(135deg, rgba(216, 120, 80, 0.9), rgba(255, 205, 166, 0.85));
|
||||
color: var(--charcoal-ink);
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
border-radius: 1.5rem;
|
||||
padding: 1.1rem 1.4rem;
|
||||
border: 1px solid color-mix(in srgb, var(--charcoal-ink) 12%, transparent);
|
||||
background: rgba(255, 255, 255, 0.56);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
color: var(--charcoal-ink);
|
||||
}
|
||||
|
||||
.message-bubble.user {
|
||||
padding: 0.85rem 1.1rem;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 2px 6px rgba(45, 45, 45, 0.08), inset 0 0 0 1px rgba(255, 255, 255, 0.25), inset 0 8px 14px rgba(255, 255, 255, 0.2);
|
||||
color: var(--charcoal-ink);
|
||||
}
|
||||
|
||||
.message-bubble.assistant {
|
||||
background: color-mix(in srgb, var(--sage-concrete) 28%, #ffffff);
|
||||
color: var(--charcoal-ink);
|
||||
}
|
||||
|
||||
.dark .message-bubble {
|
||||
background: rgba(12, 12, 12, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.dark .message-bubble.user {
|
||||
padding: 0.85rem 1.1rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(255, 255, 255, 0.04), inset 0 8px 14px rgba(255, 255, 255, 0.035);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.dark .message-bubble.assistant {
|
||||
background: color-mix(in srgb, var(--sage-concrete) 45%, rgba(8, 8, 8, 0.7));
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@keyframes agent-picker-breathe {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
box-shadow: 0 8px 18px rgba(230, 126, 80, 0.18);
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
box-shadow: 0 16px 28px rgba(216, 120, 80, 0.35);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
box-shadow: 0 8px 18px rgba(230, 126, 80, 0.18);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-picker-prompt {
|
||||
border-color: rgba(255, 255, 255, 0.65) !important;
|
||||
color: #fff !important;
|
||||
background-image: linear-gradient(130deg, rgba(230, 126, 80, 0.85), rgba(122, 139, 139, 0.75));
|
||||
background-size: 180% 180%;
|
||||
animation: agent-picker-breathe 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.manuscript-panel {
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--charcoal-ink) 15%, transparent);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 30px 70px rgba(45, 45, 45, 0.12), inset 0 0 0 1px rgba(255, 255, 255, 0.35), inset 0 12px 25px rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.dark .manuscript-panel {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(9, 9, 9, 0.4);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.35), inset 0 0 0 1px rgba(255, 255, 255, 0.06), inset 0 10px 20px rgba(255, 255, 255, 0.04);
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-charcoal {
|
||||
color: var(--charcoal-ink);
|
||||
}
|
||||
|
||||
.bg-burnt {
|
||||
background-color: var(--burnt-orange);
|
||||
}
|
||||
|
||||
.text-burnt {
|
||||
color: var(--burnt-orange);
|
||||
}
|
||||
|
||||
.bg-terracotta {
|
||||
background-color: var(--terracotta);
|
||||
}
|
||||
|
||||
.bg-sage {
|
||||
background-color: var(--sage-concrete);
|
||||
}
|
||||
|
||||
.border-burnt {
|
||||
border-color: var(--burnt-orange);
|
||||
}
|
||||
|
||||
.scroll-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
transition: transform 260ms ease-out, opacity 260ms ease-out;
|
||||
}
|
||||
|
||||
.scroll-reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.pt-safe {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.bottom-safe {
|
||||
bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scroll-reveal,
|
||||
.message-frame {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(200%);
|
||||
}
|
||||
}
|
||||
.markdown-glass pre {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
color: var(--charcoal-ink);
|
||||
}
|
||||
|
||||
.dark .markdown-glass pre {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.markdown-glass .hljs {
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
}
|
||||
60
src/app/layout.tsx
Normal file
60
src/app/layout.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { Playfair_Display, Space_Grotesk } from "next/font/google"
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { Suspense } from "react"
|
||||
import "./globals.css"
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-heading",
|
||||
display: "swap",
|
||||
})
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-body",
|
||||
display: "swap",
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Inspiration Repo Agent - AI-Powered Creative Assistant",
|
||||
description: "Get inspired and generate creative content with our AI-powered assistant. Perfect for brainstorming, content creation, and creative problem-solving.",
|
||||
keywords: "AI, creative assistant, inspiration, brainstorming, content creation",
|
||||
authors: [{ name: "Inspiration Repo Team" }],
|
||||
creator: "Inspiration Repo",
|
||||
publisher: "Inspiration Repo",
|
||||
robots: "index, follow",
|
||||
openGraph: {
|
||||
title: "Inspiration Repo Agent - AI-Powered Creative Assistant",
|
||||
description: "Get inspired and generate creative content with our AI-powered assistant.",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
siteName: "Inspiration Repo Agent",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Inspiration Repo Agent - AI-Powered Creative Assistant",
|
||||
description: "Get inspired and generate creative content with our AI-powered assistant.",
|
||||
},
|
||||
viewport: "width=device-width, initial-scale=1",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="canonical" href="https://inspiration-repo-agent.com" />
|
||||
</head>
|
||||
<body className={`${spaceGrotesk.variable} ${playfair.variable} ${GeistMono.variable} font-sans antialiased`}>
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
133
src/app/page.tsx
Normal file
133
src/app/page.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { ChatInterface } from "@/components/chat-interface"
|
||||
import type { Agent } from "@/lib/types"
|
||||
|
||||
export default function Home() {
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const [agentsError, setAgentsError] = useState<string | null>(null)
|
||||
const [isAgentsLoading, setIsAgentsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Try to load previously selected agent from localStorage
|
||||
const savedAgent = localStorage.getItem("selected-agent")
|
||||
|
||||
if (savedAgent) {
|
||||
try {
|
||||
const agent = JSON.parse(savedAgent)
|
||||
setSelectedAgent(agent)
|
||||
} catch (err) {
|
||||
console.error("[home] Failed to load saved agent:", err)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
setIsAgentsLoading(true)
|
||||
setAgentsError(null)
|
||||
const response = await fetch("/api/agents")
|
||||
const data = (await response.json()) as { agents?: unknown; error?: string }
|
||||
|
||||
if (!response.ok || !data.agents) {
|
||||
throw new Error(data.error || "Failed to load agents")
|
||||
}
|
||||
|
||||
setAgents(data.agents as typeof agents)
|
||||
} catch (err) {
|
||||
setAgents([])
|
||||
setAgentsError(err instanceof Error ? err.message : "Failed to load agents")
|
||||
} finally {
|
||||
setIsAgentsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAgents()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgent || agents.length === 0) return
|
||||
const match = agents.find((agent) => agent.id === selectedAgent.id)
|
||||
if (!match) {
|
||||
setSelectedAgent(null)
|
||||
localStorage.removeItem("selected-agent")
|
||||
localStorage.removeItem("selected-agent-id")
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
match.name !== selectedAgent.name ||
|
||||
match.description !== selectedAgent.description
|
||||
) {
|
||||
setSelectedAgent(match)
|
||||
localStorage.setItem("selected-agent-id", match.id)
|
||||
localStorage.setItem("selected-agent", JSON.stringify(match))
|
||||
}
|
||||
}, [agents, selectedAgent])
|
||||
|
||||
const handleAgentSelected = (agent: Agent) => {
|
||||
setSelectedAgent(agent)
|
||||
localStorage.setItem("selected-agent-id", agent.id)
|
||||
localStorage.setItem("selected-agent", JSON.stringify(agent))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return null // Avoid hydration mismatch
|
||||
}
|
||||
|
||||
// If no agent is selected but we have agents loaded, select the first one
|
||||
// This ensures we always show the ChatInterface with its beautiful selection UI
|
||||
const activeAgent = selectedAgent || (agents.length > 0 ? agents[0] : null)
|
||||
|
||||
if (!activeAgent) {
|
||||
return (
|
||||
<motion.div
|
||||
className="gallery-shell h-screen"
|
||||
initial={{ opacity: 0, y: 25 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.9, ease: "easeOut" }}
|
||||
>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
{agentsError ? (
|
||||
<p className="text-xs text-destructive">{agentsError}</p>
|
||||
) : (
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Loading agents...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="gallery-shell mobile-shell h-screen"
|
||||
initial={{ opacity: 0, y: 25 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.9, ease: "easeOut" }}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<main className="flex-1 overflow-hidden px-3 py-4 sm:px-6 sm:py-6">
|
||||
<div className="mx-auto flex h-full max-w-5xl justify-center">
|
||||
<div className="h-full w-full">
|
||||
<ChatInterface
|
||||
agent={activeAgent}
|
||||
agents={agents}
|
||||
onAgentSelected={handleAgentSelected}
|
||||
isAgentsLoading={isAgentsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
69
src/components/DIFF_TOOL_USAGE.md
Normal file
69
src/components/DIFF_TOOL_USAGE.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Diff Tool Usage Guide
|
||||
|
||||
The diff tool allows the AI model to display code differences in a beautiful, interactive format within chat messages.
|
||||
|
||||
## How to Use
|
||||
|
||||
The model can include diff displays in its responses by using the following markdown syntax:
|
||||
|
||||
```markdown
|
||||
```diff-tool
|
||||
{
|
||||
"oldCode": "function hello() {\n console.log('Hello, World!');\n}",
|
||||
"newCode": "function hello(name = 'World') {\n console.log(\`Hello, \${name}!\`);\n}",
|
||||
"title": "Updated hello function",
|
||||
"language": "javascript"
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
- **oldCode** (required): The original code as a string with `\n` for line breaks
|
||||
- **newCode** (required): The modified code as a string with `\n` for line breaks
|
||||
- **title** (optional): A descriptive title for the diff (defaults to "Code Changes")
|
||||
- **language** (optional): The programming language for syntax highlighting (defaults to "text")
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive**: Click to expand/collapse the diff
|
||||
- **Copy to Clipboard**: Copy button to copy the full diff
|
||||
- **Line Numbers**: Shows line numbers for both old and new code
|
||||
- **Color Coding**:
|
||||
- Green for additions (+)
|
||||
- Red for deletions (-)
|
||||
- Neutral for unchanged lines
|
||||
- **Syntax Highlighting**: Supports various programming languages
|
||||
- **Responsive**: Works well on different screen sizes
|
||||
|
||||
## Example Usage Scenarios
|
||||
|
||||
1. **Code Refactoring**: Show before/after code improvements
|
||||
2. **Bug Fixes**: Highlight what was changed to fix an issue
|
||||
3. **Feature Additions**: Display new functionality being added
|
||||
4. **Configuration Changes**: Show config file modifications
|
||||
5. **API Changes**: Demonstrate API signature changes
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep it focused**: Show only relevant changes, not entire files
|
||||
2. **Add context**: Use descriptive titles and surrounding text
|
||||
3. **Escape properly**: Make sure to escape quotes and newlines in JSON
|
||||
4. **Reasonable size**: Avoid extremely large diffs that are hard to read
|
||||
|
||||
## Example Response Format
|
||||
|
||||
```markdown
|
||||
Here are the changes I made to fix the bug:
|
||||
|
||||
```diff-tool
|
||||
{
|
||||
"oldCode": "if (user) {\n return user.name;\n}",
|
||||
"newCode": "if (user && user.name) {\n return user.name;\n} else {\n return 'Anonymous';\n}",
|
||||
"title": "Fixed null reference bug",
|
||||
"language": "javascript"
|
||||
}
|
||||
```
|
||||
|
||||
The fix adds proper null checking and provides a fallback value.
|
||||
```
|
||||
166
src/components/agent-selector.tsx
Normal file
166
src/components/agent-selector.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from "react"
|
||||
import { Loader2, Sparkles } from "lucide-react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import type { Agent } from "@/lib/types"
|
||||
|
||||
interface AgentSelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onAgentSelected: (agent: Agent) => void
|
||||
}
|
||||
|
||||
export function AgentSelector({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAgentSelected,
|
||||
}: AgentSelectorProps) {
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch("/api/agents")
|
||||
const data = (await response.json()) as { agents?: unknown; error?: string }
|
||||
|
||||
if (!response.ok || !data.agents) {
|
||||
throw new Error(data.error || "Failed to load agents")
|
||||
}
|
||||
|
||||
const agentsList = data.agents as typeof agents
|
||||
setAgents(agentsList)
|
||||
|
||||
if (agentsList.length === 0) {
|
||||
setError("No agents configured. Please add agents via environment variables.")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load agents",
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAgents()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const cards = document.querySelectorAll<HTMLElement>("[data-reveal='agent-card']")
|
||||
if (!cards.length) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("is-visible")
|
||||
observer.unobserve(entry.target)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold: 0.25 }
|
||||
)
|
||||
|
||||
cards.forEach((card) => observer.observe(card))
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [agents, open])
|
||||
|
||||
const handleSelectAgent = (agent: Agent) => {
|
||||
// Store selected agent in localStorage
|
||||
localStorage.setItem("selected-agent-id", agent.id)
|
||||
localStorage.setItem("selected-agent", JSON.stringify(agent))
|
||||
// Close dialog and notify parent component
|
||||
onOpenChange(false)
|
||||
onAgentSelected(agent)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl border-none bg-transparent p-0 shadow-none">
|
||||
<div
|
||||
className="palette-shell"
|
||||
style={
|
||||
{
|
||||
"--panel-tint": "rgba(230, 126, 80, 0.25)",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-border/40 bg-white/40 text-burnt shadow-md">
|
||||
<Sparkles className="h-6 w-6 text-burnt" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="text-center text-3xl font-heading">
|
||||
Select Your Correspondent
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center text-base leading-relaxed text-muted-foreground">
|
||||
Each agent keeps a different archive. Take a breath, read their placard, then choose the voice you trust.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-burnt" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">Cataloguing configured agents…</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/15 p-4 text-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-6 md:grid-cols-2">
|
||||
{agents.map((agent, index) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => handleSelectAgent(agent)}
|
||||
className="swatch-card scroll-reveal group p-5 text-left focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-reveal="agent-card"
|
||||
style={
|
||||
{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
"--swatch-color": index % 2 === 0 ? "var(--burnt-orange)" : "var(--terracotta)",
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className="eyebrow text-[0.65rem] text-muted-foreground">Agent</span>
|
||||
<h3 className="mt-2 font-heading text-xl text-charcoal">{agent.name}</h3>
|
||||
</div>
|
||||
<div className="message-avatar assistant !h-9 !w-9 rounded-full text-[0.6rem] uppercase tracking-[0.25em]">
|
||||
{agent.name.slice(0, 2)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
{agent.description}
|
||||
</p>
|
||||
<p className="mt-4 text-[0.65rem] uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Press enter to begin
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
612
src/components/chat-interface.tsx
Normal file
612
src/components/chat-interface.tsx
Normal file
@ -0,0 +1,612 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Send, Loader2, SquarePen, Paperclip, Copy, X, ChevronDown } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { MarkdownRenderer } from "./markdown-renderer"
|
||||
import type { Message, Agent } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
agent: Agent
|
||||
agents: Agent[]
|
||||
onAgentSelected: (agent: Agent) => void
|
||||
isAgentsLoading: boolean
|
||||
}
|
||||
|
||||
export function ChatInterface({
|
||||
agent,
|
||||
agents,
|
||||
onAgentSelected,
|
||||
isAgentsLoading,
|
||||
}: ChatInterfaceProps) {
|
||||
const heroGreeting = "hello, user"
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string>("")
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([])
|
||||
const [composerAgentId, setComposerAgentId] = useState<string | null>(null)
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>(32)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Use agent-specific session ID: chat-session-{agentId}
|
||||
const sessionKey = `chat-session-${agent.id}`
|
||||
let existingSessionId = localStorage.getItem(sessionKey)
|
||||
|
||||
if (!existingSessionId) {
|
||||
// Generate new sessionID using timestamp and random string
|
||||
existingSessionId = `session-${agent.id}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
|
||||
localStorage.setItem(sessionKey, existingSessionId)
|
||||
}
|
||||
|
||||
setSessionId(existingSessionId)
|
||||
|
||||
// Load existing messages for this agent
|
||||
const messagesKey = `chat-messages-${agent.id}`
|
||||
const savedMessages = localStorage.getItem(messagesKey)
|
||||
if (savedMessages) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedMessages)
|
||||
// Ensure timestamps are Date objects
|
||||
const messages = parsed.map((msg: any) => ({
|
||||
...msg,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
}))
|
||||
setMessages(messages)
|
||||
} catch (err) {
|
||||
console.error("[chat] Failed to load saved messages:", err)
|
||||
}
|
||||
}
|
||||
}, [agent.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, isLoading])
|
||||
|
||||
// Update textarea height based on content
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
const element = inputRef.current
|
||||
element.style.height = "auto"
|
||||
const newHeight = Math.min(element.scrollHeight, 224)
|
||||
setTextareaHeight(newHeight)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
// Save messages to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
const messagesKey = `chat-messages-${agent.id}`
|
||||
localStorage.setItem(messagesKey, JSON.stringify(messages))
|
||||
}, [messages, agent.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = "auto"
|
||||
inputRef.current.style.height = Math.min(inputRef.current.scrollHeight, 160) + "px"
|
||||
}
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0 && composerAgentId !== agent.id) {
|
||||
setComposerAgentId(agent.id)
|
||||
}
|
||||
}, [messages.length, agent.id])
|
||||
|
||||
// Handle image file selection
|
||||
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.currentTarget.files
|
||||
if (!files) return
|
||||
|
||||
const newImages: string[] = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
// Only accept image files
|
||||
if (!file.type.startsWith("image/")) {
|
||||
console.warn("[chat] Skipping non-image file:", file.name)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file)
|
||||
newImages.push(base64)
|
||||
} catch (err) {
|
||||
console.error("[chat] Failed to convert image:", err)
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedImages((prev) => [...prev, ...newImages])
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Convert file to base64 string
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
})
|
||||
}
|
||||
|
||||
// Remove selected image
|
||||
const removeImage = (index: number) => {
|
||||
setSelectedImages((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const sendMessage = async (e?: React.FormEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
if (!input.trim() || isLoading) return
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: "user",
|
||||
content: input.trim(),
|
||||
timestamp: new Date(),
|
||||
images: selectedImages.length > 0 ? selectedImages : undefined,
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInput("")
|
||||
setSelectedImages([])
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: userMessage.content,
|
||||
timestamp: userMessage.timestamp.toISOString(),
|
||||
sessionId: sessionId,
|
||||
agentId: agent.id,
|
||||
images: selectedImages.length > 0 ? selectedImages : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = (await response.json()) as {
|
||||
error?: string
|
||||
hint?: string
|
||||
response?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
content: data.error || "Failed to communicate with the webhook.",
|
||||
timestamp: new Date(),
|
||||
isError: true,
|
||||
hint: data.hint,
|
||||
}
|
||||
setMessages((prev) => [...prev, errorMessage])
|
||||
} else {
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
content: data.response || data.message || JSON.stringify(data),
|
||||
timestamp: new Date(),
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMessage])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Error sending message:", error)
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
content: "Sorry, I encountered an error processing your message. Please try again.",
|
||||
timestamp: new Date(),
|
||||
isError: true,
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, errorMessage])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const startNewChat = () => {
|
||||
// Clear all messages
|
||||
setMessages([])
|
||||
// Generate new sessionID for this agent
|
||||
const newSessionId = `session-${agent.id}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
|
||||
setSessionId(newSessionId)
|
||||
const sessionKey = `chat-session-${agent.id}`
|
||||
localStorage.setItem(sessionKey, newSessionId)
|
||||
// Clear input and images
|
||||
setInput("")
|
||||
setSelectedImages([])
|
||||
setComposerAgentId(null)
|
||||
// Focus input
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyMessage = async (id: string, content: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopiedMessageId(id)
|
||||
setTimeout(() => {
|
||||
setCopiedMessageId((current) => (current === id ? null : current))
|
||||
}, 1200)
|
||||
} catch (error) {
|
||||
console.error("[chat] Failed to copy message", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComposerAgentSelect = (entry: Agent) => {
|
||||
setComposerAgentId(entry.id)
|
||||
onAgentSelected(entry)
|
||||
}
|
||||
|
||||
const canSwitchAgents = agents.length > 0 && !isAgentsLoading
|
||||
const hasMessages = messages.length > 0
|
||||
const dropdownSelectedId = composerAgentId ?? (hasMessages ? agent.id : null)
|
||||
const dropdownAgentEntry = dropdownSelectedId
|
||||
? agents.find((entry) => entry.id === dropdownSelectedId) ?? agent
|
||||
: null
|
||||
const dropdownLabel = dropdownAgentEntry ? dropdownAgentEntry.name : "Select a correspondent"
|
||||
const highlightAgentDropdown = !dropdownSelectedId && !hasMessages
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 35 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.85, ease: "easeOut" }}
|
||||
className="chat-panel relative flex h-full w-full flex-col overflow-hidden rounded-[2.5rem] bg-gradient-to-b from-white/0 via-white/15 to-white/45 px-4 py-8 shadow-[0_15px_35px_rgba(45,45,45,0.1),0_0_0_1px_rgba(255,255,255,0.25)_inset,0_15px_25px_rgba(255,255,255,0.12)_inset] backdrop-blur-xl dark:bg-gradient-to-b dark:from-transparent dark:via-white/5 dark:to-white/20 dark:shadow-[0_12px_25px_rgba(0,0,0,0.35),0_0_0_1px_rgba(255,255,255,0.06)_inset,0_12px_20px_rgba(255,255,255,0.04)_inset] sm:px-8 sm:py-10"
|
||||
>
|
||||
<div className="mb-4 flex justify-end">
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
onClick={startNewChat}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group h-11 w-11 rounded-2xl border border-white/25 bg-white/15 text-white shadow-[0_2px_6px_rgba(0,0,0,0.12)] backdrop-blur transition hover:bg-white/25"
|
||||
title="Start a fresh conversation"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={cn(
|
||||
"mobile-feed px-1 pt-4 sm:px-0",
|
||||
hasMessages ? "flex-1 overflow-y-auto pb-10" : "pb-6"
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-[52rem] space-y-10 px-2 sm:px-4">
|
||||
<AnimatePresence mode="wait">
|
||||
{hasMessages ? (
|
||||
<motion.div
|
||||
key="conversation"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="space-y-10"
|
||||
>
|
||||
{messages.map((message) => {
|
||||
const isUser = message.role === "user"
|
||||
return (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className={cn("message-frame flex flex-col gap-3", isUser ? "items-end text-right" : "")}
|
||||
>
|
||||
{isUser ? (
|
||||
<div className="message-bubble user">
|
||||
<MarkdownRenderer content={message.content} tone="bubble" />
|
||||
</div>
|
||||
) : message.isError ? (
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed">{message.content}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative text-sm text-charcoal dark:text-foreground">
|
||||
<MarkdownRenderer content={message.content} />
|
||||
<div className="mt-4 flex items-center justify-end gap-3 border-t border-white/10 pt-3 opacity-50 transition hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyMessage(message.id, message.content)}
|
||||
className={`inline-flex h-7 w-7 items-center justify-center rounded border border-white/20 bg-white/8 text-white/70 shadow-[0_2px_5px_rgba(0,0,0,0.07)] backdrop-blur transition-transform duration-150 hover:bg-white/18 ${
|
||||
copiedMessageId === message.id ? "scale-90 bg-white/20 text-white" : ""
|
||||
}`}
|
||||
aria-label="Copy response"
|
||||
>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.hint && (
|
||||
<div className="rounded-lg border border-accent/60 bg-accent/40 px-3 py-2 text-xs text-charcoal">
|
||||
{message.hint}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
{isLoading && (
|
||||
<div className="message-frame flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="uppercase tracking-[0.25em] text-white/70">Correspondent</span>
|
||||
<span className="relative flex h-3 w-24 overflow-hidden rounded-full bg-white/10">
|
||||
<span className="absolute inset-y-0 w-1/2 animate-[shimmer_1.4s_infinite] bg-white/40"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty-state"
|
||||
initial={{ opacity: 0, y: 60 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.65, ease: "easeOut" }}
|
||||
className="flex min-h-[40vh] flex-col items-center justify-center gap-6 text-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h1 className="font-heading text-[3.5rem] lowercase tracking-tight text-white/85 drop-shadow-[0_12px_30px_rgba(0,0,0,0.4)] sm:text-[7rem]">
|
||||
{heroGreeting.split("").map((char, index) => (
|
||||
<motion.span
|
||||
key={`${char}-${index}`}
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 + index * 0.05, duration: 0.35, ease: "easeOut" }}
|
||||
className="inline-block"
|
||||
>
|
||||
{char === " " ? "\u00A0" : char}
|
||||
</motion.span>
|
||||
))}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-3xl space-y-4">
|
||||
<p className="text-sm uppercase tracking-[0.35em] text-white/80">
|
||||
Select a correspondent to begin
|
||||
</p>
|
||||
{agents.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{agents.map((entry) => {
|
||||
const isActive = dropdownSelectedId === entry.id
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
onClick={() => handleComposerAgentSelect(entry)}
|
||||
className={cn(
|
||||
"rounded-full border px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] transition",
|
||||
isActive
|
||||
? "border-white/25 bg-white/25 text-white shadow-[0_5px_20px_rgba(0,0,0,0.35)]"
|
||||
: "border-white/10 bg-white/5 text-white/70 hover:border-white/30 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-white/60">No agents available yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2">
|
||||
{[
|
||||
"Help me brainstorm ideas for a new mobile app",
|
||||
"Generate creative writing prompts for a fantasy novel",
|
||||
"Suggest innovative marketing strategies for a startup",
|
||||
"Create a list of unique product names for a tech company",
|
||||
].map((prompt, index) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onClick={() => setInput(prompt)}
|
||||
className="scroll-reveal rounded-2xl border border-border/30 bg-white/80 p-4 text-left text-sm text-charcoal shadow-sm transition hover:border-ring/60 hover:bg-white"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
layout
|
||||
className="composer-affix relative mt-auto pt-6 pb-4 transition-all duration-500"
|
||||
animate={{ y: hasMessages ? 0 : -140, scale: hasMessages ? 1 : 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 160, damping: 24 }}
|
||||
>
|
||||
<form onSubmit={sendMessage} className="composer-form relative flex justify-center">
|
||||
{/* Image preview section */}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="composer-images mb-3 flex flex-wrap gap-3 px-3 pt-2">
|
||||
{selectedImages.map((image, index) => (
|
||||
<div key={index} className="composer-image-thumb relative">
|
||||
<img
|
||||
src={image}
|
||||
alt={`Selected ${index}`}
|
||||
className="h-16 w-16 rounded-lg border border-border/40 object-cover shadow-md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(index)}
|
||||
className="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full text-white shadow-md hover:opacity-80"
|
||||
style={{ backgroundColor: "var(--charcoal-ink)" }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"manuscript-panel composer-panel w-[85%] max-w-2xl p-5",
|
||||
"max-sm:mobile-composer max-sm:w-full max-sm:p-4"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<motion.textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Write a note, share a hunch, or paste a brief…"
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="hide-scrollbar w-full resize-none border-0 bg-transparent text-lg text-foreground placeholder:text-muted-foreground/80 focus:outline-none"
|
||||
animate={{
|
||||
height: textareaHeight
|
||||
}}
|
||||
transition={{
|
||||
height: {
|
||||
type: "spring",
|
||||
stiffness: 600,
|
||||
damping: 35,
|
||||
mass: 0.5,
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
minHeight: "32px",
|
||||
maxHeight: "224px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"composer-dropdown-trigger inline-flex max-w-[12rem] items-center gap-2 rounded-2xl border border-white/20 bg-white/30 px-3 py-2 text-left text-[0.55rem] uppercase tracking-[0.3em] shadow-[0_10px_25px_rgba(0,0,0,0.2)] backdrop-blur transition hover:bg-white/40 hover:text-white disabled:opacity-50",
|
||||
highlightAgentDropdown ? "agent-picker-prompt text-white" : "text-white"
|
||||
)}
|
||||
disabled={!canSwitchAgents}
|
||||
>
|
||||
<span className="truncate text-xs font-heading normal-case tracking-normal text-white">
|
||||
{dropdownLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-white/70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[12rem] rounded-2xl border border-white/15 bg-white/10 p-2 text-white shadow-[0_20px_40px_rgba(0,0,0,0.3)] backdrop-blur"
|
||||
>
|
||||
{isAgentsLoading ? (
|
||||
<DropdownMenuItem disabled className="text-white/50">
|
||||
Gathering correspondents…
|
||||
</DropdownMenuItem>
|
||||
) : agents.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-white/50">
|
||||
No agents configured
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
agents.map((entry) => {
|
||||
const isActive = dropdownSelectedId === entry.id
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.id}
|
||||
onClick={() => handleComposerAgentSelect(entry)}
|
||||
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs transition ${
|
||||
isActive
|
||||
? "bg-white/15 text-white"
|
||||
: "text-white/90 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className="font-heading text-sm">{entry.name}</span>
|
||||
{isActive && <span className="text-[0.55rem] uppercase tracking-[0.3em]">Active</span>}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 text-xs uppercase tracking-[0.25em] text-muted-foreground">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isLoading}
|
||||
size="icon"
|
||||
className="composer-send-button group h-12 w-12 flex-shrink-0 rounded-2xl border border-white/20 bg-white/30 text-white shadow-[0_10px_25px_rgba(0,0,0,0.2)] backdrop-blur transition hover:bg-white/40 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="composer-action-button h-11 w-11 rounded-2xl border border-white/20 bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white"
|
||||
title="Attach image"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
222
src/components/diff-display.tsx
Normal file
222
src/components/diff-display.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, ChevronRight, Copy, Check } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface DiffDisplayProps {
|
||||
oldText: string
|
||||
newText: string
|
||||
title?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
type: 'added' | 'removed' | 'unchanged'
|
||||
content: string
|
||||
oldLineNumber?: number
|
||||
newLineNumber?: number
|
||||
}
|
||||
|
||||
export function DiffDisplay({ oldText, newText, title = "Code Diff", language = "text" }: DiffDisplayProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Simple diff algorithm - split into lines and compare
|
||||
const generateDiff = (): DiffLine[] => {
|
||||
const oldLines = oldText.split('\n')
|
||||
const newLines = newText.split('\n')
|
||||
const diff: DiffLine[] = []
|
||||
|
||||
let oldIndex = 0
|
||||
let newIndex = 0
|
||||
let oldLineNum = 1
|
||||
let newLineNum = 1
|
||||
|
||||
while (oldIndex < oldLines.length || newIndex < newLines.length) {
|
||||
const oldLine = oldLines[oldIndex]
|
||||
const newLine = newLines[newIndex]
|
||||
|
||||
if (oldIndex >= oldLines.length) {
|
||||
// Only new lines left
|
||||
diff.push({
|
||||
type: 'added',
|
||||
content: newLine,
|
||||
newLineNumber: newLineNum
|
||||
})
|
||||
newIndex++
|
||||
newLineNum++
|
||||
} else if (newIndex >= newLines.length) {
|
||||
// Only old lines left
|
||||
diff.push({
|
||||
type: 'removed',
|
||||
content: oldLine,
|
||||
oldLineNumber: oldLineNum
|
||||
})
|
||||
oldIndex++
|
||||
oldLineNum++
|
||||
} else if (oldLine === newLine) {
|
||||
// Lines are the same
|
||||
diff.push({
|
||||
type: 'unchanged',
|
||||
content: oldLine,
|
||||
oldLineNumber: oldLineNum,
|
||||
newLineNumber: newLineNum
|
||||
})
|
||||
oldIndex++
|
||||
newIndex++
|
||||
oldLineNum++
|
||||
newLineNum++
|
||||
} else {
|
||||
// Lines are different - check if it's an addition or removal
|
||||
const oldLineNext = oldLines[oldIndex + 1]
|
||||
const newLineNext = newLines[newIndex + 1]
|
||||
|
||||
if (oldLineNext === newLine) {
|
||||
// Old line was removed
|
||||
diff.push({
|
||||
type: 'removed',
|
||||
content: oldLine,
|
||||
oldLineNumber: oldLineNum
|
||||
})
|
||||
oldIndex++
|
||||
oldLineNum++
|
||||
} else if (newLineNext === oldLine) {
|
||||
// New line was added
|
||||
diff.push({
|
||||
type: 'added',
|
||||
content: newLine,
|
||||
newLineNumber: newLineNum
|
||||
})
|
||||
newIndex++
|
||||
newLineNum++
|
||||
} else {
|
||||
// Both lines changed
|
||||
diff.push({
|
||||
type: 'removed',
|
||||
content: oldLine,
|
||||
oldLineNumber: oldLineNum
|
||||
})
|
||||
diff.push({
|
||||
type: 'added',
|
||||
content: newLine,
|
||||
newLineNumber: newLineNum
|
||||
})
|
||||
oldIndex++
|
||||
newIndex++
|
||||
oldLineNum++
|
||||
newLineNum++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
const diff = generateDiff()
|
||||
const hasChanges = diff.some(line => line.type !== 'unchanged')
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const fullDiff = diff.map(line => {
|
||||
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
|
||||
const oldNum = line.oldLineNumber ? line.oldLineNumber.toString().padStart(3) : ' '
|
||||
const newNum = line.newLineNumber ? line.newLineNumber.toString().padStart(3) : ' '
|
||||
return `${prefix} ${oldNum}|${newNum} ${line.content}`
|
||||
}).join('\n')
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullDiff)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy diff:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-400">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>No changes detected</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-800 bg-neutral-900/50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-neutral-800/50 border-b border-neutral-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="h-6 w-6 p-0 text-neutral-400 hover:text-white"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
<span className="text-sm font-medium text-white">{title}</span>
|
||||
<span className="text-xs text-neutral-500">({language})</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
className="h-6 w-6 p-0 text-neutral-400 hover:text-white"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="font-mono text-sm">
|
||||
{diff.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-start gap-2 px-4 py-1 ${
|
||||
line.type === 'added'
|
||||
? 'bg-green-500/10 border-l-4 border-green-500'
|
||||
: line.type === 'removed'
|
||||
? 'bg-red-500/10 border-l-4 border-red-500'
|
||||
: 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 text-xs text-neutral-500 select-none">
|
||||
{line.oldLineNumber && line.newLineNumber ? (
|
||||
`${line.oldLineNumber}|${line.newLineNumber}`
|
||||
) : line.oldLineNumber ? (
|
||||
`${line.oldLineNumber}| `
|
||||
) : line.newLineNumber ? (
|
||||
` |${line.newLineNumber}`
|
||||
) : (
|
||||
' | '
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-4 text-center text-xs select-none">
|
||||
{line.type === 'added' ? (
|
||||
<span className="text-green-500">+</span>
|
||||
) : line.type === 'removed' ? (
|
||||
<span className="text-red-500">-</span>
|
||||
) : (
|
||||
<span className="text-neutral-500"> </span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex-1 ${
|
||||
line.type === 'added'
|
||||
? 'text-green-400'
|
||||
: line.type === 'removed'
|
||||
? 'text-red-400'
|
||||
: 'text-neutral-300'
|
||||
}`}>
|
||||
<code>{line.content || ' '}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/components/diff-tool.tsx
Normal file
36
src/components/diff-tool.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { DiffDisplay } from "./diff-display"
|
||||
|
||||
interface DiffToolProps {
|
||||
oldCode: string
|
||||
newCode: string
|
||||
title?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export function DiffTool({ oldCode, newCode, title, language }: DiffToolProps) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<DiffDisplay
|
||||
oldText={oldCode}
|
||||
newText={newCode}
|
||||
title={title || "Code Changes"}
|
||||
language={language || "text"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to create a diff tool call
|
||||
export function createDiffToolCall(oldCode: string, newCode: string, title?: string, language?: string) {
|
||||
return {
|
||||
type: "diff_tool",
|
||||
props: {
|
||||
oldCode,
|
||||
newCode,
|
||||
title,
|
||||
language
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/components/header.tsx
Normal file
81
src/components/header.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import type { Agent } from "@/lib/types"
|
||||
|
||||
interface HeaderProps {
|
||||
agent: Agent | null
|
||||
agents: Agent[]
|
||||
onAgentSelected: (agent: Agent) => void
|
||||
isLoadingAgents: boolean
|
||||
}
|
||||
|
||||
export function Header({ agent, agents, onAgentSelected, isLoadingAgents }: HeaderProps) {
|
||||
const canSelect = agents.length > 0 && !isLoadingAgents
|
||||
const showDropdown = !agent
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 mx-auto w-full max-w-6xl px-4 pt-4 sm:px-6">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-3 text-white">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[0.6rem] uppercase tracking-[0.5em] text-white/45">
|
||||
{agent ? "Current correspondent" : "Select a correspondent"}
|
||||
</p>
|
||||
<h1 className="font-heading text-3xl text-white">
|
||||
{agent ? agent.name : "Inspiration Repo"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-between rounded-2xl border border-white/20 bg-white/10 px-4 py-3 text-left text-[0.65rem] uppercase tracking-[0.35em] text-white/80 shadow-[0_12px_25px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:bg-white/20 disabled:opacity-45"
|
||||
disabled={!canSelect}
|
||||
>
|
||||
<span className="flex flex-col text-sm normal-case tracking-normal text-white">
|
||||
Select correspondent
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-white/80" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[260px] rounded-3xl border border-white/20 bg-black/75 p-2 text-white shadow-[0_20px_45px_rgba(0,0,0,0.45)] backdrop-blur"
|
||||
>
|
||||
{isLoadingAgents ? (
|
||||
<DropdownMenuItem disabled className="text-white/50">
|
||||
Gathering correspondents…
|
||||
</DropdownMenuItem>
|
||||
) : agents.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-white/50">
|
||||
No agents configured
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
agents.map((entry) => (
|
||||
<DropdownMenuItem
|
||||
key={entry.id}
|
||||
onClick={() => onAgentSelected(entry)}
|
||||
className="flex w-full flex-col items-start gap-1 rounded-2xl border border-transparent px-4 py-3 text-left text-xs text-white/70 transition hover:border-white/15 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<span className="font-heading text-base">{entry.name}</span>
|
||||
<span className="text-[0.6rem] uppercase tracking-[0.35em] text-white/60">
|
||||
{entry.description}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
246
src/components/markdown-renderer.tsx
Normal file
246
src/components/markdown-renderer.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
"use client"
|
||||
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import rehypeHighlight from "rehype-highlight"
|
||||
import "highlight.js/styles/github-dark.css"
|
||||
import { DiffTool } from "./diff-tool"
|
||||
import { useState, isValidElement, type ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Copy } from "lucide-react"
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
tone?: "default" | "bubble"
|
||||
}
|
||||
|
||||
// Parse diff tool calls from markdown content
|
||||
function parseDiffTools(content: string) {
|
||||
const diffToolRegex = /```diff-tool\n([\s\S]*?)\n```/g
|
||||
const tools: Array<{ match: string; props: any }> = []
|
||||
let match
|
||||
|
||||
while ((match = diffToolRegex.exec(content)) !== null) {
|
||||
try {
|
||||
const props = JSON.parse(match[1])
|
||||
tools.push({ match: match[0], props })
|
||||
} catch (e) {
|
||||
console.error('Failed to parse diff tool:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className = "", tone = "default" }: MarkdownRendererProps) {
|
||||
// Parse diff tools from content
|
||||
const diffTools = parseDiffTools(content)
|
||||
let processedContent = content
|
||||
|
||||
// Replace diff tool calls with placeholders
|
||||
diffTools.forEach((tool, index) => {
|
||||
processedContent = processedContent.replace(tool.match, `__DIFF_TOOL_${index}__`)
|
||||
})
|
||||
|
||||
const baseTone = tone === "bubble"
|
||||
? "text-charcoal dark:text-white"
|
||||
: "text-charcoal dark:text-foreground"
|
||||
|
||||
const mutedTone = tone === "bubble"
|
||||
? "text-charcoal/80 dark:text-white/80"
|
||||
: "text-charcoal/80 dark:text-foreground/75"
|
||||
|
||||
return (
|
||||
<div className={cn("markdown-glass space-y-3 text-sm leading-relaxed", baseTone, className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
components={{
|
||||
// Custom component for diff tool placeholders
|
||||
p: ({ children }) => {
|
||||
const text = typeof children === 'string' ? children : children?.toString() || ''
|
||||
const diffToolMatch = text.match(/^__DIFF_TOOL_(\d+)__$/)
|
||||
|
||||
if (diffToolMatch) {
|
||||
const index = parseInt(diffToolMatch[1])
|
||||
const tool = diffTools[index]
|
||||
if (tool) {
|
||||
return (
|
||||
<DiffTool
|
||||
oldCode={tool.props.oldCode}
|
||||
newCode={tool.props.newCode}
|
||||
title={tool.props.title}
|
||||
language={tool.props.language}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={cn("mb-2 text-sm leading-relaxed last:mb-0", baseTone)}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
},
|
||||
// Custom styling for different elements
|
||||
h1: ({ children }) => (
|
||||
<h1 className={cn("text-[2rem] font-semibold tracking-tight", baseTone)}>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className={cn("text-[1.75rem] font-semibold tracking-tight", baseTone)}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className={cn("text-[1.5rem] font-semibold", baseTone)}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className={cn("mb-2 list-disc space-y-1 pl-4 text-sm", mutedTone)}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className={cn("mb-2 list-decimal space-y-1 pl-4 text-sm", mutedTone)}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className={cn("text-sm", mutedTone)}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
// Check if this is inline code (no language class) or block code
|
||||
const isInline = !className
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="rounded bg-white/60 px-1.5 py-0.5 font-mono text-xs text-charcoal dark:bg-white/10 dark:text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code className={className}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children, className }) => (
|
||||
<PreWithCopy className={className}>{children}</PreWithCopy>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-burnt/70 pl-4 text-sm italic text-muted-foreground dark:text-foreground/80">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-burnt/40 decoration-2 underline-offset-4 text-burnt hover:text-terracotta dark:text-white dark:hover:text-burnt"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-charcoal dark:text-white">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className={cn("italic", mutedTone)}>
|
||||
{children}
|
||||
</em>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-3">
|
||||
<table className="min-w-full rounded-lg border border-border/50">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-white/70 text-charcoal dark:bg-white/10 dark:text-foreground">
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }) => (
|
||||
<tbody className="bg-white/40 text-charcoal dark:bg-white/5 dark:text-foreground">
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-border/40">
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 text-left text-sm font-semibold text-charcoal dark:text-foreground">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2 text-sm text-charcoal dark:text-foreground">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreWithCopy({ children, className }: { children?: ReactNode; className?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const text = extractCodeText(children)
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.trimEnd())
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1200)
|
||||
} catch (error) {
|
||||
console.error("[markdown] Code copy failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mb-3">
|
||||
<pre className={cn("overflow-x-auto rounded-xl border border-border/50 p-4 text-sm text-charcoal shadow-sm dark:border-white/10 dark:text-foreground", className)}>
|
||||
{children}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"absolute right-3 top-3 inline-flex h-5 w-5 items-center justify-center rounded border border-white/25 bg-white/8 text-white/70 shadow-[0_2px_4px_rgba(0,0,0,0.06)] backdrop-blur transition-transform duration-150 hover:bg-white/18",
|
||||
copied && "scale-90 bg-white/30 text-white"
|
||||
)}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractCodeText(node: ReactNode): string {
|
||||
if (typeof node === "string") {
|
||||
return node
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractCodeText).join("")
|
||||
}
|
||||
if (isValidElement(node)) {
|
||||
return extractCodeText(node.props.children)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
12
src/components/theme-provider.tsx
Normal file
12
src/components/theme-provider.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
|
||||
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
111
src/components/ui/dialog.tsx
Normal file
111
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/30 backdrop-blur-sm transition-opacity duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border/60 bg-background/95 p-6 shadow-2xl backdrop-blur-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring/40 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent/40 data-[state=open]:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
201
src/components/ui/dropdown-menu.tsx
Normal file
201
src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
88
src/lib/types.ts
Normal file
88
src/lib/types.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Core type definitions for the multi-agent chat application
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents an AI agent that users can chat with
|
||||
*/
|
||||
export interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single message in the chat
|
||||
* Images are stored as base64 strings for transmission to the webhook
|
||||
*/
|
||||
export interface Message {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
timestamp: Date
|
||||
isError?: boolean
|
||||
hint?: string
|
||||
images?: string[] // Base64 encoded images (user messages only)
|
||||
}
|
||||
|
||||
/**
|
||||
* API request body for POST /api/chat
|
||||
*/
|
||||
export interface ChatRequest {
|
||||
message: string
|
||||
timestamp: string
|
||||
sessionId: string
|
||||
agentId: string
|
||||
images?: string[] // Optional base64 encoded images
|
||||
}
|
||||
|
||||
/**
|
||||
* API response from POST /api/chat
|
||||
*/
|
||||
export interface ChatResponse {
|
||||
error?: string
|
||||
hint?: string
|
||||
response?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* API response from GET /api/agents
|
||||
*/
|
||||
export interface AgentsResponse {
|
||||
agents: Agent[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for diff-related components
|
||||
*/
|
||||
export interface DiffToolProps {
|
||||
oldCode: string
|
||||
newCode: string
|
||||
title?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface DiffDisplayProps {
|
||||
oldText: string
|
||||
newText: string
|
||||
title?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: "added" | "removed" | "unchanged"
|
||||
content: string
|
||||
oldLineNumber?: number
|
||||
newLineNumber?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for markdown renderer
|
||||
*/
|
||||
export interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
1
test/mock-n8n-server/node_modules/.bin/esbuild
generated
vendored
Symbolic link
1
test/mock-n8n-server/node_modules/.bin/esbuild
generated
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../esbuild/bin/esbuild
|
||||
1
test/mock-n8n-server/node_modules/.bin/mime
generated
vendored
Symbolic link
1
test/mock-n8n-server/node_modules/.bin/mime
generated
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../mime/cli.js
|
||||
1
test/mock-n8n-server/node_modules/.bin/tsx
generated
vendored
Symbolic link
1
test/mock-n8n-server/node_modules/.bin/tsx
generated
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../tsx/dist/cli.mjs
|
||||
1084
test/mock-n8n-server/node_modules/.package-lock.json
generated
vendored
Normal file
1084
test/mock-n8n-server/node_modules/.package-lock.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3
test/mock-n8n-server/node_modules/@esbuild/linux-x64/README.md
generated
vendored
Normal file
3
test/mock-n8n-server/node_modules/@esbuild/linux-x64/README.md
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# esbuild
|
||||
|
||||
This is the Linux 64-bit binary for esbuild, a JavaScript bundler and minifier. See https://github.com/evanw/esbuild for details.
|
||||
BIN
test/mock-n8n-server/node_modules/@esbuild/linux-x64/bin/esbuild
generated
vendored
Executable file
BIN
test/mock-n8n-server/node_modules/@esbuild/linux-x64/bin/esbuild
generated
vendored
Executable file
Binary file not shown.
20
test/mock-n8n-server/node_modules/@esbuild/linux-x64/package.json
generated
vendored
Normal file
20
test/mock-n8n-server/node_modules/@esbuild/linux-x64/package.json
generated
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@esbuild/linux-x64",
|
||||
"version": "0.25.12",
|
||||
"description": "The Linux 64-bit binary for esbuild, a JavaScript bundler.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/evanw/esbuild.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"preferUnplugged": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
21
test/mock-n8n-server/node_modules/@types/body-parser/LICENSE
generated
vendored
Normal file
21
test/mock-n8n-server/node_modules/@types/body-parser/LICENSE
generated
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
15
test/mock-n8n-server/node_modules/@types/body-parser/README.md
generated
vendored
Normal file
15
test/mock-n8n-server/node_modules/@types/body-parser/README.md
generated
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Installation
|
||||
> `npm install --save @types/body-parser`
|
||||
|
||||
# Summary
|
||||
This package contains type definitions for body-parser (https://github.com/expressjs/body-parser).
|
||||
|
||||
# Details
|
||||
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/body-parser.
|
||||
|
||||
### Additional Details
|
||||
* Last updated: Sat, 07 Jun 2025 02:15:25 GMT
|
||||
* Dependencies: [@types/connect](https://npmjs.com/package/@types/connect), [@types/node](https://npmjs.com/package/@types/node)
|
||||
|
||||
# Credits
|
||||
These definitions were written by [Santi Albo](https://github.com/santialbo), [Vilic Vane](https://github.com/vilic), [Jonathan Häberle](https://github.com/dreampulse), [Gevik Babakhani](https://github.com/blendsdk), [Tomasz Łaziuk](https://github.com/tlaziuk), [Jason Walton](https://github.com/jwalton), [Piotr Błażejewicz](https://github.com/peterblazejewicz), and [Sebastian Beltran](https://github.com/bjohansebas).
|
||||
95
test/mock-n8n-server/node_modules/@types/body-parser/index.d.ts
generated
vendored
Normal file
95
test/mock-n8n-server/node_modules/@types/body-parser/index.d.ts
generated
vendored
Normal file
@ -0,0 +1,95 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { NextHandleFunction } from "connect";
|
||||
import * as http from "http";
|
||||
|
||||
// for docs go to https://github.com/expressjs/body-parser/tree/1.19.0#body-parser
|
||||
|
||||
declare namespace bodyParser {
|
||||
interface BodyParser {
|
||||
/**
|
||||
* @deprecated use individual json/urlencoded middlewares
|
||||
*/
|
||||
(options?: OptionsJson & OptionsText & OptionsUrlencoded): NextHandleFunction;
|
||||
/**
|
||||
* Returns middleware that only parses json and only looks at requests
|
||||
* where the Content-Type header matches the type option.
|
||||
*/
|
||||
json(options?: OptionsJson): NextHandleFunction;
|
||||
/**
|
||||
* Returns middleware that parses all bodies as a Buffer and only looks at requests
|
||||
* where the Content-Type header matches the type option.
|
||||
*/
|
||||
raw(options?: Options): NextHandleFunction;
|
||||
|
||||
/**
|
||||
* Returns middleware that parses all bodies as a string and only looks at requests
|
||||
* where the Content-Type header matches the type option.
|
||||
*/
|
||||
text(options?: OptionsText): NextHandleFunction;
|
||||
/**
|
||||
* Returns middleware that only parses urlencoded bodies and only looks at requests
|
||||
* where the Content-Type header matches the type option
|
||||
*/
|
||||
urlencoded(options?: OptionsUrlencoded): NextHandleFunction;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
/** When set to true, then deflated (compressed) bodies will be inflated; when false, deflated bodies are rejected. Defaults to true. */
|
||||
inflate?: boolean | undefined;
|
||||
/**
|
||||
* Controls the maximum request body size. If this is a number,
|
||||
* then the value specifies the number of bytes; if it is a string,
|
||||
* the value is passed to the bytes library for parsing. Defaults to '100kb'.
|
||||
*/
|
||||
limit?: number | string | undefined;
|
||||
/**
|
||||
* The type option is used to determine what media type the middleware will parse
|
||||
*/
|
||||
type?: string | string[] | ((req: http.IncomingMessage) => any) | undefined;
|
||||
/**
|
||||
* The verify option, if supplied, is called as verify(req, res, buf, encoding),
|
||||
* where buf is a Buffer of the raw request body and encoding is the encoding of the request.
|
||||
*/
|
||||
verify?(req: http.IncomingMessage, res: http.ServerResponse, buf: Buffer, encoding: string): void;
|
||||
}
|
||||
|
||||
interface OptionsJson extends Options {
|
||||
/**
|
||||
* The reviver option is passed directly to JSON.parse as the second argument.
|
||||
*/
|
||||
reviver?(key: string, value: any): any;
|
||||
/**
|
||||
* When set to `true`, will only accept arrays and objects;
|
||||
* when `false` will accept anything JSON.parse accepts. Defaults to `true`.
|
||||
*/
|
||||
strict?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface OptionsText extends Options {
|
||||
/**
|
||||
* Specify the default character set for the text content if the charset
|
||||
* is not specified in the Content-Type header of the request.
|
||||
* Defaults to `utf-8`.
|
||||
*/
|
||||
defaultCharset?: string | undefined;
|
||||
}
|
||||
|
||||
interface OptionsUrlencoded extends Options {
|
||||
/**
|
||||
* The extended option allows to choose between parsing the URL-encoded data
|
||||
* with the querystring library (when `false`) or the qs library (when `true`).
|
||||
*/
|
||||
extended?: boolean | undefined;
|
||||
/**
|
||||
* The parameterLimit option controls the maximum number of parameters
|
||||
* that are allowed in the URL-encoded data. If a request contains more parameters than this value,
|
||||
* a 413 will be returned to the client. Defaults to 1000.
|
||||
*/
|
||||
parameterLimit?: number | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
declare const bodyParser: bodyParser.BodyParser;
|
||||
|
||||
export = bodyParser;
|
||||
64
test/mock-n8n-server/node_modules/@types/body-parser/package.json
generated
vendored
Normal file
64
test/mock-n8n-server/node_modules/@types/body-parser/package.json
generated
vendored
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@types/body-parser",
|
||||
"version": "1.19.6",
|
||||
"description": "TypeScript definitions for body-parser",
|
||||
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/body-parser",
|
||||
"license": "MIT",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Santi Albo",
|
||||
"githubUsername": "santialbo",
|
||||
"url": "https://github.com/santialbo"
|
||||
},
|
||||
{
|
||||
"name": "Vilic Vane",
|
||||
"githubUsername": "vilic",
|
||||
"url": "https://github.com/vilic"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Häberle",
|
||||
"githubUsername": "dreampulse",
|
||||
"url": "https://github.com/dreampulse"
|
||||
},
|
||||
{
|
||||
"name": "Gevik Babakhani",
|
||||
"githubUsername": "blendsdk",
|
||||
"url": "https://github.com/blendsdk"
|
||||
},
|
||||
{
|
||||
"name": "Tomasz Łaziuk",
|
||||
"githubUsername": "tlaziuk",
|
||||
"url": "https://github.com/tlaziuk"
|
||||
},
|
||||
{
|
||||
"name": "Jason Walton",
|
||||
"githubUsername": "jwalton",
|
||||
"url": "https://github.com/jwalton"
|
||||
},
|
||||
{
|
||||
"name": "Piotr Błażejewicz",
|
||||
"githubUsername": "peterblazejewicz",
|
||||
"url": "https://github.com/peterblazejewicz"
|
||||
},
|
||||
{
|
||||
"name": "Sebastian Beltran",
|
||||
"githubUsername": "bjohansebas",
|
||||
"url": "https://github.com/bjohansebas"
|
||||
}
|
||||
],
|
||||
"main": "",
|
||||
"types": "index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
|
||||
"directory": "types/body-parser"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
},
|
||||
"peerDependencies": {},
|
||||
"typesPublisherContentHash": "d788c843f427d6ca19640ee90eb433324a18f23aed05402a82c4e47e6d60b29d",
|
||||
"typeScriptVersion": "5.1"
|
||||
}
|
||||
21
test/mock-n8n-server/node_modules/@types/connect/LICENSE
generated
vendored
Normal file
21
test/mock-n8n-server/node_modules/@types/connect/LICENSE
generated
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
15
test/mock-n8n-server/node_modules/@types/connect/README.md
generated
vendored
Normal file
15
test/mock-n8n-server/node_modules/@types/connect/README.md
generated
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Installation
|
||||
> `npm install --save @types/connect`
|
||||
|
||||
# Summary
|
||||
This package contains type definitions for connect (https://github.com/senchalabs/connect).
|
||||
|
||||
# Details
|
||||
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/connect.
|
||||
|
||||
### Additional Details
|
||||
* Last updated: Mon, 06 Nov 2023 22:41:05 GMT
|
||||
* Dependencies: [@types/node](https://npmjs.com/package/@types/node)
|
||||
|
||||
# Credits
|
||||
These definitions were written by [Maxime LUCE](https://github.com/SomaticIT), and [Evan Hahn](https://github.com/EvanHahn).
|
||||
91
test/mock-n8n-server/node_modules/@types/connect/index.d.ts
generated
vendored
Normal file
91
test/mock-n8n-server/node_modules/@types/connect/index.d.ts
generated
vendored
Normal file
@ -0,0 +1,91 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import * as http from "http";
|
||||
|
||||
/**
|
||||
* Create a new connect server.
|
||||
*/
|
||||
declare function createServer(): createServer.Server;
|
||||
|
||||
declare namespace createServer {
|
||||
export type ServerHandle = HandleFunction | http.Server;
|
||||
|
||||
export class IncomingMessage extends http.IncomingMessage {
|
||||
originalUrl?: http.IncomingMessage["url"] | undefined;
|
||||
}
|
||||
|
||||
type NextFunction = (err?: any) => void;
|
||||
|
||||
export type SimpleHandleFunction = (req: IncomingMessage, res: http.ServerResponse) => void;
|
||||
export type NextHandleFunction = (req: IncomingMessage, res: http.ServerResponse, next: NextFunction) => void;
|
||||
export type ErrorHandleFunction = (
|
||||
err: any,
|
||||
req: IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
next: NextFunction,
|
||||
) => void;
|
||||
export type HandleFunction = SimpleHandleFunction | NextHandleFunction | ErrorHandleFunction;
|
||||
|
||||
export interface ServerStackItem {
|
||||
route: string;
|
||||
handle: ServerHandle;
|
||||
}
|
||||
|
||||
export interface Server extends NodeJS.EventEmitter {
|
||||
(req: http.IncomingMessage, res: http.ServerResponse, next?: Function): void;
|
||||
|
||||
route: string;
|
||||
stack: ServerStackItem[];
|
||||
|
||||
/**
|
||||
* Utilize the given middleware `handle` to the given `route`,
|
||||
* defaulting to _/_. This "route" is the mount-point for the
|
||||
* middleware, when given a value other than _/_ the middleware
|
||||
* is only effective when that segment is present in the request's
|
||||
* pathname.
|
||||
*
|
||||
* For example if we were to mount a function at _/admin_, it would
|
||||
* be invoked on _/admin_, and _/admin/settings_, however it would
|
||||
* not be invoked for _/_, or _/posts_.
|
||||
*/
|
||||
use(fn: NextHandleFunction): Server;
|
||||
use(fn: HandleFunction): Server;
|
||||
use(route: string, fn: NextHandleFunction): Server;
|
||||
use(route: string, fn: HandleFunction): Server;
|
||||
|
||||
/**
|
||||
* Handle server requests, punting them down
|
||||
* the middleware stack.
|
||||
*/
|
||||
handle(req: http.IncomingMessage, res: http.ServerResponse, next: Function): void;
|
||||
|
||||
/**
|
||||
* Listen for connections.
|
||||
*
|
||||
* This method takes the same arguments
|
||||
* as node's `http.Server#listen()`.
|
||||
*
|
||||
* HTTP and HTTPS:
|
||||
*
|
||||
* If you run your application both as HTTP
|
||||
* and HTTPS you may wrap them individually,
|
||||
* since your Connect "server" is really just
|
||||
* a JavaScript `Function`.
|
||||
*
|
||||
* var connect = require('connect')
|
||||
* , http = require('http')
|
||||
* , https = require('https');
|
||||
*
|
||||
* var app = connect();
|
||||
*
|
||||
* http.createServer(app).listen(80);
|
||||
* https.createServer(options, app).listen(443);
|
||||
*/
|
||||
listen(port: number, hostname?: string, backlog?: number, callback?: Function): http.Server;
|
||||
listen(port: number, hostname?: string, callback?: Function): http.Server;
|
||||
listen(path: string, callback?: Function): http.Server;
|
||||
listen(handle: any, listeningListener?: Function): http.Server;
|
||||
}
|
||||
}
|
||||
|
||||
export = createServer;
|
||||
32
test/mock-n8n-server/node_modules/@types/connect/package.json
generated
vendored
Normal file
32
test/mock-n8n-server/node_modules/@types/connect/package.json
generated
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@types/connect",
|
||||
"version": "3.4.38",
|
||||
"description": "TypeScript definitions for connect",
|
||||
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/connect",
|
||||
"license": "MIT",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Maxime LUCE",
|
||||
"githubUsername": "SomaticIT",
|
||||
"url": "https://github.com/SomaticIT"
|
||||
},
|
||||
{
|
||||
"name": "Evan Hahn",
|
||||
"githubUsername": "EvanHahn",
|
||||
"url": "https://github.com/EvanHahn"
|
||||
}
|
||||
],
|
||||
"main": "",
|
||||
"types": "index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
|
||||
"directory": "types/connect"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
},
|
||||
"typesPublisherContentHash": "8990242237504bdec53088b79e314b94bec69286df9de56db31f22de403b4092",
|
||||
"typeScriptVersion": "4.5"
|
||||
}
|
||||
21
test/mock-n8n-server/node_modules/@types/cors/LICENSE
generated
vendored
Normal file
21
test/mock-n8n-server/node_modules/@types/cors/LICENSE
generated
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
75
test/mock-n8n-server/node_modules/@types/cors/README.md
generated
vendored
Normal file
75
test/mock-n8n-server/node_modules/@types/cors/README.md
generated
vendored
Normal file
@ -0,0 +1,75 @@
|
||||
# Installation
|
||||
> `npm install --save @types/cors`
|
||||
|
||||
# Summary
|
||||
This package contains type definitions for cors (https://github.com/expressjs/cors/).
|
||||
|
||||
# Details
|
||||
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/cors.
|
||||
## [index.d.ts](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/cors/index.d.ts)
|
||||
````ts
|
||||
/// <reference types="node" />
|
||||
|
||||
import { IncomingHttpHeaders } from "http";
|
||||
|
||||
type StaticOrigin = boolean | string | RegExp | Array<boolean | string | RegExp>;
|
||||
|
||||
type CustomOrigin = (
|
||||
requestOrigin: string | undefined,
|
||||
callback: (err: Error | null, origin?: StaticOrigin) => void,
|
||||
) => void;
|
||||
|
||||
declare namespace e {
|
||||
interface CorsRequest {
|
||||
method?: string | undefined;
|
||||
headers: IncomingHttpHeaders;
|
||||
}
|
||||
interface CorsOptions {
|
||||
/**
|
||||
* @default '*'
|
||||
*/
|
||||
origin?: StaticOrigin | CustomOrigin | undefined;
|
||||
/**
|
||||
* @default 'GET,HEAD,PUT,PATCH,POST,DELETE'
|
||||
*/
|
||||
methods?: string | string[] | undefined;
|
||||
allowedHeaders?: string | string[] | undefined;
|
||||
exposedHeaders?: string | string[] | undefined;
|
||||
credentials?: boolean | undefined;
|
||||
maxAge?: number | undefined;
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
preflightContinue?: boolean | undefined;
|
||||
/**
|
||||
* @default 204
|
||||
*/
|
||||
optionsSuccessStatus?: number | undefined;
|
||||
}
|
||||
type CorsOptionsDelegate<T extends CorsRequest = CorsRequest> = (
|
||||
req: T,
|
||||
callback: (err: Error | null, options?: CorsOptions) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
declare function e<T extends e.CorsRequest = e.CorsRequest>(
|
||||
options?: e.CorsOptions | e.CorsOptionsDelegate<T>,
|
||||
): (
|
||||
req: T,
|
||||
res: {
|
||||
statusCode?: number | undefined;
|
||||
setHeader(key: string, value: string): any;
|
||||
end(): any;
|
||||
},
|
||||
next: (err?: any) => any,
|
||||
) => void;
|
||||
export = e;
|
||||
|
||||
````
|
||||
|
||||
### Additional Details
|
||||
* Last updated: Sat, 07 Jun 2025 02:15:25 GMT
|
||||
* Dependencies: [@types/node](https://npmjs.com/package/@types/node)
|
||||
|
||||
# Credits
|
||||
These definitions were written by [Alan Plum](https://github.com/pluma), [Gaurav Sharma](https://github.com/gtpan77), and [Sebastian Beltran](https://github.com/bjohansebas).
|
||||
56
test/mock-n8n-server/node_modules/@types/cors/index.d.ts
generated
vendored
Normal file
56
test/mock-n8n-server/node_modules/@types/cors/index.d.ts
generated
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { IncomingHttpHeaders } from "http";
|
||||
|
||||
type StaticOrigin = boolean | string | RegExp | Array<boolean | string | RegExp>;
|
||||
|
||||
type CustomOrigin = (
|
||||
requestOrigin: string | undefined,
|
||||
callback: (err: Error | null, origin?: StaticOrigin) => void,
|
||||
) => void;
|
||||
|
||||
declare namespace e {
|
||||
interface CorsRequest {
|
||||
method?: string | undefined;
|
||||
headers: IncomingHttpHeaders;
|
||||
}
|
||||
interface CorsOptions {
|
||||
/**
|
||||
* @default '*'
|
||||
*/
|
||||
origin?: StaticOrigin | CustomOrigin | undefined;
|
||||
/**
|
||||
* @default 'GET,HEAD,PUT,PATCH,POST,DELETE'
|
||||
*/
|
||||
methods?: string | string[] | undefined;
|
||||
allowedHeaders?: string | string[] | undefined;
|
||||
exposedHeaders?: string | string[] | undefined;
|
||||
credentials?: boolean | undefined;
|
||||
maxAge?: number | undefined;
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
preflightContinue?: boolean | undefined;
|
||||
/**
|
||||
* @default 204
|
||||
*/
|
||||
optionsSuccessStatus?: number | undefined;
|
||||
}
|
||||
type CorsOptionsDelegate<T extends CorsRequest = CorsRequest> = (
|
||||
req: T,
|
||||
callback: (err: Error | null, options?: CorsOptions) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
declare function e<T extends e.CorsRequest = e.CorsRequest>(
|
||||
options?: e.CorsOptions | e.CorsOptionsDelegate<T>,
|
||||
): (
|
||||
req: T,
|
||||
res: {
|
||||
statusCode?: number | undefined;
|
||||
setHeader(key: string, value: string): any;
|
||||
end(): any;
|
||||
},
|
||||
next: (err?: any) => any,
|
||||
) => void;
|
||||
export = e;
|
||||
38
test/mock-n8n-server/node_modules/@types/cors/package.json
generated
vendored
Normal file
38
test/mock-n8n-server/node_modules/@types/cors/package.json
generated
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@types/cors",
|
||||
"version": "2.8.19",
|
||||
"description": "TypeScript definitions for cors",
|
||||
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/cors",
|
||||
"license": "MIT",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Alan Plum",
|
||||
"githubUsername": "pluma",
|
||||
"url": "https://github.com/pluma"
|
||||
},
|
||||
{
|
||||
"name": "Gaurav Sharma",
|
||||
"githubUsername": "gtpan77",
|
||||
"url": "https://github.com/gtpan77"
|
||||
},
|
||||
{
|
||||
"name": "Sebastian Beltran",
|
||||
"githubUsername": "bjohansebas",
|
||||
"url": "https://github.com/bjohansebas"
|
||||
}
|
||||
],
|
||||
"main": "",
|
||||
"types": "index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
|
||||
"directory": "types/cors"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
},
|
||||
"peerDependencies": {},
|
||||
"typesPublisherContentHash": "a090e558c5f443573318c2955deecddc840bd8dfaac7cdedf31c7f6ede8d0b47",
|
||||
"typeScriptVersion": "5.1"
|
||||
}
|
||||
21
test/mock-n8n-server/node_modules/@types/express-serve-static-core/LICENSE
generated
vendored
Normal file
21
test/mock-n8n-server/node_modules/@types/express-serve-static-core/LICENSE
generated
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
15
test/mock-n8n-server/node_modules/@types/express-serve-static-core/README.md
generated
vendored
Normal file
15
test/mock-n8n-server/node_modules/@types/express-serve-static-core/README.md
generated
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Installation
|
||||
> `npm install --save @types/express-serve-static-core`
|
||||
|
||||
# Summary
|
||||
This package contains type definitions for express-serve-static-core (http://expressjs.com).
|
||||
|
||||
# Details
|
||||
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/express-serve-static-core/v4.
|
||||
|
||||
### Additional Details
|
||||
* Last updated: Mon, 06 Oct 2025 21:02:40 GMT
|
||||
* Dependencies: [@types/node](https://npmjs.com/package/@types/node), [@types/qs](https://npmjs.com/package/@types/qs), [@types/range-parser](https://npmjs.com/package/@types/range-parser), [@types/send](https://npmjs.com/package/@types/send)
|
||||
|
||||
# Credits
|
||||
These definitions were written by [Boris Yankov](https://github.com/borisyankov), [Satana Charuwichitratana](https://github.com/micksatana), [Jose Luis Leon](https://github.com/JoseLion), [David Stephens](https://github.com/dwrss), and [Shin Ando](https://github.com/andoshin11).
|
||||
1295
test/mock-n8n-server/node_modules/@types/express-serve-static-core/index.d.ts
generated
vendored
Normal file
1295
test/mock-n8n-server/node_modules/@types/express-serve-static-core/index.d.ts
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
51
test/mock-n8n-server/node_modules/@types/express-serve-static-core/package.json
generated
vendored
Normal file
51
test/mock-n8n-server/node_modules/@types/express-serve-static-core/package.json
generated
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@types/express-serve-static-core",
|
||||
"version": "4.19.7",
|
||||
"description": "TypeScript definitions for express-serve-static-core",
|
||||
"homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/express-serve-static-core",
|
||||
"license": "MIT",
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Boris Yankov",
|
||||
"githubUsername": "borisyankov",
|
||||
"url": "https://github.com/borisyankov"
|
||||
},
|
||||
{
|
||||
"name": "Satana Charuwichitratana",
|
||||
"githubUsername": "micksatana",
|
||||
"url": "https://github.com/micksatana"
|
||||
},
|
||||
{
|
||||
"name": "Jose Luis Leon",
|
||||
"githubUsername": "JoseLion",
|
||||
"url": "https://github.com/JoseLion"
|
||||
},
|
||||
{
|
||||
"name": "David Stephens",
|
||||
"githubUsername": "dwrss",
|
||||
"url": "https://github.com/dwrss"
|
||||
},
|
||||
{
|
||||
"name": "Shin Ando",
|
||||
"githubUsername": "andoshin11",
|
||||
"url": "https://github.com/andoshin11"
|
||||
}
|
||||
],
|
||||
"main": "",
|
||||
"types": "index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
|
||||
"directory": "types/express-serve-static-core"
|
||||
},
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
},
|
||||
"peerDependencies": {},
|
||||
"typesPublisherContentHash": "a4797d651510430b6b53a07eb01d86881a113b9ca00290eadb6d46d91e8cedf2",
|
||||
"typeScriptVersion": "5.2"
|
||||
}
|
||||
21
test/mock-n8n-server/node_modules/@types/express/LICENSE
generated
vendored
Normal file
21
test/mock-n8n-server/node_modules/@types/express/LICENSE
generated
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
15
test/mock-n8n-server/node_modules/@types/express/README.md
generated
vendored
Normal file
15
test/mock-n8n-server/node_modules/@types/express/README.md
generated
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
# Installation
|
||||
> `npm install --save @types/express`
|
||||
|
||||
# Summary
|
||||
This package contains type definitions for express (http://expressjs.com).
|
||||
|
||||
# Details
|
||||
Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/express/v4.
|
||||
|
||||
### Additional Details
|
||||
* Last updated: Mon, 27 Oct 2025 20:34:59 GMT
|
||||
* Dependencies: [@types/body-parser](https://npmjs.com/package/@types/body-parser), [@types/express-serve-static-core](https://npmjs.com/package/@types/express-serve-static-core), [@types/qs](https://npmjs.com/package/@types/qs), [@types/serve-static](https://npmjs.com/package/@types/serve-static)
|
||||
|
||||
# Credits
|
||||
These definitions were written by [Boris Yankov](https://github.com/borisyankov), [Puneet Arora](https://github.com/puneetar), [Dylan Frankland](https://github.com/dfrankland), and [Sebastian Beltran](https://github.com/bjohansebas).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user