Public: anyone can read this; the crew can change it.
schedule functions #24
Check · Review · Land · Live — It conflicts with its base.
function-scheduler → main 3 commits
A guest
This pull comes from a guest: its preview runs without the app's variables or a forge token until a person who can write here approves it.
This pull cannot land as it is, whatever its review says, because it changes guarded paths: package.json (release machinery).
77 files
changed +2334
−10620
.biomeignore added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | *.css | |
| 2 | **/*.css | |
| 3 | .claude/ | |
| 4 | src/app/globals.css | |
| 5 | src/styles/ |
.eslintrc.json deleted −7
| @@ -1,7 +0,0 @@ | ||
| 1 | { | |
| 2 | "extends": ["next/core-web-vitals", "next/typescript"], | |
| 3 | "rules": { | |
| 4 | "@typescript-eslint/no-unused-vars": "off", | |
| 5 | "@typescript-eslint/no-explicit-any": "off" | |
| 6 | } | |
| 7 | } |
README.md +222 −20
| @@ -1,36 +1,238 @@ | ||
| 1 | This is a [](https://nextjs.org) project bootstrapped with [](https://nextjs.org/docs/app/api-reference/cli/create-next-app). | |
| 1 | # trevato.dev | |
| 2 | ||
| 3 | Personal portfolio and interactive playground built with Next.js, featuring code execution, 3D visualization, and modern web technologies. | |
| 4 | ||
| 5 | ## Features | |
| 6 | ||
| 7 | ### Portfolio | |
| 8 | - **Hero** - Animated typing introduction with call-to-action | |
| 9 | - **About** - Personal bio with social links | |
| 10 | - **Work** - Showcase of websites built with interactive iframe previews | |
| 11 | - **Projects** - GitHub repository integration with README rendering | |
| 12 | - **Booking** - Cal.com integration for $500 consultations | |
| 13 | ||
| 14 | ### Interactive Playground | |
| 15 | - **Function Creator** - Full-featured code editor with TypeScript/Python execution via Vercel Sandbox | |
| 16 | - Environment variable support | |
| 17 | - Secure isolated execution | |
| 18 | - **Scheduling system** with cron-based execution (runs via Vercel Cron every minute) | |
| 19 | - Execution history tracking | |
| 20 | - Supabase authentication required | |
| 21 | - **3D Viewer** - Three.js object visualization | |
| 22 | - **Color Picker** - Interactive color selection tool | |
| 23 | ||
| 24 | ### Visual Experience | |
| 25 | - Space-themed background with shooting stars and particles | |
| 26 | - Smooth animations powered by Framer Motion | |
| 27 | - Responsive design (mobile-first) | |
| 28 | - Neon/glow effects with blue color scheme | |
| 29 | ||
| 30 | ## Tech Stack | |
| 31 | ||
| 32 | - **Framework**: Next.js 14 (App Router) | |
| 33 | - **Runtime**: Bun | |
| 34 | - **Language**: TypeScript (strict mode) | |
| 35 | - **UI**: React 18, Tailwind CSS, shadcn/ui, Radix UI | |
| 36 | - **Animation**: Framer Motion | |
| 37 | - **3D Graphics**: Three.js, React Three Fiber | |
| 38 | - **Authentication**: Supabase SSR | |
| 39 | - **Code Execution**: Vercel Sandbox | |
| 40 | - **Analytics**: Vercel Analytics | |
| 41 | - **Linting**: Biome.js | |
| 42 | - **Testing**: Vitest | |
| 2 | 43 | |
| 3 | 44 | ## Getting Started |
| 4 | 45 | |
| 5 | First, run the development server: | |
| 46 | ### Prerequisites | |
| 47 | - [](https://bun.sh/) installed | |
| 48 | - Supabase project (for authentication features) | |
| 49 | - Vercel Sandbox API access (for code execution) | |
| 50 | ||
| 51 | ### Installation | |
| 6 | 52 | |
| 7 | 53 | bash |
| 8 | npm run dev | |
| 9 | # or | |
| 10 | yarn dev | |
| 11 | # or | |
| 12 | pnpm dev | |
| 13 | # or | |
| 14 | bun dev | |
| 54 | # Clone the repository | |
| 55 | git clone https://github.com/yourusername/trevato.dev.git | |
| 56 | cd trevato.dev | |
| 57 | ||
| 58 | # Install dependencies | |
| 59 | bun install | |
| 60 | ||
| 61 | # Set up environment variables | |
| 62 | cp .env.local.example .env.local | |
| 63 | # Edit .env.local with your credentials | |
| 15 | 64 | |
| 16 | 65 | |
| 17 | Open [](http://localhost:3000) with your browser to see the result. | |
| 66 | ### Environment Variables | |
| 18 | 67 | |
| 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. | |
| 68 | Create a `.env.local` file with: | |
| 20 | 69 | |
| 21 | This project uses [](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [](https://vercel.com/font), a new font family for Vercel. | |
| 70 | env | |
| 71 | # Supabase | |
| 72 | NEXT_PUBLIC_SUPABASE_URL=your_supabase_url | |
| 73 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key | |
| 22 | 74 | |
| 23 | ## Learn More | |
| 75 | # GitHub (for projects page) | |
| 76 | GITHUB_API_TOKEN=your_github_token | |
| 24 | 77 | |
| 25 | To learn more about Next.js, take a look at the following resources: | |
| 78 | # App URL (for cron jobs) | |
| 79 | NEXT_PUBLIC_APP_URL=http://localhost:3000 | |
| 26 | 80 | |
| 27 | - [](https://nextjs.org/docs) - learn about Next.js features and API. | |
| 28 | - [](https://nextjs.org/learn) - an interactive Next.js tutorial. | |
| 81 | # Cron Secret (for production - generate a random string) | |
| 82 | # This is used to authenticate Vercel Cron → Supabase Edge Function calls | |
| 83 | CRON_SECRET=your_random_secret_string | |
| 29 | 84 | |
| 30 | You can check out [](https://github.com/vercel/next.js) - your feedback and contributions are welcome! | |
| 85 | # Sentry (for error tracking in production) | |
| 86 | NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn | |
| 87 | SENTRY_ORG=your_org | |
| 88 | SENTRY_PROJECT=your_project | |
| 89 | SENTRY_AUTH_TOKEN=your_auth_token | |
| 90 | ||
| 31 | 91 | |
| 32 | ## Deploy on Vercel | |
| 92 | ### Database Setup | |
| 33 | 93 | |
| 34 | The easiest way to deploy your Next.js app is to use the [](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |
| 94 | The scheduling feature requires database tables and Edge Function deployment: | |
| 35 | 95 | |
| 36 | Check out our [](https://nextjs.org/docs/app/building-your-application/deploying) for more details. | |
| 96 | bash | |
| 97 | # 1. Apply database migration | |
| 98 | # Option A: Via Supabase CLI (if running locally) | |
| 99 | supabase db reset | |
| 100 | ||
| 101 | # Option B: Via Supabase Dashboard | |
| 102 | # Copy SQL from supabase/migrations/20251123_create_scheduled_functions.sql | |
| 103 | # and run it in your Supabase SQL editor | |
| 104 | ||
| 105 | # 2. Deploy Edge Function | |
| 106 | supabase functions deploy execute-scheduled | |
| 107 | ||
| 108 | # 3. Set Edge Function secrets | |
| 109 | supabase secrets set CRON_SECRET=your_random_secret_string | |
| 110 | supabase secrets set NEXT_PUBLIC_APP_URL=https://your-app.vercel.app | |
| 111 | ||
| 112 | ||
| 113 | ### Development | |
| 114 | ||
| 115 | bash | |
| 116 | # Start development server | |
| 117 | bun dev | |
| 118 | ||
| 119 | # Start Supabase locally (optional) | |
| 120 | supabase start | |
| 121 | ||
| 122 | # Run tests | |
| 123 | bun test | |
| 124 | ||
| 125 | # Run tests with UI | |
| 126 | bun test:ui | |
| 127 | ||
| 128 | # Run linter | |
| 129 | bun run lint | |
| 130 | ||
| 131 | # Auto-fix linting issues | |
| 132 | bun run lint:fix | |
| 133 | ||
| 134 | # Format code | |
| 135 | bun run format | |
| 136 | ||
| 137 | ||
| 138 | Open [](http://localhost:3000) to see the result. | |
| 139 | ||
| 140 | ### Building | |
| 141 | ||
| 142 | bash | |
| 143 | # Build for production | |
| 144 | bun run build | |
| 145 | ||
| 146 | # Start production server | |
| 147 | bun start | |
| 148 | ||
| 149 | ||
| 150 | ## Project Structure | |
| 151 | ||
| 152 | ||
| 153 | trevato.dev/ | |
| 154 | ├── src/ | |
| 155 | │ ├── app/ # Next.js App Router pages | |
| 156 | │ │ ├── api/ # API routes (sandbox execution, scheduling) | |
| 157 | │ │ ├── auth/ # Authentication pages | |
| 158 | │ │ ├── playground/ # Interactive playground features | |
| 159 | │ │ └── projects/ # GitHub projects integration | |
| 160 | │ ├── components/ # React components | |
| 161 | │ │ ├── playground/ # Playground-specific components | |
| 162 | │ │ └── ui/ # shadcn/ui components | |
| 163 | │ ├── lib/ # Utility functions and mappings | |
| 164 | │ ├── styles/ # Global styles and markdown CSS | |
| 165 | │ ├── utils/ # Helper utilities | |
| 166 | │ │ └── supabase/ # Supabase client/server utilities | |
| 167 | │ └── __tests__/ # Test files | |
| 168 | ├── public/ # Static assets | |
| 169 | │ ├── models/ # 3D models | |
| 170 | │ ├── projects/ # Project images | |
| 171 | │ └── resume/ # Resume PDFs | |
| 172 | ├── supabase/ # Supabase configuration | |
| 173 | └── biome.json # Biome linter configuration | |
| 174 | ||
| 175 | ||
| 176 | ## Code Quality | |
| 177 | ||
| 178 | - **Type Safety**: TypeScript with strict mode enabled | |
| 179 | - **Linting**: Biome.js with recommended rules | |
| 180 | - **Testing**: Vitest with @testing-library/react | |
| 181 | - **Formatting**: Automated via Biome.js | |
| 182 | - **Git Hooks**: Recommended to set up with lint-staged | |
| 183 | ||
| 184 | ## Scheduling System | |
| 185 | ||
| 186 | The Function Creator includes a production-ready scheduling system: | |
| 187 | ||
| 188 | ### How It Works | |
| 189 | 1. User schedules a function with cron expression (e.g., `0 * * * *` for hourly) | |
| 190 | 2. Function is stored in Supabase with calculated next run time | |
| 191 | 3. Vercel Cron runs `/api/cron/execute-scheduled` every minute | |
| 192 | 4. Vercel endpoint triggers Supabase Edge Function with secret | |
| 193 | 5. Edge Function (with elevated permissions) fetches due functions and executes them via Vercel Sandbox | |
| 194 | 6. Execution history is logged and next run time is updated | |
| 195 | ||
| 196 | ### Features | |
| 197 | - Standard cron expression support (minute hour day month weekday) | |
| 198 | - Per-function environment variables | |
| 199 | - Execution history with output/error logging | |
| 200 | - Enable/disable individual functions | |
| 201 | - Automatic next-run calculation using cron-parser | |
| 202 | - RLS policies ensure users only see their own functions | |
| 203 | ||
| 204 | ### Database Tables | |
| 205 | - `scheduled_functions` - Stores function code, cron schedules, and metadata | |
| 206 | - `execution_history` - Logs each execution with status, output, and duration | |
| 207 | ||
| 208 | ## Known Limitations | |
| 209 | ||
| 210 | - CSS linting disabled for Tailwind directive compatibility | |
| 211 | - 3 CSS parsing warnings (known Biome limitation with Tailwind) | |
| 212 | - Vercel Cron limited to 1-minute intervals (finest granularity) | |
| 213 | ||
| 214 | ## Performance | |
| 215 | ||
| 216 | - Canvas animations optimized for smooth rendering | |
| 217 | - Code splitting via Next.js defaults | |
| 218 | - Image optimization with next/image | |
| 219 | - Vercel Sandbox execution timeout: 5 minutes | |
| 220 | ||
| 221 | ## Security | |
| 222 | ||
| 223 | - Authentication via Supabase | |
| 224 | - Code execution in isolated Vercel Sandbox environment | |
| 225 | - Middleware protecting sensitive routes | |
| 226 | - Environment variables properly configured | |
| 227 | ||
| 228 | ## Contributing | |
| 229 | ||
| 230 | This is a personal portfolio project. Feel free to fork and adapt for your own use. | |
| 231 | ||
| 232 | ## License | |
| 233 | ||
| 234 | MIT License - feel free to use this code for your own portfolio. | |
| 235 | ||
| 236 | --- | |
| 237 | ||
| 238 | Built with attention to detail by [](https://trevato.dev) |
biome.json added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | { | |
| 2 | "$schema": "https://biomejs.dev/schemas/2.3.7/schema.json", | |
| 3 | "vcs": { | |
| 4 | "enabled": true, | |
| 5 | "clientKind": "git", | |
| 6 | "useIgnoreFile": true, | |
| 7 | "defaultBranch": "main" | |
| 8 | }, | |
| 9 | "files": { | |
| 10 | "ignoreUnknown": true | |
| 11 | }, | |
| 12 | "formatter": { | |
| 13 | "enabled": true, | |
| 14 | "formatWithErrors": true, | |
| 15 | "indentStyle": "space", | |
| 16 | "indentWidth": 2, | |
| 17 | "lineWidth": 100, | |
| 18 | "lineEnding": "lf" | |
| 19 | }, | |
| 20 | "linter": { | |
| 21 | "enabled": true, | |
| 22 | "rules": { | |
| 23 | "recommended": true, | |
| 24 | "a11y": { | |
| 25 | "recommended": true, | |
| 26 | "noLabelWithoutControl": "warn", | |
| 27 | "noSvgWithoutTitle": "warn", | |
| 28 | "useButtonType": "warn" | |
| 29 | }, | |
| 30 | "correctness": { | |
| 31 | "recommended": true, | |
| 32 | "noUnusedVariables": "warn", | |
| 33 | "noUnusedFunctionParameters": "warn", | |
| 34 | "useExhaustiveDependencies": "warn" | |
| 35 | }, | |
| 36 | "style": { | |
| 37 | "recommended": true, | |
| 38 | "noNonNullAssertion": "warn" | |
| 39 | }, | |
| 40 | "suspicious": { | |
| 41 | "recommended": true, | |
| 42 | "noExplicitAny": "off", | |
| 43 | "noArrayIndexKey": "warn", | |
| 44 | "useIterableCallbackReturn": "warn" | |
| 45 | }, | |
| 46 | "complexity": { | |
| 47 | "recommended": true | |
| 48 | }, | |
| 49 | "performance": { | |
| 50 | "recommended": true | |
| 51 | }, | |
| 52 | "security": { | |
| 53 | "recommended": true | |
| 54 | } | |
| 55 | } | |
| 56 | }, | |
| 57 | "javascript": { | |
| 58 | "formatter": { | |
| 59 | "quoteStyle": "double", | |
| 60 | "jsxQuoteStyle": "double", | |
| 61 | "semicolons": "asNeeded", | |
| 62 | "trailingCommas": "es5" | |
| 63 | } | |
| 64 | }, | |
| 65 | "css": { | |
| 66 | "parser": { | |
| 67 | "allowWrongLineComments": true, | |
| 68 | "cssModules": false | |
| 69 | }, | |
| 70 | "formatter": { | |
| 71 | "enabled": false | |
| 72 | }, | |
| 73 | "linter": { | |
| 74 | "enabled": false | |
| 75 | } | |
| 76 | }, | |
| 77 | "overrides": [ | |
| 78 | { | |
| 79 | "includes": ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"], | |
| 80 | "linter": { | |
| 81 | "rules": { | |
| 82 | "suspicious": { | |
| 83 | "noExplicitAny": "off" | |
| 84 | } | |
| 85 | } | |
| 86 | } | |
| 87 | } | |
| 88 | ] | |
| 89 | } |
bun.lockb
Binary file not shown.
components.json +1 −1
| @@ -17,4 +17,4 @@ | ||
| 17 | 17 | "lib": "@/lib", |
| 18 | 18 | "hooks": "@/hooks" |
| 19 | 19 | } |
| 20 | }↵ | |
| 20 | } |
middleware.ts +3 −3
| @@ -1,5 +1,5 @@ | ||
| 1 | import { type NextRequest } from 'next/server' | |
| 2 | import { updateSession } from '@/utils/supabase/middleware' | |
| 1 | import type { NextRequest } from "next/server" | |
| 2 | import { updateSession } from "@/utils/supabase/middleware" | |
| 3 | 3 | |
| 4 | 4 | export async function middleware(request: NextRequest) { |
| 5 | 5 | return await updateSession(request) |
| @@ -14,6 +14,6 @@ export const config = { | ||
| 14 | 14 | * - favicon.ico (favicon file) |
| 15 | 15 | * Feel free to modify this pattern to include more paths. |
| 16 | 16 | */ |
| 17 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', | |
| 17 | "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", | |
| 18 | 18 | ], |
| 19 | 19 | } |
next.config.mjs +32 −3
| @@ -1,4 +1,33 @@ | ||
| 1 | /** @type {import('next').NextConfig} */ | |
| 2 | const nextConfig = {}; | |
| 1 | import { withSentryConfig } from "@sentry/nextjs" | |
| 3 | 2 | |
| 4 | export default nextConfig; | |
| 3 | /** @type {import('next').NextConfig} */ | |
| 4 | const nextConfig = { | |
| 5 | eslint: { | |
| 6 | // Disable ESLint during builds since we're using Biome | |
| 7 | ignoreDuringBuilds: true, | |
| 8 | }, | |
| 9 | } | |
| 10 | ||
| 11 | export default withSentryConfig(nextConfig, { | |
| 12 | // Sentry configuration options | |
| 13 | org: process.env.SENTRY_ORG, | |
| 14 | project: process.env.SENTRY_PROJECT, | |
| 15 | ||
| 16 | // Auth token for uploading source maps | |
| 17 | authToken: process.env.SENTRY_AUTH_TOKEN, | |
| 18 | ||
| 19 | // Only upload source maps in production | |
| 20 | silent: process.env.NODE_ENV !== "production", | |
| 21 | ||
| 22 | // Upload source maps during build | |
| 23 | widenClientFileUpload: true, | |
| 24 | ||
| 25 | // Transpile SDK for compatibility | |
| 26 | transpileClientSDK: true, | |
| 27 | ||
| 28 | // Hide source maps from generated client bundles | |
| 29 | hideSourceMaps: true, | |
| 30 | ||
| 31 | // Disable Sentry during development | |
| 32 | disableLogger: process.env.NODE_ENV !== "production", | |
| 33 | }) |
package-lock.json deleted −9658
Diff not shown — 9,658 changed lines (limit 5,000). −9658
View filepackage.json +21 −4
| @@ -6,7 +6,14 @@ | ||
| 6 | 6 | "dev": "next dev", |
| 7 | 7 | "build": "next build", |
| 8 | 8 | "start": "next start", |
| 9 | "lint": "next lint" | |
| 9 | "lint": "biome check .", | |
| 10 | "lint:fix": "biome check --write .", | |
| 11 | "format": "biome format --write .", | |
| 12 | "format:check": "biome format .", | |
| 13 | "check": "biome check .", | |
| 14 | "test": "vitest", | |
| 15 | "test:ui": "vitest --ui", | |
| 16 | "test:coverage": "vitest --coverage" | |
| 10 | 17 | }, |
| 11 | 18 | "dependencies": { |
| 12 | 19 | "@calcom/embed-react": "^1.5.0", |
| @@ -20,16 +27,20 @@ | ||
| 20 | 27 | "@radix-ui/react-tooltip": "^1.1.2", |
| 21 | 28 | "@react-three/drei": "^9.112.0", |
| 22 | 29 | "@react-three/fiber": "^8.17.7", |
| 30 | "@sentry/nextjs": "^10.26.0", | |
| 23 | 31 | "@supabase/ssr": "^0.5.1", |
| 24 | 32 | "@uiw/react-textarea-code-editor": "^3.0.2", |
| 25 | 33 | "@vercel/analytics": "^1.5.0", |
| 26 | 34 | "@vercel/sandbox": "^1.0.2", |
| 27 | 35 | "class-variance-authority": "^0.7.0", |
| 28 | 36 | "clsx": "^2.1.1", |
| 37 | "cron-parser": "^5.4.0", | |
| 29 | 38 | "framer-motion": "^11.5.4", |
| 30 | 39 | "lucide-react": "^0.439.0", |
| 31 | 40 | "next": "14.2.9", |
| 32 | 41 | "octokit": "^4.0.2", |
| 42 | "pino": "^10.1.0", | |
| 43 | "pino-pretty": "^13.1.2", | |
| 33 | 44 | "react": "^18", |
| 34 | 45 | "react-dom": "^18", |
| 35 | 46 | "react-icons": "^5.3.0", |
| @@ -44,14 +55,20 @@ | ||
| 44 | 55 | "three": "^0.168.0" |
| 45 | 56 | }, |
| 46 | 57 | "devDependencies": { |
| 58 | "@biomejs/biome": "^2.3.7", | |
| 59 | "@testing-library/jest-dom": "^6.9.1", | |
| 60 | "@testing-library/react": "^16.3.0", | |
| 61 | "@testing-library/user-event": "^14.6.1", | |
| 47 | 62 | "@types/node": "^20", |
| 48 | 63 | "@types/react": "^18", |
| 49 | 64 | "@types/react-dom": "^18", |
| 50 | 65 | "@types/react-syntax-highlighter": "^15.5.13", |
| 51 | "eslint": "^8", | |
| 52 | "eslint-config-next": "14.2.9", | |
| 66 | "@vitejs/plugin-react": "^5.1.1", | |
| 67 | "@vitest/ui": "^4.0.13", | |
| 68 | "jsdom": "^27.2.0", | |
| 53 | 69 | "postcss": "^8", |
| 54 | 70 | "tailwindcss": "^3.4.1", |
| 55 | "typescript": "^5" | |
| 71 | "typescript": "^5", | |
| 72 | "vitest": "^4.0.13" | |
| 56 | 73 | } |
| 57 | 74 | } |
postcss.config.mjs +2 −2
| @@ -3,6 +3,6 @@ const config = { | ||
| 3 | 3 | plugins: { |
| 4 | 4 | tailwindcss: {}, |
| 5 | 5 | }, |
| 6 | }; | |
| 6 | } | |
| 7 | 7 | |
| 8 | export default config; | |
| 8 | export default config |
sentry.client.config.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | import * as Sentry from "@sentry/nextjs" | |
| 2 | ||
| 3 | Sentry.init({ | |
| 4 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, | |
| 5 | ||
| 6 | // Performance Monitoring | |
| 7 | tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0, // 10% in prod, 100% in dev | |
| 8 | ||
| 9 | // Session Replay | |
| 10 | replaysSessionSampleRate: 0.1, // 10% of sessions | |
| 11 | replaysOnErrorSampleRate: 1.0, // 100% of sessions with errors | |
| 12 | ||
| 13 | // Don't send errors in development | |
| 14 | enabled: process.env.NODE_ENV === "production", | |
| 15 | ||
| 16 | // Environment | |
| 17 | environment: process.env.NODE_ENV, | |
| 18 | ||
| 19 | // Integrations | |
| 20 | integrations: [ | |
| 21 | Sentry.replayIntegration({ | |
| 22 | maskAllText: false, | |
| 23 | blockAllMedia: false, | |
| 24 | }), | |
| 25 | ], | |
| 26 | ||
| 27 | // Filter out noise | |
| 28 | ignoreErrors: [ | |
| 29 | // Browser extensions | |
| 30 | "top.GLOBALS", | |
| 31 | // Random plugins/extensions | |
| 32 | "originalCreateNotification", | |
| 33 | "canvas.contentDocument", | |
| 34 | "MyApp_RemoveAllHighlights", | |
| 35 | // Network errors | |
| 36 | "NetworkError", | |
| 37 | "Failed to fetch", | |
| 38 | ], | |
| 39 | ||
| 40 | // Attach user context | |
| 41 | beforeSend(event, hint) { | |
| 42 | // Scrub sensitive data | |
| 43 | if (event.request) { | |
| 44 | delete event.request.cookies | |
| 45 | } | |
| 46 | ||
| 47 | return event | |
| 48 | }, | |
| 49 | }) |
sentry.edge.config.ts added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | import * as Sentry from "@sentry/nextjs" | |
| 2 | ||
| 3 | Sentry.init({ | |
| 4 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, | |
| 5 | ||
| 6 | // Performance Monitoring | |
| 7 | tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0, | |
| 8 | ||
| 9 | // Don't send errors in development | |
| 10 | enabled: process.env.NODE_ENV === "production", | |
| 11 | ||
| 12 | // Environment | |
| 13 | environment: process.env.NODE_ENV, | |
| 14 | ||
| 15 | // Filter out noise | |
| 16 | ignoreErrors: ["NetworkError", "Failed to fetch"], | |
| 17 | }) |
sentry.server.config.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | import * as Sentry from "@sentry/nextjs" | |
| 2 | ||
| 3 | Sentry.init({ | |
| 4 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, | |
| 5 | ||
| 6 | // Performance Monitoring | |
| 7 | tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0, | |
| 8 | ||
| 9 | // Don't send errors in development | |
| 10 | enabled: process.env.NODE_ENV === "production", | |
| 11 | ||
| 12 | // Environment | |
| 13 | environment: process.env.NODE_ENV, | |
| 14 | ||
| 15 | // Filter out noise | |
| 16 | ignoreErrors: ["NetworkError", "Failed to fetch", "ECONNREFUSED"], | |
| 17 | ||
| 18 | // Attach context | |
| 19 | beforeSend(event) { | |
| 20 | // Scrub sensitive data from server events | |
| 21 | if (event.request) { | |
| 22 | delete event.request.cookies | |
| 23 | if (event.request.headers) { | |
| 24 | delete event.request.headers["authorization"] | |
| 25 | delete event.request.headers["cookie"] | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | return event | |
| 30 | }, | |
| 31 | }) |
src/__tests__/setup.test.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | import { describe, expect, it } from "vitest" | |
| 2 | ||
| 3 | describe("Vitest Setup", () => { | |
| 4 | it("should run basic test", () => { | |
| 5 | expect(true).toBe(true) | |
| 6 | }) | |
| 7 | ||
| 8 | it("should handle arithmetic", () => { | |
| 9 | expect(2 + 2).toBe(4) | |
| 10 | }) | |
| 11 | }) |
src/app/api/cron/execute-scheduled/route.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | import { type NextRequest, NextResponse } from "next/server" | |
| 2 | import { createRouteLogger } from "@/lib/logger" | |
| 3 | ||
| 4 | export const runtime = "nodejs" | |
| 5 | ||
| 6 | const log = createRouteLogger("/api/cron/execute-scheduled") | |
| 7 | ||
| 8 | // This endpoint is called by Vercel Cron every minute | |
| 9 | // It triggers the Supabase Edge Function to do the actual work | |
| 10 | export async function GET(request: NextRequest) { | |
| 11 | const startTime = Date.now() | |
| 12 | ||
| 13 | try { | |
| 14 | // Verify this is a legitimate cron request from Vercel | |
| 15 | const authHeader = request.headers.get("authorization") | |
| 16 | if (process.env.NODE_ENV === "production") { | |
| 17 | if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { | |
| 18 | log.warn("Unauthorized cron trigger attempt") | |
| 19 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | log.info("Cron triggered, calling Edge Function") | |
| 24 | ||
| 25 | // Call Supabase Edge Function to execute scheduled functions | |
| 26 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL | |
| 27 | const cronSecret = process.env.CRON_SECRET | |
| 28 | ||
| 29 | if (!supabaseUrl || !cronSecret) { | |
| 30 | log.error("Missing required environment variables") | |
| 31 | return NextResponse.json( | |
| 32 | { error: "Missing required environment variables" }, | |
| 33 | { status: 500 } | |
| 34 | ) | |
| 35 | } | |
| 36 | ||
| 37 | const response = await fetch(`${supabaseUrl}/functions/v1/execute-scheduled`, { | |
| 38 | method: "POST", | |
| 39 | headers: { | |
| 40 | Authorization: `Bearer ${cronSecret}`, | |
| 41 | "Content-Type": "application/json", | |
| 42 | }, | |
| 43 | }) | |
| 44 | ||
| 45 | if (!response.ok) { | |
| 46 | const errorText = await response.text() | |
| 47 | throw new Error(`Edge function call failed: ${response.status} ${errorText}`) | |
| 48 | } | |
| 49 | ||
| 50 | const result = await response.json() | |
| 51 | const duration = Date.now() - startTime | |
| 52 | ||
| 53 | log.info( | |
| 54 | { | |
| 55 | duration, | |
| 56 | processed: result.processed, | |
| 57 | successful: result.successful, | |
| 58 | failed: result.failed, | |
| 59 | }, | |
| 60 | "Cron execution completed" | |
| 61 | ) | |
| 62 | ||
| 63 | return NextResponse.json({ | |
| 64 | success: true, | |
| 65 | message: "Triggered edge function successfully", | |
| 66 | result, | |
| 67 | }) | |
| 68 | } catch (error) { | |
| 69 | const duration = Date.now() - startTime | |
| 70 | log.error({ err: error, duration }, "Cron trigger error") | |
| 71 | ||
| 72 | return NextResponse.json( | |
| 73 | { | |
| 74 | error: "Failed to trigger scheduled execution", | |
| 75 | details: error instanceof Error ? error.message : "Unknown error", | |
| 76 | }, | |
| 77 | { status: 500 } | |
| 78 | ) | |
| 79 | } | |
| 80 | } |
src/app/api/sandbox/execute/route.ts +57 −44
| @@ -1,46 +1,51 @@ | ||
| 1 | import { NextRequest, NextResponse } from 'next/server'; | |
| 2 | import { Sandbox } from '@vercel/sandbox'; | |
| 3 | import { createClient } from '@/utils/supabase/server'; | |
| 1 | import { Sandbox } from "@vercel/sandbox" | |
| 2 | import { type NextRequest, NextResponse } from "next/server" | |
| 3 | import { createClient } from "@/utils/supabase/server" | |
| 4 | import { createRouteLogger } from "@/lib/logger" | |
| 4 | 5 | |
| 5 | export const runtime = 'nodejs'; | |
| 6 | export const maxDuration = 300; // 5 minutes | |
| 6 | export const runtime = "nodejs" | |
| 7 | export const maxDuration = 300 // 5 minutes | |
| 8 | ||
| 9 | const log = createRouteLogger("/api/sandbox/execute") | |
| 7 | 10 | |
| 8 | 11 | export async function POST(request: NextRequest) { |
| 12 | const startTime = Date.now() | |
| 13 | ||
| 9 | 14 | try { |
| 10 | 15 | // Check authentication |
| 11 | const supabase = await createClient(); | |
| 12 | const { data: { user }, error: authError } = await supabase.auth.getUser(); | |
| 16 | const supabase = await createClient() | |
| 17 | const { | |
| 18 | data: { user }, | |
| 19 | error: authError, | |
| 20 | } = await supabase.auth.getUser() | |
| 13 | 21 | |
| 14 | 22 | if (authError || !user) { |
| 15 | return NextResponse.json( | |
| 16 | { error: 'Authentication required' }, | |
| 17 | { status: 401 } | |
| 18 | ); | |
| 23 | log.warn("Unauthorized sandbox execution attempt") | |
| 24 | return NextResponse.json({ error: "Authentication required" }, { status: 401 }) | |
| 19 | 25 | } |
| 20 | 26 | |
| 21 | const { code, language, envVars = {} } = await request.json(); | |
| 27 | const { code, language, envVars = {} } = await request.json() | |
| 28 | ||
| 29 | log.info({ userId: user.id, language, hasEnvVars: Object.keys(envVars).length > 0 }, "Executing sandbox code") | |
| 22 | 30 | |
| 23 | 31 | if (!code || !language) { |
| 24 | return NextResponse.json( | |
| 25 | { error: 'Code and language are required' }, | |
| 26 | { status: 400 } | |
| 27 | ); | |
| 32 | return NextResponse.json({ error: "Code and language are required" }, { status: 400 }) | |
| 28 | 33 | } |
| 29 | 34 | |
| 30 | 35 | // Create a new sandbox with the appropriate runtime |
| 31 | const runtime = language === 'python' ? 'python3.13' : 'node22'; | |
| 36 | const runtime = language === "python" ? "python3.13" : "node22" | |
| 32 | 37 | const sandbox = await Sandbox.create({ |
| 33 | 38 | runtime, |
| 34 | }); | |
| 39 | }) | |
| 35 | 40 | |
| 36 | 41 | try { |
| 37 | let output = ''; | |
| 38 | let errorOutput = ''; | |
| 42 | let output = "" | |
| 43 | let errorOutput = "" | |
| 39 | 44 | |
| 40 | 45 | // Determine the file extension and execution command based on language |
| 41 | const fileName = language === 'python' ? 'main.py' : 'main.ts'; | |
| 42 | const execCmd = language === 'python' ? 'python3' : 'npx'; | |
| 43 | const execArgs = language === 'python' ? ['main.py'] : ['tsx', 'main.ts']; | |
| 46 | const fileName = language === "python" ? "main.py" : "main.ts" | |
| 47 | const execCmd = language === "python" ? "python3" : "npx" | |
| 48 | const execArgs = language === "python" ? ["main.py"] : ["tsx", "main.ts"] | |
| 44 | 49 | |
| 45 | 50 | // Write the code to a file in the sandbox |
| 46 | 51 | await sandbox.writeFiles([ |
| @@ -48,24 +53,24 @@ export async function POST(request: NextRequest) { | ||
| 48 | 53 | path: fileName, |
| 49 | 54 | content: Buffer.from(code), |
| 50 | 55 | }, |
| 51 | ]); | |
| 56 | ]) | |
| 52 | 57 | |
| 53 | 58 | // Install tsx for TypeScript execution if needed |
| 54 | if (language === 'typescript') { | |
| 59 | if (language === "typescript") { | |
| 55 | 60 | const installResult = await sandbox.runCommand({ |
| 56 | cmd: 'npm', | |
| 57 | args: ['install', '-g', 'tsx'], | |
| 58 | }); | |
| 61 | cmd: "npm", | |
| 62 | args: ["install", "-g", "tsx"], | |
| 63 | }) | |
| 59 | 64 | |
| 60 | 65 | // Wait for install to complete |
| 61 | const installExitCode = installResult.exitCode; | |
| 66 | const installExitCode = installResult.exitCode | |
| 62 | 67 | if (installExitCode !== 0) { |
| 63 | const installError = await installResult.stderr(); | |
| 68 | const installError = await installResult.stderr() | |
| 64 | 69 | return NextResponse.json({ |
| 65 | 70 | success: false, |
| 66 | 71 | error: `Failed to install tsx: ${installError}`, |
| 67 | 72 | exitCode: installExitCode, |
| 68 | }); | |
| 73 | }) | |
| 69 | 74 | } |
| 70 | 75 | } |
| 71 | 76 | |
| @@ -74,34 +79,42 @@ export async function POST(request: NextRequest) { | ||
| 74 | 79 | cmd: execCmd, |
| 75 | 80 | args: execArgs, |
| 76 | 81 | env: envVars, |
| 77 | }); | |
| 82 | }) | |
| 78 | 83 | |
| 79 | 84 | // Get the output |
| 80 | const exitCode = result.exitCode; | |
| 81 | output = await result.stdout(); | |
| 82 | errorOutput = await result.stderr(); | |
| 85 | const exitCode = result.exitCode | |
| 86 | output = await result.stdout() | |
| 87 | errorOutput = await result.stderr() | |
| 88 | ||
| 89 | const duration = Date.now() - startTime | |
| 90 | ||
| 91 | if (exitCode === 0) { | |
| 92 | log.info({ duration, sandboxId: sandbox.sandboxId }, "Sandbox execution successful") | |
| 93 | } else { | |
| 94 | log.warn({ duration, exitCode, sandboxId: sandbox.sandboxId }, "Sandbox execution failed") | |
| 95 | } | |
| 83 | 96 | |
| 84 | 97 | return NextResponse.json({ |
| 85 | 98 | success: exitCode === 0, |
| 86 | output: output || 'Code executed successfully', | |
| 99 | output: output || "Code executed successfully", | |
| 87 | 100 | error: errorOutput || null, |
| 88 | 101 | exitCode, |
| 89 | 102 | sandboxId: sandbox.sandboxId, |
| 90 | }); | |
| 91 | ||
| 103 | }) | |
| 92 | 104 | } finally { |
| 93 | 105 | // Clean up the sandbox |
| 94 | await sandbox.stop(); | |
| 106 | await sandbox.stop() | |
| 95 | 107 | } |
| 96 | ||
| 97 | 108 | } catch (error) { |
| 98 | console.error('Sandbox execution error:', error); | |
| 109 | const duration = Date.now() - startTime | |
| 110 | log.error({ err: error, duration }, "Sandbox execution error") | |
| 111 | ||
| 99 | 112 | return NextResponse.json( |
| 100 | 113 | { |
| 101 | error: 'Failed to execute code', | |
| 102 | details: error instanceof Error ? error.message : 'Unknown error' | |
| 114 | error: "Failed to execute code", | |
| 115 | details: error instanceof Error ? error.message : "Unknown error", | |
| 103 | 116 | }, |
| 104 | 117 | { status: 500 } |
| 105 | ); | |
| 118 | ) | |
| 106 | 119 | } |
| 107 | 120 | } |
src/app/api/sandbox/schedule/route.ts +226 −49
| @@ -1,83 +1,260 @@ | ||
| 1 | import { NextRequest, NextResponse } from 'next/server'; | |
| 2 | import { createClient } from '@/utils/supabase/server'; | |
| 1 | import { type NextRequest, NextResponse } from "next/server" | |
| 2 | import cronParser from "cron-parser" | |
| 3 | import { createClient } from "@/utils/supabase/server" | |
| 3 | 4 | |
| 4 | export const runtime = 'nodejs'; | |
| 5 | export const runtime = "nodejs" | |
| 5 | 6 | |
| 6 | // This is a simplified version - for production, you'd want to use Vercel Cron Jobs | |
| 7 | // or integrate with a service like Vercel Cron, Upstash QStash, or similar | |
| 7 | interface ScheduledFunction { | |
| 8 | id: string | |
| 9 | name: string | |
| 10 | code: string | |
| 11 | language: string | |
| 12 | cron_expression: string | |
| 13 | env_vars: Record<string, string> | |
| 14 | enabled: boolean | |
| 15 | created_at: string | |
| 16 | next_run_at: string | null | |
| 17 | last_run_at: string | null | |
| 18 | execution_count: number | |
| 19 | } | |
| 20 | ||
| 21 | // Schedule a new function | |
| 8 | 22 | export async function POST(request: NextRequest) { |
| 9 | 23 | try { |
| 10 | // Check authentication | |
| 11 | const supabase = await createClient(); | |
| 12 | const { data: { user }, error: authError } = await supabase.auth.getUser(); | |
| 24 | const supabase = await createClient() | |
| 25 | const { | |
| 26 | data: { user }, | |
| 27 | error: authError, | |
| 28 | } = await supabase.auth.getUser() | |
| 13 | 29 | |
| 14 | 30 | if (authError || !user) { |
| 15 | return NextResponse.json( | |
| 16 | { error: 'Authentication required' }, | |
| 17 | { status: 401 } | |
| 18 | ); | |
| 31 | return NextResponse.json({ error: "Authentication required" }, { status: 401 }) | |
| 19 | 32 | } |
| 20 | 33 | |
| 21 | const { code, language, cronExpression } = await request.json(); | |
| 34 | const { name, code, language, cronExpression, envVars } = await request.json() | |
| 22 | 35 | |
| 23 | if (!code || !language || !cronExpression) { | |
| 36 | if (!name || !code || !language || !cronExpression) { | |
| 24 | 37 | return NextResponse.json( |
| 25 | { error: 'Code, language, and cron expression are required' }, | |
| 38 | { error: "Name, code, language, and cron expression are required" }, | |
| 26 | 39 | { status: 400 } |
| 27 | ); | |
| 40 | ) | |
| 28 | 41 | } |
| 29 | 42 | |
| 30 | // Validate cron expression (basic validation) | |
| 31 | const cronRegex = /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/; | |
| 32 | ||
| 33 | if (!cronRegex.test(cronExpression)) { | |
| 43 | // Validate cron expression | |
| 44 | let nextRun: Date | |
| 45 | try { | |
| 46 | const interval = cronParser.parse(cronExpression) | |
| 47 | nextRun = interval.next().toDate() | |
| 48 | } catch (_error) { | |
| 34 | 49 | return NextResponse.json( |
| 35 | { error: 'Invalid cron expression format' }, | |
| 50 | { error: "Invalid cron expression format. Use standard cron format: minute hour day month weekday" }, | |
| 36 | 51 | { status: 400 } |
| 37 | ); | |
| 52 | ) | |
| 38 | 53 | } |
| 39 | 54 | |
| 40 | // In a production environment, you would: | |
| 41 | // 1. Store the function in a database with the cron schedule | |
| 42 | // 2. Use Vercel Cron Jobs (vercel.json) or a service like Upstash QStash | |
| 43 | // 3. Create a separate API endpoint that checks scheduled functions and executes them | |
| 55 | // Insert into database | |
| 56 | const { data: scheduledFunction, error: insertError } = await supabase | |
| 57 | .from("scheduled_functions") | |
| 58 | .insert({ | |
| 59 | user_id: user.id, | |
| 60 | name, | |
| 61 | code, | |
| 62 | language, | |
| 63 | cron_expression: cronExpression, | |
| 64 | env_vars: envVars || {}, | |
| 65 | next_run_at: nextRun.toISOString(), | |
| 66 | }) | |
| 67 | .select() | |
| 68 | .single() | |
| 69 | ||
| 70 | if (insertError) { | |
| 71 | return NextResponse.json( | |
| 72 | { error: "Failed to schedule function", details: insertError.message }, | |
| 73 | { status: 500 } | |
| 74 | ) | |
| 75 | } | |
| 44 | 76 | |
| 45 | // For now, we'll return a success message with instructions | |
| 46 | 77 | return NextResponse.json({ |
| 47 | 78 | success: true, |
| 48 | message: 'Function scheduled successfully', | |
| 79 | message: "Function scheduled successfully", | |
| 49 | 80 | scheduled: { |
| 50 | code: code.substring(0, 100) + '...', // Truncate for response | |
| 51 | language, | |
| 81 | id: scheduledFunction.id, | |
| 82 | name: scheduledFunction.name, | |
| 52 | 83 | cronExpression, |
| 53 | nextRun: calculateNextRun(cronExpression), | |
| 54 | note: 'This is a demo implementation. In production, integrate with Vercel Cron Jobs or a scheduling service.', | |
| 84 | nextRun: nextRun.toISOString(), | |
| 85 | language, | |
| 55 | 86 | }, |
| 56 | }); | |
| 57 | ||
| 87 | }) | |
| 58 | 88 | } catch (error) { |
| 59 | console.error('Scheduling error:', error); | |
| 60 | 89 | return NextResponse.json( |
| 61 | 90 | { |
| 62 | error: 'Failed to schedule function', | |
| 63 | details: error instanceof Error ? error.message : 'Unknown error' | |
| 91 | error: "Failed to schedule function", | |
| 92 | details: error instanceof Error ? error.message : "Unknown error", | |
| 64 | 93 | }, |
| 65 | 94 | { status: 500 } |
| 66 | ); | |
| 95 | ) | |
| 67 | 96 | } |
| 68 | 97 | } |
| 69 | 98 | |
| 70 | // Helper function to calculate next run time (simplified) | |
| 71 | function calculateNextRun(cronExpression: string): string { | |
| 72 | // This is a simplified version - in production, use a library like 'cron-parser' | |
| 73 | const now = new Date(); | |
| 74 | const nextRun = new Date(now.getTime() + 60 * 60 * 1000); // Add 1 hour as example | |
| 75 | return nextRun.toISOString(); | |
| 99 | // Get all scheduled functions for the current user | |
| 100 | export async function GET() { | |
| 101 | try { | |
| 102 | const supabase = await createClient() | |
| 103 | const { | |
| 104 | data: { user }, | |
| 105 | error: authError, | |
| 106 | } = await supabase.auth.getUser() | |
| 107 | ||
| 108 | if (authError || !user) { | |
| 109 | return NextResponse.json({ error: "Authentication required" }, { status: 401 }) | |
| 110 | } | |
| 111 | ||
| 112 | const { data: functions, error: fetchError } = await supabase | |
| 113 | .from("scheduled_functions") | |
| 114 | .select("*") | |
| 115 | .eq("user_id", user.id) | |
| 116 | .order("created_at", { ascending: false }) | |
| 117 | ||
| 118 | if (fetchError) { | |
| 119 | return NextResponse.json( | |
| 120 | { error: "Failed to fetch scheduled functions", details: fetchError.message }, | |
| 121 | { status: 500 } | |
| 122 | ) | |
| 123 | } | |
| 124 | ||
| 125 | return NextResponse.json({ | |
| 126 | functions: functions || [], | |
| 127 | }) | |
| 128 | } catch (error) { | |
| 129 | return NextResponse.json( | |
| 130 | { | |
| 131 | error: "Failed to fetch scheduled functions", | |
| 132 | details: error instanceof Error ? error.message : "Unknown error", | |
| 133 | }, | |
| 134 | { status: 500 } | |
| 135 | ) | |
| 136 | } | |
| 76 | 137 | } |
| 77 | 138 | |
| 78 | export async function GET() { | |
| 79 | return NextResponse.json({ | |
| 80 | message: 'Scheduled functions endpoint', | |
| 81 | note: 'Use POST to schedule a new function', | |
| 82 | }); | |
| 139 | // Update a scheduled function (enable/disable, update code, etc.) | |
| 140 | export async function PATCH(request: NextRequest) { | |
| 141 | try { | |
| 142 | const supabase = await createClient() | |
| 143 | const { | |
| 144 | data: { user }, | |
| 145 | error: authError, | |
| 146 | } = await supabase.auth.getUser() | |
| 147 | ||
| 148 | if (authError || !user) { | |
| 149 | return NextResponse.json({ error: "Authentication required" }, { status: 401 }) | |
| 150 | } | |
| 151 | ||
| 152 | const { id, enabled, name, code, language, cronExpression, envVars } = await request.json() | |
| 153 | ||
| 154 | if (!id) { | |
| 155 | return NextResponse.json({ error: "Function ID is required" }, { status: 400 }) | |
| 156 | } | |
| 157 | ||
| 158 | const updates: Partial<ScheduledFunction> = {} | |
| 159 | ||
| 160 | if (typeof enabled === "boolean") { | |
| 161 | updates.enabled = enabled | |
| 162 | } | |
| 163 | ||
| 164 | if (name) updates.name = name | |
| 165 | if (code) updates.code = code | |
| 166 | if (language) updates.language = language | |
| 167 | if (envVars) updates.env_vars = envVars | |
| 168 | ||
| 169 | if (cronExpression) { | |
| 170 | try { | |
| 171 | const interval = cronParser.parse(cronExpression) | |
| 172 | const nextRun = interval.next().toDate() | |
| 173 | updates.cron_expression = cronExpression | |
| 174 | updates.next_run_at = nextRun.toISOString() | |
| 175 | } catch (_error) { | |
| 176 | return NextResponse.json({ error: "Invalid cron expression format" }, { status: 400 }) | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | const { data: updatedFunction, error: updateError } = await supabase | |
| 181 | .from("scheduled_functions") | |
| 182 | .update(updates) | |
| 183 | .eq("id", id) | |
| 184 | .eq("user_id", user.id) // Ensure user owns this function | |
| 185 | .select() | |
| 186 | .single() | |
| 187 | ||
| 188 | if (updateError) { | |
| 189 | return NextResponse.json( | |
| 190 | { error: "Failed to update function", details: updateError.message }, | |
| 191 | { status: 500 } | |
| 192 | ) | |
| 193 | } | |
| 194 | ||
| 195 | if (!updatedFunction) { | |
| 196 | return NextResponse.json({ error: "Function not found or access denied" }, { status: 404 }) | |
| 197 | } | |
| 198 | ||
| 199 | return NextResponse.json({ | |
| 200 | success: true, | |
| 201 | function: updatedFunction, | |
| 202 | }) | |
| 203 | } catch (error) { | |
| 204 | return NextResponse.json( | |
| 205 | { | |
| 206 | error: "Failed to update function", | |
| 207 | details: error instanceof Error ? error.message : "Unknown error", | |
| 208 | }, | |
| 209 | { status: 500 } | |
| 210 | ) | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | // Delete a scheduled function | |
| 215 | export async function DELETE(request: NextRequest) { | |
| 216 | try { | |
| 217 | const supabase = await createClient() | |
| 218 | const { | |
| 219 | data: { user }, | |
| 220 | error: authError, | |
| 221 | } = await supabase.auth.getUser() | |
| 222 | ||
| 223 | if (authError || !user) { | |
| 224 | return NextResponse.json({ error: "Authentication required" }, { status: 401 }) | |
| 225 | } | |
| 226 | ||
| 227 | const { searchParams } = new URL(request.url) | |
| 228 | const id = searchParams.get("id") | |
| 229 | ||
| 230 | if (!id) { | |
| 231 | return NextResponse.json({ error: "Function ID is required" }, { status: 400 }) | |
| 232 | } | |
| 233 | ||
| 234 | const { error: deleteError } = await supabase | |
| 235 | .from("scheduled_functions") | |
| 236 | .delete() | |
| 237 | .eq("id", id) | |
| 238 | .eq("user_id", user.id) // Ensure user owns this function | |
| 239 | ||
| 240 | if (deleteError) { | |
| 241 | return NextResponse.json( | |
| 242 | { error: "Failed to delete function", details: deleteError.message }, | |
| 243 | { status: 500 } | |
| 244 | ) | |
| 245 | } | |
| 246 | ||
| 247 | return NextResponse.json({ | |
| 248 | success: true, | |
| 249 | message: "Function deleted successfully", | |
| 250 | }) | |
| 251 | } catch (error) { | |
| 252 | return NextResponse.json( | |
| 253 | { | |
| 254 | error: "Failed to delete function", | |
| 255 | details: error instanceof Error ? error.message : "Unknown error", | |
| 256 | }, | |
| 257 | { status: 500 } | |
| 258 | ) | |
| 259 | } | |
| 83 | 260 | } |
src/app/auth/sign-in/page.tsx +39 −43
| @@ -1,16 +1,16 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import { useState } from 'react' | |
| 4 | import { useRouter } from 'next/navigation' | |
| 5 | import { createClient } from '@/utils/supabase/client' | |
| 6 | import { Card } from '@/components/ui/card' | |
| 7 | import { Button } from '@/components/ui/button' | |
| 8 | import { toast } from '@/hooks/use-toast' | |
| 9 | import { Mail, KeyRound } from 'lucide-react' | |
| 3 | import { KeyRound, Mail } from "lucide-react" | |
| 4 | import { useRouter } from "next/navigation" | |
| 5 | import { useState } from "react" | |
| 6 | import { Button } from "@/components/ui/button" | |
| 7 | import { Card } from "@/components/ui/card" | |
| 8 | import { toast } from "@/hooks/use-toast" | |
| 9 | import { createClient } from "@/utils/supabase/client" | |
| 10 | 10 | |
| 11 | 11 | export default function AuthPage() { |
| 12 | const [email, setEmail] = useState('') | |
| 13 | const [otp, setOtp] = useState('') | |
| 12 | const [email, setEmail] = useState("") | |
| 13 | const [otp, setOtp] = useState("") | |
| 14 | 14 | const [isLoading, setIsLoading] = useState(false) |
| 15 | 15 | const [isVerifying, setIsVerifying] = useState(false) |
| 16 | 16 | const [emailSent, setEmailSent] = useState(false) |
| @@ -31,22 +31,22 @@ export default function AuthPage() { | ||
| 31 | 31 | |
| 32 | 32 | if (error) { |
| 33 | 33 | toast({ |
| 34 | title: 'Error', | |
| 34 | title: "Error", | |
| 35 | 35 | description: error.message, |
| 36 | variant: 'destructive', | |
| 36 | variant: "destructive", | |
| 37 | 37 | }) |
| 38 | 38 | } else { |
| 39 | 39 | setEmailSent(true) |
| 40 | 40 | toast({ |
| 41 | title: 'Check your email', | |
| 42 | description: 'We sent you a magic link and a 6-digit code', | |
| 41 | title: "Check your email", | |
| 42 | description: "We sent you a magic link and a 6-digit code", | |
| 43 | 43 | }) |
| 44 | 44 | } |
| 45 | } catch (error) { | |
| 45 | } catch (_error) { | |
| 46 | 46 | toast({ |
| 47 | title: 'Error', | |
| 48 | description: 'Something went wrong', | |
| 49 | variant: 'destructive', | |
| 47 | title: "Error", | |
| 48 | description: "Something went wrong", | |
| 49 | variant: "destructive", | |
| 50 | 50 | }) |
| 51 | 51 | } finally { |
| 52 | 52 | setIsLoading(false) |
| @@ -58,9 +58,9 @@ export default function AuthPage() { | ||
| 58 | 58 | |
| 59 | 59 | if (otp.length !== 6) { |
| 60 | 60 | toast({ |
| 61 | title: 'Invalid code', | |
| 62 | description: 'Please enter a 6-digit code', | |
| 63 | variant: 'destructive', | |
| 61 | title: "Invalid code", | |
| 62 | description: "Please enter a 6-digit code", | |
| 63 | variant: "destructive", | |
| 64 | 64 | }) |
| 65 | 65 | return |
| 66 | 66 | } |
| @@ -71,27 +71,27 @@ export default function AuthPage() { | ||
| 71 | 71 | const { error } = await supabase.auth.verifyOtp({ |
| 72 | 72 | email, |
| 73 | 73 | token: otp, |
| 74 | type: 'email', | |
| 74 | type: "email", | |
| 75 | 75 | }) |
| 76 | 76 | |
| 77 | 77 | if (error) { |
| 78 | 78 | toast({ |
| 79 | title: 'Verification failed', | |
| 79 | title: "Verification failed", | |
| 80 | 80 | description: error.message, |
| 81 | variant: 'destructive', | |
| 81 | variant: "destructive", | |
| 82 | 82 | }) |
| 83 | 83 | } else { |
| 84 | 84 | toast({ |
| 85 | title: 'Success', | |
| 86 | description: 'You are now signed in', | |
| 85 | title: "Success", | |
| 86 | description: "You are now signed in", | |
| 87 | 87 | }) |
| 88 | router.push('/playground/function-creator') | |
| 88 | router.push("/playground/function-creator") | |
| 89 | 89 | } |
| 90 | } catch (error) { | |
| 90 | } catch (_error) { | |
| 91 | 91 | toast({ |
| 92 | title: 'Error', | |
| 93 | description: 'Something went wrong', | |
| 94 | variant: 'destructive', | |
| 92 | title: "Error", | |
| 93 | description: "Something went wrong", | |
| 94 | variant: "destructive", | |
| 95 | 95 | }) |
| 96 | 96 | } finally { |
| 97 | 97 | setIsVerifying(false) |
| @@ -104,9 +104,7 @@ export default function AuthPage() { | ||
| 104 | 104 | <div className="space-y-6"> |
| 105 | 105 | <div className="text-center space-y-2"> |
| 106 | 106 | <h1 className="text-3xl font-bold text-white">Sign In</h1> |
| 107 | <p className="text-gray-300"> | |
| 108 | Enter your email to access the Function Creator | |
| 109 | </p> | |
| 107 | <p className="text-gray-300">Enter your email to access the Function Creator</p> | |
| 110 | 108 | </div> |
| 111 | 109 | |
| 112 | 110 | {!emailSent ? ( |
| @@ -132,15 +130,13 @@ export default function AuthPage() { | ||
| 132 | 130 | className="w-full bg-blue-600 hover:bg-blue-700" |
| 133 | 131 | > |
| 134 | 132 | <Mail className="w-4 h-4 mr-2" /> |
| 135 | {isLoading ? 'Sending...' : 'Send Magic Link'} | |
| 133 | {isLoading ? "Sending..." : "Send Magic Link"} | |
| 136 | 134 | </Button> |
| 137 | 135 | </form> |
| 138 | 136 | ) : ( |
| 139 | 137 | <div className="space-y-4"> |
| 140 | 138 | <div className="bg-green-500/10 border border-green-500/30 rounded p-4 space-y-2"> |
| 141 | <p className="text-green-400 font-medium"> | |
| 142 | Check your email! | |
| 143 | </p> | |
| 139 | <p className="text-green-400 font-medium">Check your email!</p> | |
| 144 | 140 | <p className="text-sm text-gray-300"> |
| 145 | 141 | We sent you a magic link and a 6-digit code to <strong>{email}</strong> |
| 146 | 142 | </p> |
| @@ -164,7 +160,7 @@ export default function AuthPage() { | ||
| 164 | 160 | id="otp" |
| 165 | 161 | type="text" |
| 166 | 162 | value={otp} |
| 167 | onChange={(e) => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))} | |
| 163 | onChange={(e) => setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))} | |
| 168 | 164 | placeholder="000000" |
| 169 | 165 | maxLength={6} |
| 170 | 166 | className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 text-center text-2xl tracking-widest" |
| @@ -177,14 +173,14 @@ export default function AuthPage() { | ||
| 177 | 173 | className="w-full bg-blue-600 hover:bg-blue-700" |
| 178 | 174 | > |
| 179 | 175 | <KeyRound className="w-4 h-4 mr-2" /> |
| 180 | {isVerifying ? 'Verifying...' : 'Verify Code'} | |
| 176 | {isVerifying ? "Verifying..." : "Verify Code"} | |
| 181 | 177 | </Button> |
| 182 | 178 | </form> |
| 183 | 179 | |
| 184 | 180 | <Button |
| 185 | 181 | onClick={() => { |
| 186 | 182 | setEmailSent(false) |
| 187 | setOtp('') | |
| 183 | setOtp("") | |
| 188 | 184 | }} |
| 189 | 185 | variant="outline" |
| 190 | 186 | className="w-full" |
| @@ -196,8 +192,8 @@ export default function AuthPage() { | ||
| 196 | 192 | |
| 197 | 193 | <div className="text-xs text-gray-400 text-center"> |
| 198 | 194 | <p> |
| 199 | Authentication is required to prevent abuse and manage rate limits. | |
| 200 | No password needed - we will send you a magic link and code. | |
| 195 | Authentication is required to prevent abuse and manage rate limits. No password needed | |
| 196 | - we will send you a magic link and code. | |
| 201 | 197 | </p> |
| 202 | 198 | </div> |
| 203 | 199 | </div> |
src/app/layout.tsx +20 −19
| @@ -1,14 +1,15 @@ | ||
| 1 | import type { Metadata } from "next"; | |
| 2 | import { Inter } from "next/font/google"; | |
| 3 | import "./globals.css"; | |
| 4 | import { SpaceBackground } from "@/components/SpaceBackground"; | |
| 5 | import { FloatingNavigation } from "@/components/FloatingNavigation"; | |
| 6 | import { InteractiveBackground } from "@/components/InteractiveBackground"; | |
| 7 | import { Toaster } from "@/components/ui/toaster"; | |
| 8 | import { Trevato } from "@/components/Trevato"; | |
| 9 | import { Analytics } from "@vercel/analytics/react"; | |
| 1 | import type { Metadata } from "next" | |
| 2 | import { Inter } from "next/font/google" | |
| 3 | import "./globals.css" | |
| 4 | import { Analytics } from "@vercel/analytics/react" | |
| 5 | import { ClientErrorBoundary } from "@/components/ClientErrorBoundary" | |
| 6 | import { FloatingNavigation } from "@/components/FloatingNavigation" | |
| 7 | import { InteractiveBackground } from "@/components/InteractiveBackground" | |
| 8 | import { SpaceBackground } from "@/components/SpaceBackground" | |
| 9 | import { Trevato } from "@/components/Trevato" | |
| 10 | import { Toaster } from "@/components/ui/toaster" | |
| 10 | 11 | |
| 11 | const inter = Inter({ subsets: ["latin"] }); | |
| 12 | const inter = Inter({ subsets: ["latin"] }) | |
| 12 | 13 | |
| 13 | 14 | export const metadata: Metadata = { |
| 14 | 15 | title: { |
| @@ -18,7 +19,8 @@ export const metadata: Metadata = { | ||
| 18 | 19 | description: "Building systems from first principles. Working with people who know their domain.", |
| 19 | 20 | openGraph: { |
| 20 | 21 | title: "trevato.dev", |
| 21 | description: "Building systems from first principles. Working with people who know their domain.", | |
| 22 | description: | |
| 23 | "Building systems from first principles. Working with people who know their domain.", | |
| 22 | 24 | url: "https://trevato.dev", |
| 23 | 25 | siteName: "trevato.dev", |
| 24 | 26 | images: [ |
| @@ -46,31 +48,30 @@ export const metadata: Metadata = { | ||
| 46 | 48 | title: "trevordobbertin", |
| 47 | 49 | card: "summary_large_image", |
| 48 | 50 | }, |
| 49 | }; | |
| 51 | } | |
| 50 | 52 | |
| 51 | 53 | export default function RootLayout({ |
| 52 | 54 | children, |
| 53 | 55 | }: Readonly<{ |
| 54 | children: React.ReactNode; | |
| 56 | children: React.ReactNode | |
| 55 | 57 | }>) { |
| 56 | 58 | return ( |
| 57 | 59 | <html lang="en"> |
| 58 | 60 | <head> |
| 59 | <meta | |
| 60 | name="viewport" | |
| 61 | content="width=device-width, initial-scale=1, viewport-fit=cover" | |
| 62 | /> | |
| 61 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| 63 | 62 | </head> |
| 64 | 63 | <body className={`${inter.className} bg-black text-white`}> |
| 65 | 64 | <SpaceBackground> |
| 66 | 65 | <InteractiveBackground /> |
| 67 | 66 | <FloatingNavigation /> |
| 68 | <main className="min-h-screen p-4 md:p-8">{children}</main> | |
| 67 | <main className="min-h-screen p-4 md:p-8"> | |
| 68 | <ClientErrorBoundary>{children}</ClientErrorBoundary> | |
| 69 | </main> | |
| 69 | 70 | <Trevato /> |
| 70 | 71 | </SpaceBackground> |
| 71 | 72 | <Toaster /> |
| 72 | 73 | <Analytics /> |
| 73 | 74 | </body> |
| 74 | 75 | </html> |
| 75 | ); | |
| 76 | ) | |
| 76 | 77 | } |
src/app/page.tsx +2 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | import { Contributions } from "@/components/Contributions" | |
| 1 | 2 | import { HeroSection } from "@/components/HeroSection" |
| 2 | import { ServicesOverview } from "@/components/ServicesOverview" | |
| 3 | 3 | import { Me } from "@/components/Me" |
| 4 | import { Contributions } from '@/components/Contributions' | |
| 4 | import { ServicesOverview } from "@/components/ServicesOverview" | |
| 5 | 5 | |
| 6 | 6 | export default function Home() { |
| 7 | 7 | return ( |
src/app/playground/function-creator/page.tsx +25 −20
| @@ -1,11 +1,13 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import { useEffect, useState } from 'react' | |
| 4 | import { createClient } from '@/utils/supabase/client' | |
| 5 | import { FunctionCreator } from '@/components/playground/FunctionCreator' | |
| 6 | import { Card } from '@/components/ui/card' | |
| 7 | import { Button } from '@/components/ui/button' | |
| 8 | import Link from 'next/link' | |
| 3 | import Link from "next/link" | |
| 4 | import { useEffect, useState } from "react" | |
| 5 | import { ErrorBoundary } from "@/components/ErrorBoundary" | |
| 6 | import { FunctionCreator } from "@/components/playground/FunctionCreator" | |
| 7 | import { Button } from "@/components/ui/button" | |
| 8 | import { Card } from "@/components/ui/card" | |
| 9 | import { Spinner } from "@/components/ui/spinner" | |
| 10 | import { createClient } from "@/utils/supabase/client" | |
| 9 | 11 | |
| 10 | 12 | export default function FunctionCreatorPage() { |
| 11 | 13 | const [isAuthenticated, setIsAuthenticated] = useState(false) |
| @@ -14,7 +16,9 @@ export default function FunctionCreatorPage() { | ||
| 14 | 16 | |
| 15 | 17 | useEffect(() => { |
| 16 | 18 | const checkAuth = async () => { |
| 17 | const { data: { user } } = await supabase.auth.getUser() | |
| 19 | const { | |
| 20 | data: { user }, | |
| 21 | } = await supabase.auth.getUser() | |
| 18 | 22 | setIsAuthenticated(!!user) |
| 19 | 23 | setIsLoading(false) |
| 20 | 24 | } |
| @@ -22,7 +26,9 @@ export default function FunctionCreatorPage() { | ||
| 22 | 26 | checkAuth() |
| 23 | 27 | |
| 24 | 28 | // Listen for auth changes |
| 25 | const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { | |
| 29 | const { | |
| 30 | data: { subscription }, | |
| 31 | } = supabase.auth.onAuthStateChange((_event, session) => { | |
| 26 | 32 | setIsAuthenticated(!!session) |
| 27 | 33 | }) |
| 28 | 34 | |
| @@ -31,8 +37,9 @@ export default function FunctionCreatorPage() { | ||
| 31 | 37 | |
| 32 | 38 | if (isLoading) { |
| 33 | 39 | return ( |
| 34 | <div className="container mx-auto px-4 flex items-center justify-center min-h-[80vh]"> | |
| 35 | <div className="text-white">Loading...</div> | |
| 40 | <div className="container mx-auto px-4 flex flex-col items-center justify-center min-h-[80vh] gap-4"> | |
| 41 | <Spinner size="lg" className="text-blue-500" /> | |
| 42 | <p className="text-gray-400">Checking authentication...</p> | |
| 36 | 43 | </div> |
| 37 | 44 | ) |
| 38 | 45 | } |
| @@ -44,20 +51,16 @@ export default function FunctionCreatorPage() { | ||
| 44 | 51 | <div className="space-y-6 text-center"> |
| 45 | 52 | <div className="space-y-2"> |
| 46 | 53 | <h1 className="text-3xl font-bold text-white">Authentication Required</h1> |
| 47 | <p className="text-gray-300"> | |
| 48 | Sign in to access the Function Creator | |
| 49 | </p> | |
| 54 | <p className="text-gray-300">Sign in to access the Function Creator</p> | |
| 50 | 55 | </div> |
| 51 | 56 | <div className="text-sm text-gray-400"> |
| 52 | 57 | <p> |
| 53 | We require authentication to prevent abuse and manage rate limits. | |
| 54 | This feature uses computational resources that need to be monitored. | |
| 58 | We require authentication to prevent abuse and manage rate limits. This feature uses | |
| 59 | computational resources that need to be monitored. | |
| 55 | 60 | </p> |
| 56 | 61 | </div> |
| 57 | 62 | <Link href="/auth/sign-in"> |
| 58 | <Button className="w-full bg-blue-600 hover:bg-blue-700"> | |
| 59 | Sign In | |
| 60 | </Button> | |
| 63 | <Button className="w-full bg-blue-600 hover:bg-blue-700">Sign In</Button> | |
| 61 | 64 | </Link> |
| 62 | 65 | </div> |
| 63 | 66 | </Card> |
| @@ -73,7 +76,9 @@ export default function FunctionCreatorPage() { | ||
| 73 | 76 | </p> |
| 74 | 77 | <div className="max-w-[95vw] sm:max-w-4xl mx-auto"> |
| 75 | 78 | <div className="bg-gray-900 bg-opacity-40 p-4 rounded-lg backdrop-filter backdrop-blur-xl"> |
| 76 | <FunctionCreator /> | |
| 79 | <ErrorBoundary> | |
| 80 | <FunctionCreator /> | |
| 81 | </ErrorBoundary> | |
| 77 | 82 | </div> |
| 78 | 83 | </div> |
| 79 | 84 | </div> |
src/app/playground/page.tsx +14 −12
| @@ -1,25 +1,25 @@ | ||
| 1 | import Link from 'next/link' | |
| 1 | import { Box, CodeIcon, Palette } from "lucide-react" | |
| 2 | import Link from "next/link" | |
| 2 | 3 | import { Card } from "@/components/ui/card" |
| 3 | import { CodeIcon, Box, Palette } from 'lucide-react' | |
| 4 | 4 | |
| 5 | 5 | export default function PlaygroundPage() { |
| 6 | 6 | const tools = [ |
| 7 | 7 | { |
| 8 | href: '/playground/function-creator', | |
| 9 | title: 'Function Creator', | |
| 10 | description: 'Write and execute TypeScript or Python code in a secure environment', | |
| 8 | href: "/playground/function-creator", | |
| 9 | title: "Function Creator", | |
| 10 | description: "Write and execute TypeScript or Python code in a secure environment", | |
| 11 | 11 | icon: CodeIcon, |
| 12 | 12 | }, |
| 13 | 13 | { |
| 14 | href: '/playground/3d-viewer', | |
| 15 | title: '3D Object Viewer', | |
| 16 | description: 'Interactive 3D model viewer with orbit controls', | |
| 14 | href: "/playground/3d-viewer", | |
| 15 | title: "3D Object Viewer", | |
| 16 | description: "Interactive 3D model viewer with orbit controls", | |
| 17 | 17 | icon: Box, |
| 18 | 18 | }, |
| 19 | 19 | { |
| 20 | href: '/playground/color-picker', | |
| 21 | title: 'Color Picker', | |
| 22 | description: 'Select and preview colors', | |
| 20 | href: "/playground/color-picker", | |
| 21 | title: "Color Picker", | |
| 22 | description: "Select and preview colors", | |
| 23 | 23 | icon: Palette, |
| 24 | 24 | }, |
| 25 | 25 | ] |
| @@ -27,7 +27,9 @@ export default function PlaygroundPage() { | ||
| 27 | 27 | return ( |
| 28 | 28 | <div className="container mx-auto px-4"> |
| 29 | 29 | <h1 className="text-4xl font-bold mb-2 text-center text-white">Playground</h1> |
| 30 | <p className="text-lg text-gray-300 mb-8 text-center">Experiments and tools worth keeping around</p> | |
| 30 | <p className="text-lg text-gray-300 mb-8 text-center"> | |
| 31 | Experiments and tools worth keeping around | |
| 32 | </p> | |
| 31 | 33 | |
| 32 | 34 | <div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-4"> |
| 33 | 35 | {tools.map((tool) => ( |
src/app/projects/[project]/page.tsx +28 −19
| @@ -1,25 +1,25 @@ | ||
| 1 | import { GitHubProject } from "@/components/GitHubProject"; | |
| 2 | import { Octokit, RequestError } from "octokit"; | |
| 3 | import { projectRepoMap } from "@/lib/projects"; | |
| 4 | import Link from 'next/link'; | |
| 1 | import Link from "next/link" | |
| 2 | import { Octokit, RequestError } from "octokit" | |
| 3 | import { GitHubProject } from "@/components/GitHubProject" | |
| 4 | import { projectRepoMap } from "@/lib/projects" | |
| 5 | 5 | |
| 6 | 6 | export default async function Page({ params }: { params: { project: string } }) { |
| 7 | const repoName = projectRepoMap[params.project]; | |
| 7 | const repoName = projectRepoMap[params.project] | |
| 8 | 8 | |
| 9 | 9 | if (!repoName) { |
| 10 | return <div>Project not found</div>; | |
| 10 | return <div>Project not found</div> | |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); | |
| 13 | const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }) | |
| 14 | 14 | |
| 15 | 15 | try { |
| 16 | const [owner, repo] = repoName.split('/'); | |
| 16 | const [owner, repo] = repoName.split("/") | |
| 17 | 17 | const [repoData, readmeData] = await Promise.all([ |
| 18 | 18 | octokit.rest.repos.get({ owner, repo }), |
| 19 | octokit.rest.repos.getReadme({ owner, repo }) | |
| 20 | ]); | |
| 19 | octokit.rest.repos.getReadme({ owner, repo }), | |
| 20 | ]) | |
| 21 | 21 | |
| 22 | const readme = Buffer.from(readmeData.data.content, 'base64').toString('utf-8'); | |
| 22 | const readme = Buffer.from(readmeData.data.content, "base64").toString("utf-8") | |
| 23 | 23 | |
| 24 | 24 | return ( |
| 25 | 25 | <> |
| @@ -27,8 +27,17 @@ export default async function Page({ params }: { params: { project: string } }) | ||
| 27 | 27 | <div className="container mx-auto px-4"> |
| 28 | 28 | <Link href="/projects"> |
| 29 | 29 | <button className="px-4 py-2 rounded-lg backdrop-blur-sm bg-transparent hover:bg-white/10 transition-all duration-300 border border-white/20 hover:border-white/40 text-white/80 hover:text-white flex items-center space-x-2"> |
| 30 | <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> | |
| 31 | <path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" /> | |
| 30 | <svg | |
| 31 | xmlns="http://www.w3.org/2000/svg" | |
| 32 | className="h-5 w-5" | |
| 33 | viewBox="0 0 20 20" | |
| 34 | fill="currentColor" | |
| 35 | > | |
| 36 | <path | |
| 37 | fillRule="evenodd" | |
| 38 | d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" | |
| 39 | clipRule="evenodd" | |
| 40 | /> | |
| 32 | 41 | </svg> |
| 33 | 42 | <span>Back to Projects</span> |
| 34 | 43 | </button> |
| @@ -39,24 +48,24 @@ export default async function Page({ params }: { params: { project: string } }) | ||
| 39 | 48 | <div className="space-y-24"> |
| 40 | 49 | <GitHubProject |
| 41 | 50 | repoName={repoData.data.name} |
| 42 | repoDescription={repoData.data.description ?? ''} | |
| 51 | repoDescription={repoData.data.description ?? ""} | |
| 43 | 52 | repoUrl={repoData.data.html_url} |
| 44 | 53 | stars={repoData.data.stargazers_count} |
| 45 | 54 | forks={repoData.data.forks_count} |
| 46 | 55 | watchers={repoData.data.watchers_count} |
| 47 | language={repoData.data.language ?? ''} | |
| 56 | language={repoData.data.language ?? ""} | |
| 48 | 57 | readme={readme} |
| 49 | 58 | /> |
| 50 | 59 | </div> |
| 51 | 60 | </div> |
| 52 | 61 | </> |
| 53 | ); | |
| 62 | ) | |
| 54 | 63 | } catch (error) { |
| 55 | 64 | if (error instanceof RequestError) { |
| 56 | console.error('Error fetching project data:', error.message, 'Status:', error.status); | |
| 65 | console.error("Error fetching project data:", error.message, "Status:", error.status) | |
| 57 | 66 | } else { |
| 58 | console.error('Unexpected error:', error); | |
| 67 | console.error("Unexpected error:", error) | |
| 59 | 68 | } |
| 60 | return <div>Failed to fetch project data</div>; | |
| 69 | return <div>Failed to fetch project data</div> | |
| 61 | 70 | } |
| 62 | 71 | } |
src/app/projects/page.tsx +51 −47
| @@ -1,52 +1,56 @@ | ||
| 1 | import { projectRepoMap } from "@/lib/projects"; | |
| 2 | import { Card } from "@/components/ui/card"; | |
| 3 | import Link from "next/link"; | |
| 4 | import { Octokit } from "octokit"; | |
| 1 | import Link from "next/link" | |
| 2 | import { Octokit } from "octokit" | |
| 3 | import { Card } from "@/components/ui/card" | |
| 4 | import { projectRepoMap } from "@/lib/projects" | |
| 5 | 5 | |
| 6 | 6 | export default async function Page() { |
| 7 | const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); | |
| 7 | const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }) | |
| 8 | 8 | |
| 9 | const projectsData = await Promise.all( | |
| 10 | Object.entries(projectRepoMap).map(async ([key, value]) => { | |
| 11 | try { | |
| 12 | const [owner, repo] = value.split('/'); | |
| 13 | const { data } = await octokit.rest.repos.get({ owner, repo }); | |
| 14 | return { key, value, description: data.description }; | |
| 15 | } catch (error) { | |
| 16 | console.error(`Error fetching data for ${key}:`, error); | |
| 17 | return { key, value, description: 'A fascinating project' }; | |
| 18 | } | |
| 19 | }) | |
| 20 | ); | |
| 9 | const projectsData = await Promise.all( | |
| 10 | Object.entries(projectRepoMap).map(async ([key, value]) => { | |
| 11 | try { | |
| 12 | const [owner, repo] = value.split("/") | |
| 13 | const { data } = await octokit.rest.repos.get({ owner, repo }) | |
| 14 | return { key, value, description: data.description } | |
| 15 | } catch (error) { | |
| 16 | console.error(`Error fetching data for ${key}:`, error) | |
| 17 | return { key, value, description: "A fascinating project" } | |
| 18 | } | |
| 19 | }) | |
| 20 | ) | |
| 21 | 21 | |
| 22 | return ( | |
| 23 | <div className="flex flex-col space-y-8 p-6"> | |
| 24 | <header className="text-center"> | |
| 25 | <h1 className="text-3xl font-bold text-gray-100 mb-4">My Side Projects</h1> | |
| 26 | <p className="text-lg text-gray-300 max-w-2xl mx-auto"> | |
| 27 | Side projects and experiments. Things I thought should exist, so I built them to see if they could. | |
| 28 | </p> | |
| 29 | </header> | |
| 30 | <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6"> | |
| 31 | {projectsData.map(({ key, value, description }) => ( | |
| 32 | <Card key={key} className="group overflow-hidden rounded-lg backdrop-blur-sm bg-black/10 hover:bg-black/20 transition-all duration-300 shadow-lg hover:shadow-xl transform hover:scale-105"> | |
| 33 | <Link href={`/projects/${key}`} className="block p-6"> | |
| 34 | <div className="flex items-center mb-2"> | |
| 35 | <span className="text-2xl mr-2" role="img" aria-label="Project icon"> | |
| 36 | 💡 | |
| 37 | </span> | |
| 38 | <h3 className="text-lg font-semibold text-gray-200 group-hover:text-white transition-colors duration-300"> | |
| 39 | {key} | |
| 40 | </h3> | |
| 41 | </div> | |
| 42 | <p className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors duration-300 mb-4"> | |
| 43 | {description} | |
| 44 | </p> | |
| 45 | <div className="w-12 h-1 bg-blue-500 group-hover:w-full transition-all duration-500 ease-in-out"></div> | |
| 46 | </Link> | |
| 47 | </Card> | |
| 48 | ))} | |
| 49 | </div> | |
| 50 | </div> | |
| 51 | ); | |
| 22 | return ( | |
| 23 | <div className="flex flex-col space-y-8 p-6"> | |
| 24 | <header className="text-center"> | |
| 25 | <h1 className="text-3xl font-bold text-gray-100 mb-4">My Side Projects</h1> | |
| 26 | <p className="text-lg text-gray-300 max-w-2xl mx-auto"> | |
| 27 | Side projects and experiments. Things I thought should exist, so I built them to see if | |
| 28 | they could. | |
| 29 | </p> | |
| 30 | </header> | |
| 31 | <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6"> | |
| 32 | {projectsData.map(({ key, value, description }) => ( | |
| 33 | <Card | |
| 34 | key={key} | |
| 35 | className="group overflow-hidden rounded-lg backdrop-blur-sm bg-black/10 hover:bg-black/20 transition-all duration-300 shadow-lg hover:shadow-xl transform hover:scale-105" | |
| 36 | > | |
| 37 | <Link href={`/projects/${key}`} className="block p-6"> | |
| 38 | <div className="flex items-center mb-2"> | |
| 39 | <span className="text-2xl mr-2" role="img" aria-label="Project icon"> | |
| 40 | 💡 | |
| 41 | </span> | |
| 42 | <h3 className="text-lg font-semibold text-gray-200 group-hover:text-white transition-colors duration-300"> | |
| 43 | {key} | |
| 44 | </h3> | |
| 45 | </div> | |
| 46 | <p className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors duration-300 mb-4"> | |
| 47 | {description} | |
| 48 | </p> | |
| 49 | <div className="w-12 h-1 bg-blue-500 group-hover:w-full transition-all duration-500 ease-in-out"></div> | |
| 50 | </Link> | |
| 51 | </Card> | |
| 52 | ))} | |
| 53 | </div> | |
| 54 | </div> | |
| 55 | ) | |
| 52 | 56 | } |
src/app/supersecretcalendar/page.tsx +31 −8
| @@ -1,15 +1,38 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React from 'react' | |
| 4 | import { motion } from 'framer-motion' | |
| 5 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' | |
| 6 | 3 | import Cal from "@calcom/embed-react" |
| 4 | import { motion } from "framer-motion" | |
| 5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" | |
| 7 | 6 | |
| 8 | 7 | const services = [ |
| 9 | { id: 1, title: 'Personal workflow optimization', description: 'I\'ve been a remote software developer for 5 years. I led the development of a custom Enterprise Resource Planning system for Espo Corporation just prior to the Covid lockdowns, enabling employees to work from home as the business was classified as essential. During the same time I got my Computer Science degree, suffering through e-learning. I\'ve picked up some methods to dealing with the madness. ', name: 'workflow-optimization-consult' }, | |
| 10 | { id: 2, title: 'Custom software development', description: 'AI agents, custom ERP systems, API integrations, infrastructure deployment, data migrations. I\'ve done it all. Trust me, let me do it for you.', name: 'custom-software-development-consult' }, | |
| 11 | { id: 3, title: 'Trusted technical partner', description: 'I have been writing code for over 10 years and have always been quite obssessed. When I was 12 I started mowing lawns, I\'ve done demolition work, I was a dish boy, I even worked at a detail shop. I\'ve worked with different people in plenty of industries. I know how to build software to solve real problems. Bring your problem and leave with a plan.', name: 'technical-partner-consult' }, | |
| 12 | { id: 4, title: 'Super Secret Calendar', description: 'You either know me personally or someone I trust gave you this link. Grab time below.', name: 'super-secret-calendar' }, | |
| 8 | { | |
| 9 | id: 1, | |
| 10 | title: "Personal workflow optimization", | |
| 11 | description: | |
| 12 | "I've been a remote software developer for 5 years. I led the development of a custom Enterprise Resource Planning system for Espo Corporation just prior to the Covid lockdowns, enabling employees to work from home as the business was classified as essential. During the same time I got my Computer Science degree, suffering through e-learning. I've picked up some methods to dealing with the madness. ", | |
| 13 | name: "workflow-optimization-consult", | |
| 14 | }, | |
| 15 | { | |
| 16 | id: 2, | |
| 17 | title: "Custom software development", | |
| 18 | description: | |
| 19 | "AI agents, custom ERP systems, API integrations, infrastructure deployment, data migrations. I've done it all. Trust me, let me do it for you.", | |
| 20 | name: "custom-software-development-consult", | |
| 21 | }, | |
| 22 | { | |
| 23 | id: 3, | |
| 24 | title: "Trusted technical partner", | |
| 25 | description: | |
| 26 | "I have been writing code for over 10 years and have always been quite obssessed. When I was 12 I started mowing lawns, I've done demolition work, I was a dish boy, I even worked at a detail shop. I've worked with different people in plenty of industries. I know how to build software to solve real problems. Bring your problem and leave with a plan.", | |
| 27 | name: "technical-partner-consult", | |
| 28 | }, | |
| 29 | { | |
| 30 | id: 4, | |
| 31 | title: "Super Secret Calendar", | |
| 32 | description: | |
| 33 | "You either know me personally or someone I trust gave you this link. Grab time below.", | |
| 34 | name: "super-secret-calendar", | |
| 35 | }, | |
| 13 | 36 | ] |
| 14 | 37 | |
| 15 | 38 | export default function ServicePage() { |
src/app/thanksforbooking/page.tsx +12 −18
| @@ -1,10 +1,10 @@ | ||
| 1 | "use client"; | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import { motion } from "framer-motion"; | |
| 4 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| 5 | import Link from "next/link"; | |
| 6 | import { Button } from "@/components/ui/button"; | |
| 7 | import { Home } from "lucide-react"; | |
| 3 | import { motion } from "framer-motion" | |
| 4 | import { Home } from "lucide-react" | |
| 5 | import Link from "next/link" | |
| 6 | import { Button } from "@/components/ui/button" | |
| 7 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" | |
| 8 | 8 | |
| 9 | 9 | export default function ThanksForBookingPage() { |
| 10 | 10 | return ( |
| @@ -28,8 +28,7 @@ export default function ThanksForBookingPage() { | ||
| 28 | 28 | transition={{ delay: 0.3 }} |
| 29 | 29 | className="text-xl text-blue-200 text-center" |
| 30 | 30 | > |
| 31 | I'm looking forward to our conversation and learning more | |
| 32 | about your project. | |
| 31 | I'm looking forward to our conversation and learning more about your project. | |
| 33 | 32 | </motion.div> |
| 34 | 33 | |
| 35 | 34 | <motion.div |
| @@ -38,9 +37,7 @@ export default function ThanksForBookingPage() { | ||
| 38 | 37 | transition={{ delay: 0.5 }} |
| 39 | 38 | className="bg-black/50 backdrop-blur-md border border-blue-500/50 rounded-lg p-6" |
| 40 | 39 | > |
| 41 | <h2 className="text-xl font-semibold mb-4 text-blue-300"> | |
| 42 | What's Next? | |
| 43 | </h2> | |
| 40 | <h2 className="text-xl font-semibold mb-4 text-blue-300">What's Next?</h2> | |
| 44 | 41 | <ul className="space-y-4 text-gray-300"> |
| 45 | 42 | <li className="flex items-center space-x-3"> |
| 46 | 43 | <span className="text-blue-400">✉️</span> |
| @@ -49,15 +46,13 @@ export default function ThanksForBookingPage() { | ||
| 49 | 46 | <li className="flex items-center space-x-3"> |
| 50 | 47 | <span className="text-blue-400">📝</span> |
| 51 | 48 | <span> |
| 52 | Feel free to prepare any specific questions or topics | |
| 53 | you'd like to discuss | |
| 49 | Feel free to prepare any specific questions or topics you'd like to discuss | |
| 54 | 50 | </span> |
| 55 | 51 | </li> |
| 56 | 52 | <li className="flex items-center space-x-3"> |
| 57 | 53 | <span className="text-blue-400">🎯</span> |
| 58 | 54 | <span> |
| 59 | I'll review any information you've provided before | |
| 60 | our meeting | |
| 55 | I'll review any information you've provided before our meeting | |
| 61 | 56 | </span> |
| 62 | 57 | </li> |
| 63 | 58 | </ul> |
| @@ -70,8 +65,7 @@ export default function ThanksForBookingPage() { | ||
| 70 | 65 | className="text-center" |
| 71 | 66 | > |
| 72 | 67 | <p className="text-gray-400 mb-6"> |
| 73 | If you need to make any changes, please don't hesitate to | |
| 74 | reach out. | |
| 68 | If you need to make any changes, please don't hesitate to reach out. | |
| 75 | 69 | </p> |
| 76 | 70 | <Button |
| 77 | 71 | asChild |
| @@ -88,5 +82,5 @@ export default function ThanksForBookingPage() { | ||
| 88 | 82 | </Card> |
| 89 | 83 | </motion.div> |
| 90 | 84 | </div> |
| 91 | ); | |
| 85 | ) | |
| 92 | 86 | } |
src/components/ClientErrorBoundary.tsx added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | "use client" | |
| 2 | ||
| 3 | import { type ReactNode } from "react" | |
| 4 | import { ErrorBoundary } from "./ErrorBoundary" | |
| 5 | ||
| 6 | export function ClientErrorBoundary({ children }: { children: ReactNode }) { | |
| 7 | return <ErrorBoundary>{children}</ErrorBoundary> | |
| 8 | } |
src/components/Contributions.tsx +5 −5
| @@ -1,8 +1,8 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React from 'react' | |
| 4 | import { motion } from 'framer-motion' | |
| 5 | import WebsiteGallery from './WebsiteGallery' | |
| 3 | import { motion } from "framer-motion" | |
| 4 | import type React from "react" | |
| 5 | import WebsiteGallery from "./WebsiteGallery" | |
| 6 | 6 | |
| 7 | 7 | // Using the same websites from the original portfolio carousel |
| 8 | 8 | const websites = [ |
| @@ -46,7 +46,7 @@ const websites = [ | ||
| 46 | 46 | export const Contributions: React.FC = () => { |
| 47 | 47 | return ( |
| 48 | 48 | <section className="py-12 container mx-auto px-4"> |
| 49 | <motion.h2 | |
| 49 | <motion.h2 | |
| 50 | 50 | className="text-3xl font-bold mb-8 text-center text-white text-glow" |
| 51 | 51 | initial={{ opacity: 0, y: 20 }} |
| 52 | 52 | animate={{ opacity: 1, y: 0 }} |
src/components/ErrorBoundary.tsx added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | "use client" | |
| 2 | ||
| 3 | import { Component, type ErrorInfo, type ReactNode } from "react" | |
| 4 | import { Button } from "@/components/ui/button" | |
| 5 | import { Card } from "@/components/ui/card" | |
| 6 | ||
| 7 | interface Props { | |
| 8 | children: ReactNode | |
| 9 | fallback?: ReactNode | |
| 10 | } | |
| 11 | ||
| 12 | interface State { | |
| 13 | hasError: boolean | |
| 14 | error: Error | null | |
| 15 | } | |
| 16 | ||
| 17 | export class ErrorBoundary extends Component<Props, State> { | |
| 18 | constructor(props: Props) { | |
| 19 | super(props) | |
| 20 | this.state = { hasError: false, error: null } | |
| 21 | } | |
| 22 | ||
| 23 | static getDerivedStateFromError(error: Error): State { | |
| 24 | return { hasError: true, error } | |
| 25 | } | |
| 26 | ||
| 27 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { | |
| 28 | // Log to console in development | |
| 29 | if (process.env.NODE_ENV === "development") { | |
| 30 | console.error("Error boundary caught:", error, errorInfo) | |
| 31 | } | |
| 32 | ||
| 33 | // Send to Sentry in production | |
| 34 | if (process.env.NODE_ENV === "production" && typeof window !== "undefined") { | |
| 35 | import("@sentry/nextjs").then((Sentry) => { | |
| 36 | Sentry.captureException(error, { | |
| 37 | contexts: { | |
| 38 | react: { | |
| 39 | componentStack: errorInfo.componentStack, | |
| 40 | }, | |
| 41 | }, | |
| 42 | }) | |
| 43 | }) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | render() { | |
| 48 | if (this.state.hasError) { | |
| 49 | if (this.props.fallback) { | |
| 50 | return this.props.fallback | |
| 51 | } | |
| 52 | ||
| 53 | return ( | |
| 54 | <Card className="bg-red-950/20 border-red-500/50 p-6 mx-auto max-w-md"> | |
| 55 | <div className="space-y-4"> | |
| 56 | <div className="flex items-center gap-2"> | |
| 57 | <svg | |
| 58 | className="w-6 h-6 text-red-500" | |
| 59 | fill="none" | |
| 60 | stroke="currentColor" | |
| 61 | viewBox="0 0 24 24" | |
| 62 | > | |
| 63 | <title>Error icon</title> | |
| 64 | <path | |
| 65 | strokeLinecap="round" | |
| 66 | strokeLinejoin="round" | |
| 67 | strokeWidth={2} | |
| 68 | d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" | |
| 69 | /> | |
| 70 | </svg> | |
| 71 | <h2 className="text-xl font-semibold text-red-400">Something went wrong</h2> | |
| 72 | </div> | |
| 73 | ||
| 74 | {process.env.NODE_ENV === "development" && this.state.error && ( | |
| 75 | <div className="bg-black/30 p-3 rounded text-xs font-mono text-gray-300 overflow-auto max-h-32"> | |
| 76 | {this.state.error.message} | |
| 77 | </div> | |
| 78 | )} | |
| 79 | ||
| 80 | <p className="text-gray-400 text-sm"> | |
| 81 | The component encountered an error. Please try refreshing the page. | |
| 82 | </p> | |
| 83 | ||
| 84 | <div className="flex gap-2"> | |
| 85 | <Button | |
| 86 | onClick={() => window.location.reload()} | |
| 87 | className="flex-1" | |
| 88 | variant="outline" | |
| 89 | > | |
| 90 | Refresh Page | |
| 91 | </Button> | |
| 92 | <Button | |
| 93 | onClick={() => this.setState({ hasError: false, error: null })} | |
| 94 | className="flex-1" | |
| 95 | > | |
| 96 | Try Again | |
| 97 | </Button> | |
| 98 | </div> | |
| 99 | </div> | |
| 100 | </Card> | |
| 101 | ) | |
| 102 | } | |
| 103 | ||
| 104 | return this.props.children | |
| 105 | } | |
| 106 | } |
src/components/GitHubProject.tsx +19 −19
| @@ -1,16 +1,16 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React from 'react' | |
| 4 | import { Card, CardContent } from '@/components/ui/card' | |
| 5 | import Link from 'next/link' | |
| 6 | import { FaGithub, FaStar, FaCodeBranch, FaEye, FaCode } from 'react-icons/fa' | |
| 7 | import { RiGitRepositoryLine } from 'react-icons/ri' | |
| 8 | import ReactMarkdown from 'react-markdown' | |
| 9 | import rehypeRaw from 'rehype-raw' | |
| 10 | import remarkGfm from 'remark-gfm' | |
| 11 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' | |
| 12 | import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism' | |
| 13 | import '@/styles/github-markdown.css' | |
| 3 | import Link from "next/link" | |
| 4 | import type React from "react" | |
| 5 | import { FaCode, FaCodeBranch, FaEye, FaGithub, FaStar } from "react-icons/fa" | |
| 6 | import { RiGitRepositoryLine } from "react-icons/ri" | |
| 7 | import ReactMarkdown from "react-markdown" | |
| 8 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" | |
| 9 | import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism" | |
| 10 | import rehypeRaw from "rehype-raw" | |
| 11 | import remarkGfm from "remark-gfm" | |
| 12 | import { Card, CardContent } from "@/components/ui/card" | |
| 13 | import "@/styles/github-markdown.css" | |
| 14 | 14 | |
| 15 | 15 | interface GitHubProjectProps { |
| 16 | 16 | repoName: string |
| @@ -48,10 +48,10 @@ export const GitHubProject: React.FC<GitHubProjectProps> = ({ | ||
| 48 | 48 | <div className="flex flex-col items-center mb-6"> |
| 49 | 49 | <div className="flex flex-wrap justify-center space-x-4 mb-4"> |
| 50 | 50 | {[ |
| 51 | { icon: FaStar, label: 'Stars', value: stars }, | |
| 52 | { icon: FaCodeBranch, label: 'Forks', value: forks }, | |
| 53 | { icon: FaEye, label: 'Watchers', value: watchers }, | |
| 54 | { icon: FaCode, label: 'Language', value: language }, | |
| 51 | { icon: FaStar, label: "Stars", value: stars }, | |
| 52 | { icon: FaCodeBranch, label: "Forks", value: forks }, | |
| 53 | { icon: FaEye, label: "Watchers", value: watchers }, | |
| 54 | { icon: FaCode, label: "Language", value: language }, | |
| 55 | 55 | ].map((item) => ( |
| 56 | 56 | <div key={item.label} className="flex items-center text-gray-400 mb-2"> |
| 57 | 57 | <item.icon className="mr-1" /> |
| @@ -79,7 +79,7 @@ export const GitHubProject: React.FC<GitHubProjectProps> = ({ | ||
| 79 | 79 | rehypePlugins={[rehypeRaw]} |
| 80 | 80 | components={{ |
| 81 | 81 | code({ node, className, children, ...props }) { |
| 82 | const match = /language-(\w+)/.exec(className || '') | |
| 82 | const match = /language-(\w+)/.exec(className || "") | |
| 83 | 83 | return match ? ( |
| 84 | 84 | // @ts-expect-error syntax highlight is fine |
| 85 | 85 | <SyntaxHighlighter |
| @@ -88,14 +88,14 @@ export const GitHubProject: React.FC<GitHubProjectProps> = ({ | ||
| 88 | 88 | PreTag="div" |
| 89 | 89 | {...props} |
| 90 | 90 | > |
| 91 | {String(children).replace(/\n$/, '')} | |
| 91 | {String(children).replace(/\n$/, "")} | |
| 92 | 92 | </SyntaxHighlighter> |
| 93 | 93 | ) : ( |
| 94 | 94 | <code className={className} {...props}> |
| 95 | 95 | {children} |
| 96 | 96 | </code> |
| 97 | 97 | ) |
| 98 | } | |
| 98 | }, | |
| 99 | 99 | }} |
| 100 | 100 | > |
| 101 | 101 | {readme} |
src/components/HeroSection.tsx +14 −10
| @@ -1,10 +1,10 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React from 'react' | |
| 4 | import { motion } from 'framer-motion' | |
| 5 | import { Button } from '@/components/ui/button' | |
| 6 | import Link from 'next/link' | |
| 7 | import { TypeAnimation } from 'react-type-animation' | |
| 3 | import { motion } from "framer-motion" | |
| 4 | import Link from "next/link" | |
| 5 | import type React from "react" | |
| 6 | import { TypeAnimation } from "react-type-animation" | |
| 7 | import { Button } from "@/components/ui/button" | |
| 8 | 8 | |
| 9 | 9 | export const HeroSection: React.FC = () => { |
| 10 | 10 | return ( |
| @@ -30,11 +30,11 @@ export const HeroSection: React.FC = () => { | ||
| 30 | 30 | > |
| 31 | 31 | <TypeAnimation |
| 32 | 32 | sequence={[ |
| 33 | 'First principles thinking', | |
| 33 | "First principles thinking", | |
| 34 | 34 | 3000, |
| 35 | 'Built for your domain', | |
| 35 | "Built for your domain", | |
| 36 | 36 | 3000, |
| 37 | 'Using AI as a tool, not a talking point', | |
| 37 | "Using AI as a tool, not a talking point", | |
| 38 | 38 | 3000, |
| 39 | 39 | ]} |
| 40 | 40 | wrapper="p" |
| @@ -51,7 +51,11 @@ export const HeroSection: React.FC = () => { | ||
| 51 | 51 | <Button asChild className="bg-blue-600 hover:bg-blue-700 text-white neon-border"> |
| 52 | 52 | <Link href="#booking">Book a Call ($500)</Link> |
| 53 | 53 | </Button> |
| 54 | <Button variant="outline" asChild className="border-blue-500 text-blue-500 hover:bg-blue-900 neon-border"> | |
| 54 | <Button | |
| 55 | variant="outline" | |
| 56 | asChild | |
| 57 | className="border-blue-500 text-blue-500 hover:bg-blue-900 neon-border" | |
| 58 | > | |
| 55 | 59 | <Link href="/projects">Explore My Work</Link> |
| 56 | 60 | </Button> |
| 57 | 61 | </motion.div> |
src/components/InteractiveBackground.tsx +8 −11
| @@ -1,6 +1,7 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React, { useEffect, useRef } from 'react' | |
| 3 | import type React from "react" | |
| 4 | import { useEffect, useRef } from "react" | |
| 4 | 5 | |
| 5 | 6 | export const InteractiveBackground: React.FC = () => { |
| 6 | 7 | const canvasRef = useRef<HTMLCanvasElement>(null) |
| @@ -9,7 +10,7 @@ export const InteractiveBackground: React.FC = () => { | ||
| 9 | 10 | const canvas = canvasRef.current |
| 10 | 11 | if (!canvas) return |
| 11 | 12 | |
| 12 | const ctx = canvas.getContext('2d') | |
| 13 | const ctx = canvas.getContext("2d") | |
| 13 | 14 | if (!ctx) return |
| 14 | 15 | |
| 15 | 16 | canvas.width = window.innerWidth |
| @@ -21,7 +22,7 @@ export const InteractiveBackground: React.FC = () => { | ||
| 21 | 22 | |
| 22 | 23 | for (let i = 0; i < 200; i++) { |
| 23 | 24 | const angle = Math.random() * Math.PI * 2 |
| 24 | const distance = Math.random() * Math.min(canvas.width, canvas.height) / 2 | |
| 25 | const distance = (Math.random() * Math.min(canvas.width, canvas.height)) / 2 | |
| 25 | 26 | particles.push({ |
| 26 | 27 | x: centerX + Math.cos(angle) * distance, |
| 27 | 28 | y: centerY + Math.sin(angle) * distance, |
| @@ -66,18 +67,14 @@ export const InteractiveBackground: React.FC = () => { | ||
| 66 | 67 | canvas.height = window.innerHeight |
| 67 | 68 | } |
| 68 | 69 | |
| 69 | window.addEventListener('resize', handleResize) | |
| 70 | window.addEventListener("resize", handleResize) | |
| 70 | 71 | |
| 71 | 72 | return () => { |
| 72 | window.removeEventListener('resize', handleResize) | |
| 73 | window.removeEventListener("resize", handleResize) | |
| 73 | 74 | } |
| 74 | 75 | }, []) |
| 75 | 76 | |
| 76 | 77 | return ( |
| 77 | <canvas | |
| 78 | ref={canvasRef} | |
| 79 | className="fixed inset-0 pointer-events-none" | |
| 80 | style={{ zIndex: -1 }} | |
| 81 | /> | |
| 78 | <canvas ref={canvasRef} className="fixed inset-0 pointer-events-none" style={{ zIndex: -1 }} /> | |
| 82 | 79 | ) |
| 83 | 80 | } |
src/components/Me.tsx +10 −10
| @@ -1,11 +1,11 @@ | ||
| 1 | "use client"; | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React from "react"; | |
| 4 | import { motion } from "framer-motion"; | |
| 5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| 6 | import Link from "next/link"; | |
| 7 | import Image from "next/image"; | |
| 8 | import { FaGithub, FaLinkedin, FaXTwitter, FaFilePdf } from "react-icons/fa6"; | |
| 3 | import { motion } from "framer-motion" | |
| 4 | import Image from "next/image" | |
| 5 | import Link from "next/link" | |
| 6 | import type React from "react" | |
| 7 | import { FaFilePdf, FaGithub, FaLinkedin, FaXTwitter } from "react-icons/fa6" | |
| 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" | |
| 9 | 9 | |
| 10 | 10 | const me = [ |
| 11 | 11 | { |
| @@ -18,7 +18,7 @@ const me = [ | ||
| 18 | 18 | x: "https://x.com/trevordobbertin", |
| 19 | 19 | image: "/trevor.jpg", |
| 20 | 20 | }, |
| 21 | ]; | |
| 21 | ] | |
| 22 | 22 | |
| 23 | 23 | export const Me: React.FC = () => { |
| 24 | 24 | return ( |
| @@ -98,5 +98,5 @@ export const Me: React.FC = () => { | ||
| 98 | 98 | ))} |
| 99 | 99 | </div> |
| 100 | 100 | </section> |
| 101 | ); | |
| 102 | }; | |
| 101 | ) | |
| 102 | } |
src/components/ParallaxStars.tsx +6 −9
| @@ -1,13 +1,14 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React, { useEffect, useRef } from 'react' | |
| 4 | import { motion, useScroll, useTransform } from 'framer-motion' | |
| 3 | import { motion, useScroll, useTransform } from "framer-motion" | |
| 4 | import type React from "react" | |
| 5 | import { useEffect, useRef } from "react" | |
| 5 | 6 | |
| 6 | 7 | export const ParallaxStars: React.FC = () => { |
| 7 | 8 | const ref = useRef(null) |
| 8 | 9 | const { scrollYProgress } = useScroll({ |
| 9 | 10 | target: ref, |
| 10 | offset: ["start start", "end start"] | |
| 11 | offset: ["start start", "end start"], | |
| 11 | 12 | }) |
| 12 | 13 | |
| 13 | 14 | const y1 = useTransform(scrollYProgress, [0, 1], ["0%", "50%"]) |
| @@ -27,11 +28,7 @@ export const ParallaxStars: React.FC = () => { | ||
| 27 | 28 | return ( |
| 28 | 29 | <div ref={ref} className="fixed inset-0 pointer-events-none"> |
| 29 | 30 | {[y1, y2, y3].map((y, index) => ( |
| 30 | <motion.div | |
| 31 | key={index} | |
| 32 | className="absolute inset-0" | |
| 33 | style={{ y }} | |
| 34 | > | |
| 31 | <motion.div key={index} className="absolute inset-0" style={{ y }}> | |
| 35 | 32 | {stars.current.map((star, starIndex) => ( |
| 36 | 33 | <div |
| 37 | 34 | key={starIndex} |
src/components/PlaygroundTabs.tsx +10 −9
| @@ -1,13 +1,14 @@ | ||
| 1 | "use client"; | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React, { useState } from "react"; | |
| 4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| 5 | import { FunctionCreator } from "@/components/playground/FunctionCreator"; | |
| 6 | import { ColorPicker } from "@/components/playground/ColorPicker"; | |
| 7 | import { ObjectViewer } from "@/components/playground/ObjectViewer"; | |
| 3 | import type React from "react" | |
| 4 | import { useState } from "react" | |
| 5 | import { ColorPicker } from "@/components/playground/ColorPicker" | |
| 6 | import { FunctionCreator } from "@/components/playground/FunctionCreator" | |
| 7 | import { ObjectViewer } from "@/components/playground/ObjectViewer" | |
| 8 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" | |
| 8 | 9 | |
| 9 | 10 | export const PlaygroundTabs: React.FC = () => { |
| 10 | const [activeTab, setActiveTab] = useState("object-viewer"); | |
| 11 | const [activeTab, setActiveTab] = useState("object-viewer") | |
| 11 | 12 | |
| 12 | 13 | return ( |
| 13 | 14 | <Tabs |
| @@ -54,5 +55,5 @@ export const PlaygroundTabs: React.FC = () => { | ||
| 54 | 55 | <FunctionCreator /> |
| 55 | 56 | </TabsContent> |
| 56 | 57 | </Tabs> |
| 57 | ); | |
| 58 | }; | |
| 58 | ) | |
| 59 | } |
src/components/ProjectsList.tsx +67 −16
| @@ -1,18 +1,53 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React from 'react' | |
| 4 | import { motion } from 'framer-motion' | |
| 5 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' | |
| 6 | import Link from 'next/link' | |
| 7 | import { Github, ExternalLink } from 'lucide-react' | |
| 8 | import Image from 'next/image' | |
| 3 | import { motion } from "framer-motion" | |
| 4 | import { ExternalLink, Github } from "lucide-react" | |
| 5 | import Image from "next/image" | |
| 6 | import Link from "next/link" | |
| 7 | import type React from "react" | |
| 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" | |
| 9 | 9 | |
| 10 | 10 | const projects = [ |
| 11 | { id: 1, title: 'Project Alpha', description: 'A revolutionary AI platform for predictive analytics', github: 'https://github.com/yourusername/project-alpha', demo: 'https://project-alpha-demo.com', image: '/project-alpha.jpg' }, | |
| 12 | { id: 2, title: 'Project Beta', description: 'Next-gen blockchain solution for secure transactions', github: 'https://github.com/yourusername/project-beta', demo: 'https://project-beta-demo.com', image: '/project-beta.jpg' }, | |
| 13 | { id: 3, title: 'Project Gamma', description: 'Quantum computing interface for complex simulations', github: 'https://github.com/yourusername/project-gamma', demo: 'https://project-gamma-demo.com', image: '/project-gamma.jpg' }, | |
| 14 | { id: 4, title: 'Project Delta', description: 'Advanced machine learning algorithms for natural language processing', github: 'https://github.com/yourusername/project-delta', demo: 'https://project-delta-demo.com', image: '/project-delta.jpg' }, | |
| 15 | { id: 5, title: 'Project Epsilon', description: 'Augmented reality toolkit for immersive educational experiences', github: 'https://github.com/yourusername/project-epsilon', demo: 'https://project-epsilon-demo.com', image: '/project-epsilon.jpg' }, | |
| 11 | { | |
| 12 | id: 1, | |
| 13 | title: "Project Alpha", | |
| 14 | description: "A revolutionary AI platform for predictive analytics", | |
| 15 | github: "https://github.com/yourusername/project-alpha", | |
| 16 | demo: "https://project-alpha-demo.com", | |
| 17 | image: "/project-alpha.jpg", | |
| 18 | }, | |
| 19 | { | |
| 20 | id: 2, | |
| 21 | title: "Project Beta", | |
| 22 | description: "Next-gen blockchain solution for secure transactions", | |
| 23 | github: "https://github.com/yourusername/project-beta", | |
| 24 | demo: "https://project-beta-demo.com", | |
| 25 | image: "/project-beta.jpg", | |
| 26 | }, | |
| 27 | { | |
| 28 | id: 3, | |
| 29 | title: "Project Gamma", | |
| 30 | description: "Quantum computing interface for complex simulations", | |
| 31 | github: "https://github.com/yourusername/project-gamma", | |
| 32 | demo: "https://project-gamma-demo.com", | |
| 33 | image: "/project-gamma.jpg", | |
| 34 | }, | |
| 35 | { | |
| 36 | id: 4, | |
| 37 | title: "Project Delta", | |
| 38 | description: "Advanced machine learning algorithms for natural language processing", | |
| 39 | github: "https://github.com/yourusername/project-delta", | |
| 40 | demo: "https://project-delta-demo.com", | |
| 41 | image: "/project-delta.jpg", | |
| 42 | }, | |
| 43 | { | |
| 44 | id: 5, | |
| 45 | title: "Project Epsilon", | |
| 46 | description: "Augmented reality toolkit for immersive educational experiences", | |
| 47 | github: "https://github.com/yourusername/project-epsilon", | |
| 48 | demo: "https://project-epsilon-demo.com", | |
| 49 | image: "/project-epsilon.jpg", | |
| 50 | }, | |
| 16 | 51 | ] |
| 17 | 52 | |
| 18 | 53 | export const ProjectsList: React.FC = () => { |
| @@ -31,15 +66,31 @@ export const ProjectsList: React.FC = () => { | ||
| 31 | 66 | </CardHeader> |
| 32 | 67 | <CardContent className="flex-grow flex flex-col"> |
| 33 | 68 | <div className="relative h-48 mb-4"> |
| 34 | <Image src={project.image} alt={project.title} layout="fill" objectFit="cover" className="rounded-md" /> | |
| 69 | <Image | |
| 70 | src={project.image} | |
| 71 | alt={project.title} | |
| 72 | layout="fill" | |
| 73 | objectFit="cover" | |
| 74 | className="rounded-md" | |
| 75 | /> | |
| 35 | 76 | </div> |
| 36 | 77 | <p className="text-white mb-4">{project.description}</p> |
| 37 | 78 | <div className="flex justify-between mt-auto"> |
| 38 | <Link href={project.github} target="_blank" rel="noopener noreferrer" className="flex items-center text-blue-400 hover:text-blue-300 transition-colors"> | |
| 79 | <Link | |
| 80 | href={project.github} | |
| 81 | target="_blank" | |
| 82 | rel="noopener noreferrer" | |
| 83 | className="flex items-center text-blue-400 hover:text-blue-300 transition-colors" | |
| 84 | > | |
| 39 | 85 | <Github className="mr-2 h-4 w-4" /> |
| 40 | 86 | GitHub |
| 41 | 87 | </Link> |
| 42 | <Link href={project.demo} target="_blank" rel="noopener noreferrer" className="flex items-center text-green-400 hover:text-green-300 transition-colors"> | |
| 88 | <Link | |
| 89 | href={project.demo} | |
| 90 | target="_blank" | |
| 91 | rel="noopener noreferrer" | |
| 92 | className="flex items-center text-green-400 hover:text-green-300 transition-colors" | |
| 93 | > | |
| 43 | 94 | <ExternalLink className="mr-2 h-4 w-4" /> |
| 44 | 95 | Live Demo |
| 45 | 96 | </Link> |
| @@ -50,4 +101,4 @@ export const ProjectsList: React.FC = () => { | ||
| 50 | 101 | ))} |
| 51 | 102 | </div> |
| 52 | 103 | ) |
| 53 | }↵ | |
| 104 | } |
src/components/ServicesOverview.tsx +11 −6
| @@ -1,9 +1,9 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React from 'react' | |
| 4 | import { motion } from 'framer-motion' | |
| 5 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' | |
| 6 | 3 | import Cal from "@calcom/embed-react" |
| 4 | import { motion } from "framer-motion" | |
| 5 | import type React from "react" | |
| 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" | |
| 7 | 7 | |
| 8 | 8 | export const ServicesOverview: React.FC = () => { |
| 9 | 9 | return ( |
| @@ -17,10 +17,15 @@ export const ServicesOverview: React.FC = () => { | ||
| 17 | 17 | <h2 className="text-3xl font-bold mb-8 text-center text-white">Start Here</h2> |
| 18 | 18 | <Card className="bg-black/50 backdrop-blur-md border-blue-500 max-w-3xl mx-auto"> |
| 19 | 19 | <CardHeader> |
| 20 | <CardTitle className="text-blue-300">Got an idea? A problem you can't stop thinking about?</CardTitle> | |
| 20 | <CardTitle className="text-blue-300"> | |
| 21 | Got an idea? A problem you can't stop thinking about? | |
| 22 | </CardTitle> | |
| 21 | 23 | </CardHeader> |
| 22 | 24 | <CardContent> |
| 23 | <p className="text-gray-300 mb-4">Book a consultation. We'll talk through it, figure out if it's something we should build together.</p> | |
| 25 | <p className="text-gray-300 mb-4"> | |
| 26 | Book a consultation. We'll talk through it, figure out if it's something we | |
| 27 | should build together. | |
| 28 | </p> | |
| 24 | 29 | <p className="text-blue-300 font-semibold mb-4">$500. 30 minutes. No nonsense.</p> |
| 25 | 30 | <div className="relative"> |
| 26 | 31 | <Cal |
src/components/SkillsSection.tsx +10 −10
| @@ -1,15 +1,15 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React from 'react' | |
| 4 | import { motion } from 'framer-motion' | |
| 3 | import { motion } from "framer-motion" | |
| 4 | import type React from "react" | |
| 5 | 5 | |
| 6 | 6 | const skills = [ |
| 7 | { name: 'React', level: 90 }, | |
| 8 | { name: 'TypeScript', level: 85 }, | |
| 9 | { name: 'Node.js', level: 80 }, | |
| 10 | { name: 'GraphQL', level: 75 }, | |
| 11 | { name: 'Python', level: 70 }, | |
| 12 | { name: 'AWS', level: 65 }, | |
| 7 | { name: "React", level: 90 }, | |
| 8 | { name: "TypeScript", level: 85 }, | |
| 9 | { name: "Node.js", level: 80 }, | |
| 10 | { name: "GraphQL", level: 75 }, | |
| 11 | { name: "Python", level: 70 }, | |
| 12 | { name: "AWS", level: 65 }, | |
| 13 | 13 | ] |
| 14 | 14 | |
| 15 | 15 | export const SkillsSection: React.FC = () => { |
| @@ -40,4 +40,4 @@ export const SkillsSection: React.FC = () => { | ||
| 40 | 40 | </div> |
| 41 | 41 | </section> |
| 42 | 42 | ) |
| 43 | }↵ | |
| 43 | } |
src/components/SpaceBackground.tsx +76 −54
| @@ -1,6 +1,7 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React, { useEffect, useRef } from 'react' | |
| 3 | import type React from "react" | |
| 4 | import { useEffect, useRef } from "react" | |
| 4 | 5 | |
| 5 | 6 | export const SpaceBackground: React.FC<{ children: React.ReactNode }> = ({ children }) => { |
| 6 | 7 | const canvasRef = useRef<HTMLCanvasElement>(null) |
| @@ -9,15 +10,28 @@ export const SpaceBackground: React.FC<{ children: React.ReactNode }> = ({ child | ||
| 9 | 10 | const canvas = canvasRef.current |
| 10 | 11 | if (!canvas) return |
| 11 | 12 | |
| 12 | const ctx = canvas.getContext('2d') | |
| 13 | const ctx = canvas.getContext("2d") | |
| 13 | 14 | if (!ctx) return |
| 14 | 15 | |
| 15 | 16 | canvas.width = window.innerWidth |
| 16 | 17 | canvas.height = window.innerHeight |
| 17 | 18 | |
| 18 | 19 | const stars: { x: number; y: number; size: number; speed: number }[] = [] |
| 19 | const shootingStars: { x: number; y: number; length: number; speed: number; opacity: number }[] = [] | |
| 20 | const particles: { x: number; y: number; size: number; color: string; speed: number; angle: number }[] = [] | |
| 20 | const shootingStars: { | |
| 21 | x: number | |
| 22 | y: number | |
| 23 | length: number | |
| 24 | speed: number | |
| 25 | opacity: number | |
| 26 | }[] = [] | |
| 27 | const particles: { | |
| 28 | x: number | |
| 29 | y: number | |
| 30 | size: number | |
| 31 | color: string | |
| 32 | speed: number | |
| 33 | angle: number | |
| 34 | }[] = [] | |
| 21 | 35 | |
| 22 | 36 | for (let i = 0; i < 200; i++) { |
| 23 | 37 | stars.push({ |
| @@ -40,7 +54,18 @@ export const SpaceBackground: React.FC<{ children: React.ReactNode }> = ({ child | ||
| 40 | 54 | |
| 41 | 55 | function createExplosion(x: number, y: number) { |
| 42 | 56 | const particleCount = 50 |
| 43 | const colors = ['#ffffff', '#ffc0cb', '#e6e6fa', '#dda0dd', '#ff69b4', '#da70d6', '#8a2be2', '#9370db', '#7b68ee', '#4b0082'] | |
| 57 | const colors = [ | |
| 58 | "#ffffff", | |
| 59 | "#ffc0cb", | |
| 60 | "#e6e6fa", | |
| 61 | "#dda0dd", | |
| 62 | "#ff69b4", | |
| 63 | "#da70d6", | |
| 64 | "#8a2be2", | |
| 65 | "#9370db", | |
| 66 | "#7b68ee", | |
| 67 | "#4b0082", | |
| 68 | ] | |
| 44 | 69 | for (let i = 0; i < particleCount; i++) { |
| 45 | 70 | particles.push({ |
| 46 | 71 | x, |
| @@ -48,7 +73,7 @@ export const SpaceBackground: React.FC<{ children: React.ReactNode }> = ({ child | ||
| 48 | 73 | size: Math.random() * 2 + 1, |
| 49 | 74 | color: colors[Math.floor(Math.random() * colors.length)], |
| 50 | 75 | speed: Math.random() * 3 + 1, |
| 51 | angle: Math.random() * Math.PI * 2 | |
| 76 | angle: Math.random() * Math.PI * 2, | |
| 52 | 77 | }) |
| 53 | 78 | } |
| 54 | 79 | } |
| @@ -74,62 +99,59 @@ export const SpaceBackground: React.FC<{ children: React.ReactNode }> = ({ child | ||
| 74 | 99 | }) |
| 75 | 100 | } |
| 76 | 101 | |
| 77 | function animate() { | |
| 78 | if (!ctx) return | |
| 79 | if (!canvas) return | |
| 102 | function animate() { | |
| 103 | if (!ctx) return | |
| 104 | if (!canvas) return | |
| 80 | 105 | |
| 81 | ctx.clearRect(0, 0, canvas.width, canvas.height) | |
| 82 | ||
| 83 | // Draw shooting stars | |
| 84 | shootingStars.forEach((star, index) => { | |
| 85 | ctx.strokeStyle = `rgba(255, 255, 255, ${star.opacity})` | |
| 86 | ctx.lineWidth = 2 | |
| 87 | ctx.beginPath() | |
| 88 | ctx.moveTo(star.x, star.y) | |
| 89 | ctx.lineTo(star.x + star.length, star.y + star.length) | |
| 90 | ctx.stroke() | |
| 91 | ||
| 92 | star.x += star.speed | |
| 93 | star.y += star.speed | |
| 94 | star.opacity -= 0.02 | |
| 95 | ||
| 96 | if (star.opacity <= 0 || star.x > canvas.width || star.y > canvas.height) { | |
| 97 | createExplosion(star.x, star.y) | |
| 98 | shootingStars.splice(index, 1) | |
| 99 | } | |
| 100 | }) | |
| 101 | ||
| 102 | if (Math.random() < 0.01 && shootingStars.length < 5) { | |
| 103 | shootingStars.push(createShootingStar()) | |
| 104 | } | |
| 105 | ||
| 106 | // Update and draw particles | |
| 107 | updateParticles() | |
| 108 | drawParticles(ctx) | |
| 109 | ||
| 110 | requestAnimationFrame(animate) | |
| 111 | } | |
| 112 | ||
| 113 | animate() | |
| 114 | ||
| 115 | const handleResize = () => { | |
| 116 | canvas.width = window.innerWidth | |
| 117 | canvas.height = window.innerHeight | |
| 118 | } | |
| 106 | ctx.clearRect(0, 0, canvas.width, canvas.height) | |
| 119 | 107 | |
| 120 | window.addEventListener('resize', handleResize) | |
| 108 | // Draw shooting stars | |
| 109 | shootingStars.forEach((star, index) => { | |
| 110 | ctx.strokeStyle = `rgba(255, 255, 255, ${star.opacity})` | |
| 111 | ctx.lineWidth = 2 | |
| 112 | ctx.beginPath() | |
| 113 | ctx.moveTo(star.x, star.y) | |
| 114 | ctx.lineTo(star.x + star.length, star.y + star.length) | |
| 115 | ctx.stroke() | |
| 116 | ||
| 117 | star.x += star.speed | |
| 118 | star.y += star.speed | |
| 119 | star.opacity -= 0.02 | |
| 120 | ||
| 121 | if (star.opacity <= 0 || star.x > canvas.width || star.y > canvas.height) { | |
| 122 | createExplosion(star.x, star.y) | |
| 123 | shootingStars.splice(index, 1) | |
| 124 | } | |
| 125 | }) | |
| 126 | ||
| 127 | if (Math.random() < 0.01 && shootingStars.length < 5) { | |
| 128 | shootingStars.push(createShootingStar()) | |
| 129 | } | |
| 130 | ||
| 131 | // Update and draw particles | |
| 132 | updateParticles() | |
| 133 | drawParticles(ctx) | |
| 134 | ||
| 135 | requestAnimationFrame(animate) | |
| 136 | } | |
| 137 | ||
| 138 | animate() | |
| 139 | ||
| 140 | const handleResize = () => { | |
| 141 | canvas.width = window.innerWidth | |
| 142 | canvas.height = window.innerHeight | |
| 143 | } | |
| 144 | ||
| 145 | window.addEventListener("resize", handleResize) | |
| 121 | 146 | |
| 122 | 147 | return () => { |
| 123 | window.removeEventListener('resize', handleResize) | |
| 148 | window.removeEventListener("resize", handleResize) | |
| 124 | 149 | } |
| 125 | 150 | }, []) |
| 126 | 151 | |
| 127 | 152 | return ( |
| 128 | 153 | <div className="relative min-h-screen overflow-hidden bg-gradient-to-b from-black to-blue-900"> |
| 129 | <canvas | |
| 130 | ref={canvasRef} | |
| 131 | className="absolute inset-0 w-full h-full" | |
| 132 | /> | |
| 154 | <canvas ref={canvasRef} className="absolute inset-0 w-full h-full" /> | |
| 133 | 155 | <div className="relative z-10">{children}</div> |
| 134 | 156 | </div> |
| 135 | 157 | ) |
src/components/Trevato.tsx +5 −5
| @@ -1,8 +1,8 @@ | ||
| 1 | 'use client' | |
| 2 | import Image from 'next/image' | |
| 3 | import Link from 'next/link' | |
| 4 | import { motion, useScroll } from 'framer-motion' | |
| 5 | import { useEffect, useState } from 'react' | |
| 1 | "use client" | |
| 2 | import { motion, useScroll } from "framer-motion" | |
| 3 | import Image from "next/image" | |
| 4 | import Link from "next/link" | |
| 5 | import { useEffect, useState } from "react" | |
| 6 | 6 | |
| 7 | 7 | export function Trevato() { |
| 8 | 8 | const { scrollYProgress } = useScroll() |
src/components/WebsiteFrame.tsx +28 −21
| @@ -1,9 +1,9 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import { useState, useEffect, useRef } from 'react' | |
| 4 | import { Button } from '@/components/ui/button' | |
| 5 | import { motion } from 'framer-motion' | |
| 6 | import { ZoomIn, ZoomOut } from 'lucide-react' | |
| 3 | import { motion } from "framer-motion" | |
| 4 | import { ZoomIn, ZoomOut } from "lucide-react" | |
| 5 | import { useEffect, useRef, useState } from "react" | |
| 6 | import { Button } from "@/components/ui/button" | |
| 7 | 7 | |
| 8 | 8 | interface WebsiteFrameProps { |
| 9 | 9 | url: string |
| @@ -48,10 +48,10 @@ export default function WebsiteFrame({ | ||
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | updateDimensions() |
| 51 | window.addEventListener('resize', updateDimensions) | |
| 52 | return () => window.removeEventListener('resize', updateDimensions) | |
| 51 | window.addEventListener("resize", updateDimensions) | |
| 52 | return () => window.removeEventListener("resize", updateDimensions) | |
| 53 | 53 | } |
| 54 | }, [scale, containerRef]) | |
| 54 | }, [scale]) | |
| 55 | 55 | |
| 56 | 56 | // Force a re-render after initial mount to fix white screen issue |
| 57 | 57 | useEffect(() => { |
| @@ -90,9 +90,9 @@ export default function WebsiteFrame({ | ||
| 90 | 90 | <div |
| 91 | 91 | className="scale-container overflow-hidden" |
| 92 | 92 | style={{ |
| 93 | width: '100%', | |
| 94 | height: '100%', | |
| 95 | position: 'relative', | |
| 93 | width: "100%", | |
| 94 | height: "100%", | |
| 95 | position: "relative", | |
| 96 | 96 | }} |
| 97 | 97 | > |
| 98 | 98 | <iframe |
| @@ -100,14 +100,14 @@ export default function WebsiteFrame({ | ||
| 100 | 100 | key={url} |
| 101 | 101 | src={url} |
| 102 | 102 | title={title} |
| 103 | className={`transition-all duration-500 ${isIframeLoaded ? 'opacity-100' : 'opacity-0'}`} | |
| 103 | className={`transition-all duration-500 ${isIframeLoaded ? "opacity-100" : "opacity-0"}`} | |
| 104 | 104 | style={{ |
| 105 | 105 | width: `${dimensions.width}px`, |
| 106 | 106 | height: `${dimensions.height}px`, |
| 107 | 107 | border: 0, |
| 108 | 108 | transform: `scale(${scale})`, |
| 109 | transformOrigin: '0 0', | |
| 110 | position: 'absolute', | |
| 109 | transformOrigin: "0 0", | |
| 110 | position: "absolute", | |
| 111 | 111 | top: 0, |
| 112 | 112 | left: 0, |
| 113 | 113 | }} |
| @@ -118,24 +118,31 @@ export default function WebsiteFrame({ | ||
| 118 | 118 | </div> |
| 119 | 119 | |
| 120 | 120 | {showControls && ( |
| 121 | <motion.div | |
| 121 | <motion.div | |
| 122 | 122 | initial={{ opacity: 0, y: 10 }} |
| 123 | 123 | animate={{ opacity: 1, y: 0 }} |
| 124 | 124 | transition={{ duration: 0.3 }} |
| 125 | 125 | className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-2 rounded-full bg-black/70 backdrop-blur px-3 py-1.5 border border-blue-500 animate-glow" |
| 126 | 126 | > |
| 127 | <Button onClick={handleZoomOut} size="icon" variant="ghost" className="h-8 w-8 text-blue-300 hover:text-blue-100 hover:bg-blue-900/50"> | |
| 127 | <Button | |
| 128 | onClick={handleZoomOut} | |
| 129 | size="icon" | |
| 130 | variant="ghost" | |
| 131 | className="h-8 w-8 text-blue-300 hover:text-blue-100 hover:bg-blue-900/50" | |
| 132 | > | |
| 128 | 133 | <ZoomOut className="h-4 w-4" /> |
| 129 | 134 | </Button> |
| 130 | 135 | |
| 131 | 136 | <div className="w-24 h-1.5 bg-blue-900/50 rounded-full overflow-hidden"> |
| 132 | <div | |
| 133 | className="h-full bg-blue-500" | |
| 134 | style={{ width: `${scale * 100}%` }} | |
| 135 | /> | |
| 137 | <div className="h-full bg-blue-500" style={{ width: `${scale * 100}%` }} /> | |
| 136 | 138 | </div> |
| 137 | 139 | |
| 138 | <Button onClick={handleZoomIn} size="icon" variant="ghost" className="h-8 w-8 text-blue-300 hover:text-blue-100 hover:bg-blue-900/50"> | |
| 140 | <Button | |
| 141 | onClick={handleZoomIn} | |
| 142 | size="icon" | |
| 143 | variant="ghost" | |
| 144 | className="h-8 w-8 text-blue-300 hover:text-blue-100 hover:bg-blue-900/50" | |
| 145 | > | |
| 139 | 146 | <ZoomIn className="h-4 w-4" /> |
| 140 | 147 | </Button> |
| 141 | 148 |
src/components/WebsiteGallery.tsx +24 −26
| @@ -1,11 +1,11 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import { useState, useRef, useEffect } from 'react' | |
| 4 | import { motion, AnimatePresence } from 'framer-motion' | |
| 5 | import { Card, CardContent } from '@/components/ui/card' | |
| 6 | import { Button } from '@/components/ui/button' | |
| 7 | import { ExternalLink, Maximize2, ChevronLeft, ChevronRight, X } from 'lucide-react' | |
| 8 | import WebsiteFrame from './WebsiteFrame' | |
| 3 | import { AnimatePresence, motion } from "framer-motion" | |
| 4 | import { ChevronLeft, ChevronRight, ExternalLink, Maximize2, X } from "lucide-react" | |
| 5 | import { useEffect, useRef, useState } from "react" | |
| 6 | import { Button } from "@/components/ui/button" | |
| 7 | import { Card, CardContent } from "@/components/ui/card" | |
| 8 | import WebsiteFrame from "./WebsiteFrame" | |
| 9 | 9 | |
| 10 | 10 | interface Website { |
| 11 | 11 | id: string |
| @@ -31,7 +31,7 @@ function chunkArray<T>(array: T[], size: number): T[][] { | ||
| 31 | 31 | export default function WebsiteGallery({ websites }: WebsiteGalleryProps) { |
| 32 | 32 | // Responsive chunking based on screen size |
| 33 | 33 | const [itemsPerSection, setItemsPerSection] = useState(3) |
| 34 | ||
| 34 | ||
| 35 | 35 | useEffect(() => { |
| 36 | 36 | const updateSectionSize = () => { |
| 37 | 37 | if (window.innerWidth < 640) { |
| @@ -42,21 +42,21 @@ export default function WebsiteGallery({ websites }: WebsiteGalleryProps) { | ||
| 42 | 42 | setItemsPerSection(3) // Show 3 items per section on desktop |
| 43 | 43 | } |
| 44 | 44 | } |
| 45 | ||
| 45 | ||
| 46 | 46 | updateSectionSize() |
| 47 | window.addEventListener('resize', updateSectionSize) | |
| 48 | return () => window.removeEventListener('resize', updateSectionSize) | |
| 47 | window.addEventListener("resize", updateSectionSize) | |
| 48 | return () => window.removeEventListener("resize", updateSectionSize) | |
| 49 | 49 | }, []) |
| 50 | ||
| 50 | ||
| 51 | 51 | // Group websites into sections based on responsive size |
| 52 | 52 | const websiteSections = chunkArray(websites, itemsPerSection) |
| 53 | 53 | |
| 54 | 54 | const [activeSectionIndex, setActiveSectionIndex] = useState(0) |
| 55 | 55 | const [expandedView, setExpandedView] = useState<Website | null>(null) |
| 56 | 56 | const [isLoading, setIsLoading] = useState<Record<string, boolean>>({}) |
| 57 | const [touchStart, setTouchStart] = useState<number | null>(null) | |
| 58 | const [touchEnd, setTouchEnd] = useState<number | null>(null) | |
| 59 | const [isMobile, setIsMobile] = useState(false) | |
| 57 | const [_touchStart, _setTouchStart] = useState<number | null>(null) | |
| 58 | const [_touchEnd, _setTouchEnd] = useState<number | null>(null) | |
| 59 | const [_isMobile, setIsMobile] = useState(false) | |
| 60 | 60 | const carouselRef = useRef<HTMLDivElement>(null) |
| 61 | 61 | |
| 62 | 62 | // Check if we're on mobile |
| @@ -66,10 +66,10 @@ export default function WebsiteGallery({ websites }: WebsiteGalleryProps) { | ||
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | checkMobile() |
| 69 | window.addEventListener('resize', checkMobile) | |
| 69 | window.addEventListener("resize", checkMobile) | |
| 70 | 70 | |
| 71 | 71 | return () => { |
| 72 | window.removeEventListener('resize', checkMobile) | |
| 72 | window.removeEventListener("resize", checkMobile) | |
| 73 | 73 | } |
| 74 | 74 | }, []) |
| 75 | 75 | |
| @@ -98,7 +98,7 @@ export default function WebsiteGallery({ websites }: WebsiteGalleryProps) { | ||
| 98 | 98 | }, 8000) |
| 99 | 99 | |
| 100 | 100 | return () => clearInterval(interval) |
| 101 | }, [activeSectionIndex]) | |
| 101 | }, [handleNext]) | |
| 102 | 102 | |
| 103 | 103 | return ( |
| 104 | 104 | <div className="w-full"> |
| @@ -189,7 +189,7 @@ export default function WebsiteGallery({ websites }: WebsiteGalleryProps) { | ||
| 189 | 189 | className="h-8 w-8 opacity-80 hover:opacity-100 bg-blue-900/70 hover:bg-blue-800/70 border border-blue-500 text-blue-300" |
| 190 | 190 | onClick={(e) => { |
| 191 | 191 | e.stopPropagation() |
| 192 | window.open(website.url, '_blank') | |
| 192 | window.open(website.url, "_blank") | |
| 193 | 193 | }} |
| 194 | 194 | aria-label="Open in new tab" |
| 195 | 195 | > |
| @@ -231,29 +231,27 @@ export default function WebsiteGallery({ websites }: WebsiteGalleryProps) { | ||
| 231 | 231 | key={index} |
| 232 | 232 | className={`w-2.5 h-2.5 rounded-full transition-all ${ |
| 233 | 233 | index === activeSectionIndex |
| 234 | ? 'bg-blue-500 scale-125' | |
| 235 | : 'bg-blue-900 hover:bg-blue-700' | |
| 234 | ? "bg-blue-500 scale-125" | |
| 235 | : "bg-blue-900 hover:bg-blue-700" | |
| 236 | 236 | }`} |
| 237 | 237 | onClick={() => setActiveSectionIndex(index)} |
| 238 | 238 | aria-label={`View section ${index + 1}`} |
| 239 | 239 | /> |
| 240 | 240 | ))} |
| 241 | 241 | </div> |
| 242 | ||
| 243 | ||
| 244 | 242 | </motion.div> |
| 245 | 243 | |
| 246 | 244 | {/* Expanded View Modal */} |
| 247 | 245 | <AnimatePresence> |
| 248 | 246 | {expandedView && ( |
| 249 | <motion.div | |
| 247 | <motion.div | |
| 250 | 248 | initial={{ opacity: 0 }} |
| 251 | 249 | animate={{ opacity: 1 }} |
| 252 | 250 | exit={{ opacity: 0 }} |
| 253 | 251 | transition={{ duration: 0.3 }} |
| 254 | 252 | className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm" |
| 255 | 253 | > |
| 256 | <motion.div | |
| 254 | <motion.div | |
| 257 | 255 | initial={{ scale: 0.9, opacity: 0 }} |
| 258 | 256 | animate={{ scale: 1, opacity: 1 }} |
| 259 | 257 | exit={{ scale: 0.9, opacity: 0 }} |
| @@ -266,7 +264,7 @@ export default function WebsiteGallery({ websites }: WebsiteGalleryProps) { | ||
| 266 | 264 | <Button |
| 267 | 265 | size="icon" |
| 268 | 266 | variant="ghost" |
| 269 | onClick={() => window.open(expandedView.url, '_blank')} | |
| 267 | onClick={() => window.open(expandedView.url, "_blank")} | |
| 270 | 268 | aria-label="Open in new tab" |
| 271 | 269 | className="text-blue-300 hover:text-blue-100 hover:bg-blue-900/50" |
| 272 | 270 | > |
src/components/playground/CodeEditor.tsx +7 −5
| @@ -1,11 +1,12 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React, { useState } from 'react' | |
| 4 | import dynamic from 'next/dynamic' | |
| 3 | import dynamic from "next/dynamic" | |
| 4 | import type React from "react" | |
| 5 | import { useState } from "react" | |
| 5 | 6 | import { Card } from "@/components/ui/card" |
| 6 | 7 | |
| 7 | 8 | const DynamicCodeEditor = dynamic( |
| 8 | () => import('@uiw/react-textarea-code-editor').then((mod) => mod.default), | |
| 9 | () => import("@uiw/react-textarea-code-editor").then((mod) => mod.default), | |
| 9 | 10 | { ssr: false } |
| 10 | 11 | ) |
| 11 | 12 | |
| @@ -25,7 +26,8 @@ export const CodeEditor: React.FC = () => { | ||
| 25 | 26 | style={{ |
| 26 | 27 | fontSize: 12, |
| 27 | 28 | backgroundColor: "transparent", |
| 28 | fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace', | |
| 29 | fontFamily: | |
| 30 | "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace", | |
| 29 | 31 | }} |
| 30 | 32 | className="min-h-[300px] text-white" |
| 31 | 33 | /> |
src/components/playground/ColorPicker.tsx +8 −8
| @@ -1,6 +1,7 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React, { useState } from 'react' | |
| 3 | import type React from "react" | |
| 4 | import { useState } from "react" | |
| 4 | 5 | import { Card } from "@/components/ui/card" |
| 5 | 6 | import { Input } from "@/components/ui/input" |
| 6 | 7 | import { Label } from "@/components/ui/label" |
| @@ -11,11 +12,10 @@ export const ColorPicker: React.FC = () => { | ||
| 11 | 12 | return ( |
| 12 | 13 | <Card className="bg-black/50 backdrop-blur-md border-blue-500 p-4"> |
| 13 | 14 | <div className="flex flex-col items-center space-y-4"> |
| 14 | <div | |
| 15 | className="w-32 h-32 rounded-full" | |
| 16 | style={{ backgroundColor: color }} | |
| 17 | /> | |
| 18 | <Label htmlFor="color-input" className="text-white">Select a color:</Label> | |
| 15 | <div className="w-32 h-32 rounded-full" style={{ backgroundColor: color }} /> | |
| 16 | <Label htmlFor="color-input" className="text-white"> | |
| 17 | Select a color: | |
| 18 | </Label> | |
| 19 | 19 | <Input |
| 20 | 20 | id="color-input" |
| 21 | 21 | type="color" |
| @@ -27,4 +27,4 @@ export const ColorPicker: React.FC = () => { | ||
| 27 | 27 | </div> |
| 28 | 28 | </Card> |
| 29 | 29 | ) |
| 30 | }↵ | |
| 30 | } |
src/components/playground/FunctionCreator.tsx +149 −104
| @@ -1,18 +1,19 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React, { useState } from 'react' | |
| 4 | import dynamic from 'next/dynamic' | |
| 5 | import { Card } from "@/components/ui/card" | |
| 3 | import { ClockIcon, CodeIcon, PlayIcon, SettingsIcon, XIcon } from "lucide-react" | |
| 4 | import dynamic from "next/dynamic" | |
| 5 | import type React from "react" | |
| 6 | import { useState } from "react" | |
| 6 | 7 | import { Button } from "@/components/ui/button" |
| 7 | import { PlayIcon, ClockIcon, CodeIcon, SettingsIcon, XIcon } from 'lucide-react' | |
| 8 | import { toast } from '@/hooks/use-toast' | |
| 8 | import { Card } from "@/components/ui/card" | |
| 9 | import { toast } from "@/hooks/use-toast" | |
| 9 | 10 | |
| 10 | 11 | const DynamicCodeEditor = dynamic( |
| 11 | () => import('@uiw/react-textarea-code-editor').then((mod) => mod.default), | |
| 12 | () => import("@uiw/react-textarea-code-editor").then((mod) => mod.default), | |
| 12 | 13 | { ssr: false } |
| 13 | 14 | ) |
| 14 | 15 | |
| 15 | type Language = 'typescript' | 'python'; | |
| 16 | type Language = "typescript" | "python" | |
| 16 | 17 | |
| 17 | 18 | const defaultCode = { |
| 18 | 19 | typescript: `// TypeScript Example |
| @@ -57,148 +58,175 @@ print(asyncio.run(fetch_data())) | ||
| 57 | 58 | |
| 58 | 59 | # Access environment variables |
| 59 | 60 | import os |
| 60 | print(f"Environment: {os.getenv('ENVIRONMENT', 'development')}")` | |
| 61 | }; | |
| 61 | print(f"Environment: {os.getenv('ENVIRONMENT', 'development')}")`, | |
| 62 | } | |
| 62 | 63 | |
| 63 | 64 | interface ExecutionResult { |
| 64 | success: boolean; | |
| 65 | output?: string; | |
| 66 | error?: string; | |
| 67 | exitCode?: number; | |
| 65 | success: boolean | |
| 66 | output?: string | |
| 67 | error?: string | |
| 68 | exitCode?: number | |
| 68 | 69 | } |
| 69 | 70 | |
| 70 | 71 | interface EnvVar { |
| 71 | key: string; | |
| 72 | value: string; | |
| 72 | key: string | |
| 73 | value: string | |
| 73 | 74 | } |
| 74 | 75 | |
| 75 | 76 | export const FunctionCreator: React.FC = () => { |
| 76 | const [language, setLanguage] = useState<Language>('typescript'); | |
| 77 | const [code, setCode] = useState(defaultCode.typescript); | |
| 78 | const [isExecuting, setIsExecuting] = useState(false); | |
| 79 | const [isScheduling, setIsScheduling] = useState(false); | |
| 80 | const [output, setOutput] = useState<string>(''); | |
| 81 | const [cronExpression, setCronExpression] = useState('0 * * * *'); // Every hour | |
| 82 | const [showScheduler, setShowScheduler] = useState(false); | |
| 83 | const [showEnvVars, setShowEnvVars] = useState(false); | |
| 84 | const [envVars, setEnvVars] = useState<EnvVar[]>([{ key: '', value: '' }]); | |
| 77 | const [language, setLanguage] = useState<Language>("typescript") | |
| 78 | const [code, setCode] = useState(defaultCode.typescript) | |
| 79 | const [isExecuting, setIsExecuting] = useState(false) | |
| 80 | const [isScheduling, setIsScheduling] = useState(false) | |
| 81 | const [output, setOutput] = useState<string>("") | |
| 82 | const [functionName, setFunctionName] = useState("") | |
| 83 | const [cronExpression, setCronExpression] = useState("0 * * * *") // Every hour | |
| 84 | const [showScheduler, setShowScheduler] = useState(false) | |
| 85 | const [showEnvVars, setShowEnvVars] = useState(false) | |
| 86 | const [envVars, setEnvVars] = useState<EnvVar[]>([{ key: "", value: "" }]) | |
| 85 | 87 | |
| 86 | 88 | const handleLanguageChange = (newLanguage: Language) => { |
| 87 | setLanguage(newLanguage); | |
| 88 | setCode(defaultCode[newLanguage]); | |
| 89 | setOutput(''); | |
| 90 | }; | |
| 89 | setLanguage(newLanguage) | |
| 90 | setCode(defaultCode[newLanguage]) | |
| 91 | setOutput("") | |
| 92 | } | |
| 91 | 93 | |
| 92 | 94 | const addEnvVar = () => { |
| 93 | setEnvVars([...envVars, { key: '', value: '' }]); | |
| 94 | }; | |
| 95 | setEnvVars([...envVars, { key: "", value: "" }]) | |
| 96 | } | |
| 95 | 97 | |
| 96 | 98 | const removeEnvVar = (index: number) => { |
| 97 | setEnvVars(envVars.filter((_, i) => i !== index)); | |
| 98 | }; | |
| 99 | setEnvVars(envVars.filter((_, i) => i !== index)) | |
| 100 | } | |
| 99 | 101 | |
| 100 | const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => { | |
| 101 | const newEnvVars = [...envVars]; | |
| 102 | newEnvVars[index][field] = value; | |
| 103 | setEnvVars(newEnvVars); | |
| 104 | }; | |
| 102 | const updateEnvVar = (index: number, field: "key" | "value", value: string) => { | |
| 103 | const newEnvVars = [...envVars] | |
| 104 | newEnvVars[index][field] = value | |
| 105 | setEnvVars(newEnvVars) | |
| 106 | } | |
| 105 | 107 | |
| 106 | 108 | const getEnvVarsObject = () => { |
| 107 | return envVars.reduce((acc, { key, value }) => { | |
| 108 | if (key.trim()) { | |
| 109 | acc[key.trim()] = value; | |
| 110 | } | |
| 111 | return acc; | |
| 112 | }, {} as Record<string, string>); | |
| 113 | }; | |
| 109 | return envVars.reduce( | |
| 110 | (acc, { key, value }) => { | |
| 111 | if (key.trim()) { | |
| 112 | acc[key.trim()] = value | |
| 113 | } | |
| 114 | return acc | |
| 115 | }, | |
| 116 | {} as Record<string, string> | |
| 117 | ) | |
| 118 | } | |
| 114 | 119 | |
| 115 | 120 | const handleRun = async () => { |
| 116 | setIsExecuting(true); | |
| 117 | setOutput('Executing code...'); | |
| 121 | setIsExecuting(true) | |
| 122 | setOutput("Executing code...") | |
| 118 | 123 | |
| 119 | 124 | try { |
| 120 | const response = await fetch('/api/sandbox/execute', { | |
| 121 | method: 'POST', | |
| 125 | const response = await fetch("/api/sandbox/execute", { | |
| 126 | method: "POST", | |
| 122 | 127 | headers: { |
| 123 | 'Content-Type': 'application/json', | |
| 128 | "Content-Type": "application/json", | |
| 124 | 129 | }, |
| 125 | 130 | body: JSON.stringify({ |
| 126 | 131 | code, |
| 127 | 132 | language, |
| 128 | 133 | envVars: getEnvVarsObject(), |
| 129 | 134 | }), |
| 130 | }); | |
| 135 | }) | |
| 131 | 136 | |
| 132 | const result: ExecutionResult = await response.json(); | |
| 137 | const result: ExecutionResult = await response.json() | |
| 133 | 138 | |
| 134 | 139 | if (result.success) { |
| 135 | setOutput(result.output || 'Code executed successfully!'); | |
| 140 | setOutput(result.output || "Code executed successfully!") | |
| 136 | 141 | toast({ |
| 137 | 142 | title: "Success", |
| 138 | 143 | description: "Code executed successfully", |
| 139 | }); | |
| 144 | }) | |
| 140 | 145 | } else { |
| 141 | setOutput(`Error (exit code ${result.exitCode}):\n${result.error || 'Unknown error'}`); | |
| 146 | setOutput(`Error (exit code ${result.exitCode}):\n${result.error || "Unknown error"}`) | |
| 142 | 147 | toast({ |
| 143 | 148 | title: "Execution Error", |
| 144 | 149 | description: "Check the output panel for details", |
| 145 | 150 | variant: "destructive", |
| 146 | }); | |
| 151 | }) | |
| 147 | 152 | } |
| 148 | 153 | } catch (error) { |
| 149 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; | |
| 150 | setOutput(`Failed to execute code: ${errorMessage}`); | |
| 154 | const errorMessage = error instanceof Error ? error.message : "Unknown error" | |
| 155 | setOutput(`Failed to execute code: ${errorMessage}`) | |
| 151 | 156 | toast({ |
| 152 | 157 | title: "Error", |
| 153 | 158 | description: "Failed to execute code", |
| 154 | 159 | variant: "destructive", |
| 155 | }); | |
| 160 | }) | |
| 156 | 161 | } finally { |
| 157 | setIsExecuting(false); | |
| 162 | setIsExecuting(false) | |
| 158 | 163 | } |
| 159 | }; | |
| 164 | } | |
| 160 | 165 | |
| 161 | 166 | const handleSchedule = async () => { |
| 162 | setIsScheduling(true); | |
| 167 | if (!functionName.trim()) { | |
| 168 | toast({ | |
| 169 | title: "Name Required", | |
| 170 | description: "Please provide a name for this scheduled function", | |
| 171 | variant: "destructive", | |
| 172 | }) | |
| 173 | return | |
| 174 | } | |
| 175 | ||
| 176 | setIsScheduling(true) | |
| 163 | 177 | |
| 164 | 178 | try { |
| 165 | const response = await fetch('/api/sandbox/schedule', { | |
| 166 | method: 'POST', | |
| 179 | // Convert envVars array to object | |
| 180 | const envVarsObject = envVars.reduce( | |
| 181 | (acc, { key, value }) => { | |
| 182 | if (key.trim()) { | |
| 183 | acc[key] = value | |
| 184 | } | |
| 185 | return acc | |
| 186 | }, | |
| 187 | {} as Record<string, string> | |
| 188 | ) | |
| 189 | ||
| 190 | const response = await fetch("/api/sandbox/schedule", { | |
| 191 | method: "POST", | |
| 167 | 192 | headers: { |
| 168 | 'Content-Type': 'application/json', | |
| 193 | "Content-Type": "application/json", | |
| 169 | 194 | }, |
| 170 | 195 | body: JSON.stringify({ |
| 196 | name: functionName, | |
| 171 | 197 | code, |
| 172 | 198 | language, |
| 173 | 199 | cronExpression, |
| 200 | envVars: envVarsObject, | |
| 174 | 201 | }), |
| 175 | }); | |
| 202 | }) | |
| 176 | 203 | |
| 177 | const result = await response.json(); | |
| 204 | const result = await response.json() | |
| 178 | 205 | |
| 179 | 206 | if (result.success) { |
| 180 | 207 | toast({ |
| 181 | title: "Scheduled", | |
| 182 | description: `Function scheduled with cron: ${cronExpression}`, | |
| 183 | }); | |
| 184 | setShowScheduler(false); | |
| 208 | title: "Scheduled Successfully", | |
| 209 | description: `${functionName} will run ${cronExpression}`, | |
| 210 | }) | |
| 211 | setShowScheduler(false) | |
| 212 | setFunctionName("") | |
| 185 | 213 | } else { |
| 186 | 214 | toast({ |
| 187 | 215 | title: "Scheduling Error", |
| 188 | 216 | description: result.error || "Failed to schedule function", |
| 189 | 217 | variant: "destructive", |
| 190 | }); | |
| 218 | }) | |
| 191 | 219 | } |
| 192 | } catch (error) { | |
| 220 | } catch (_error) { | |
| 193 | 221 | toast({ |
| 194 | 222 | title: "Error", |
| 195 | 223 | description: "Failed to schedule function", |
| 196 | 224 | variant: "destructive", |
| 197 | }); | |
| 225 | }) | |
| 198 | 226 | } finally { |
| 199 | setIsScheduling(false); | |
| 227 | setIsScheduling(false) | |
| 200 | 228 | } |
| 201 | }; | |
| 229 | } | |
| 202 | 230 | |
| 203 | 231 | return ( |
| 204 | 232 | <div className="space-y-4"> |
| @@ -206,18 +234,18 @@ export const FunctionCreator: React.FC = () => { | ||
| 206 | 234 | <div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between"> |
| 207 | 235 | <div className="flex flex-wrap gap-2"> |
| 208 | 236 | <Button |
| 209 | variant={language === 'typescript' ? 'default' : 'outline'} | |
| 237 | variant={language === "typescript" ? "default" : "outline"} | |
| 210 | 238 | size="sm" |
| 211 | onClick={() => handleLanguageChange('typescript')} | |
| 239 | onClick={() => handleLanguageChange("typescript")} | |
| 212 | 240 | className="transition-all duration-200" |
| 213 | 241 | > |
| 214 | 242 | <CodeIcon className="w-4 h-4 mr-2" /> |
| 215 | 243 | TypeScript |
| 216 | 244 | </Button> |
| 217 | 245 | <Button |
| 218 | variant={language === 'python' ? 'default' : 'outline'} | |
| 246 | variant={language === "python" ? "default" : "outline"} | |
| 219 | 247 | size="sm" |
| 220 | onClick={() => handleLanguageChange('python')} | |
| 248 | onClick={() => handleLanguageChange("python")} | |
| 221 | 249 | className="transition-all duration-200" |
| 222 | 250 | > |
| 223 | 251 | <CodeIcon className="w-4 h-4 mr-2" /> |
| @@ -233,7 +261,7 @@ export const FunctionCreator: React.FC = () => { | ||
| 233 | 261 | size="sm" |
| 234 | 262 | > |
| 235 | 263 | <PlayIcon className="w-4 h-4 mr-2" /> |
| 236 | {isExecuting ? 'Running...' : 'Run'} | |
| 264 | {isExecuting ? "Running..." : "Run"} | |
| 237 | 265 | </Button> |
| 238 | 266 | <Button |
| 239 | 267 | onClick={() => setShowEnvVars(!showEnvVars)} |
| @@ -268,14 +296,14 @@ export const FunctionCreator: React.FC = () => { | ||
| 268 | 296 | <input |
| 269 | 297 | type="text" |
| 270 | 298 | value={envVar.key} |
| 271 | onChange={(e) => updateEnvVar(index, 'key', e.target.value)} | |
| 299 | onChange={(e) => updateEnvVar(index, "key", e.target.value)} | |
| 272 | 300 | placeholder="KEY" |
| 273 | 301 | className="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" |
| 274 | 302 | /> |
| 275 | 303 | <input |
| 276 | 304 | type="text" |
| 277 | 305 | value={envVar.value} |
| 278 | onChange={(e) => updateEnvVar(index, 'value', e.target.value)} | |
| 306 | onChange={(e) => updateEnvVar(index, "value", e.target.value)} | |
| 279 | 307 | placeholder="value" |
| 280 | 308 | className="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" |
| 281 | 309 | /> |
| @@ -295,12 +323,7 @@ export const FunctionCreator: React.FC = () => { | ||
| 295 | 323 | ))} |
| 296 | 324 | </div> |
| 297 | 325 | <div className="flex flex-col sm:flex-row gap-2"> |
| 298 | <Button | |
| 299 | onClick={addEnvVar} | |
| 300 | variant="outline" | |
| 301 | size="sm" | |
| 302 | className="w-full sm:w-auto" | |
| 303 | > | |
| 326 | <Button onClick={addEnvVar} variant="outline" size="sm" className="w-full sm:w-auto"> | |
| 304 | 327 | Add Variable |
| 305 | 328 | </Button> |
| 306 | 329 | <Button |
| @@ -321,6 +344,20 @@ export const FunctionCreator: React.FC = () => { | ||
| 321 | 344 | <Card className="bg-black/50 backdrop-blur-md border-blue-500 p-4 animate-in slide-in-from-top-2 duration-200"> |
| 322 | 345 | <div className="space-y-3"> |
| 323 | 346 | <h3 className="text-lg font-semibold text-white">Schedule Function</h3> |
| 347 | ||
| 348 | <div className="space-y-2"> | |
| 349 | <label className="text-sm text-gray-300"> | |
| 350 | Function Name | |
| 351 | </label> | |
| 352 | <input | |
| 353 | type="text" | |
| 354 | value={functionName} | |
| 355 | onChange={(e) => setFunctionName(e.target.value)} | |
| 356 | placeholder="My Daily Task" | |
| 357 | className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| 358 | /> | |
| 359 | </div> | |
| 360 | ||
| 324 | 361 | <div className="space-y-2"> |
| 325 | 362 | <label className="text-sm text-gray-300"> |
| 326 | 363 | Cron Expression |
| @@ -336,10 +373,20 @@ export const FunctionCreator: React.FC = () => { | ||
| 336 | 373 | <div className="text-xs text-gray-400 space-y-1"> |
| 337 | 374 | <p>Examples:</p> |
| 338 | 375 | <ul className="list-disc list-inside space-y-0.5"> |
| 339 | <li><code className="bg-gray-800 px-1 rounded">0 * * * *</code> - Every hour</li> | |
| 340 | <li><code className="bg-gray-800 px-1 rounded">*/15 * * * *</code> - Every 15 minutes</li> | |
| 341 | <li><code className="bg-gray-800 px-1 rounded">0 0 * * *</code> - Daily at midnight</li> | |
| 342 | <li><code className="bg-gray-800 px-1 rounded">0 9 * * 1</code> - Every Monday at 9 AM</li> | |
| 376 | <li> | |
| 377 | <code className="bg-gray-800 px-1 rounded">0 * * * *</code> - Every hour | |
| 378 | </li> | |
| 379 | <li> | |
| 380 | <code className="bg-gray-800 px-1 rounded">*/15 * * * *</code> - Every 15 | |
| 381 | minutes | |
| 382 | </li> | |
| 383 | <li> | |
| 384 | <code className="bg-gray-800 px-1 rounded">0 0 * * *</code> - Daily at midnight | |
| 385 | </li> | |
| 386 | <li> | |
| 387 | <code className="bg-gray-800 px-1 rounded">0 9 * * 1</code> - Every Monday at 9 | |
| 388 | AM | |
| 389 | </li> | |
| 343 | 390 | </ul> |
| 344 | 391 | </div> |
| 345 | 392 | </div> |
| @@ -350,13 +397,9 @@ export const FunctionCreator: React.FC = () => { | ||
| 350 | 397 | className="bg-blue-600 hover:bg-blue-700" |
| 351 | 398 | size="sm" |
| 352 | 399 | > |
| 353 | {isScheduling ? 'Scheduling...' : 'Schedule Function'} | |
| 400 | {isScheduling ? "Scheduling..." : "Schedule Function"} | |
| 354 | 401 | </Button> |
| 355 | <Button | |
| 356 | onClick={() => setShowScheduler(false)} | |
| 357 | variant="outline" | |
| 358 | size="sm" | |
| 359 | > | |
| 402 | <Button onClick={() => setShowScheduler(false)} variant="outline" size="sm"> | |
| 360 | 403 | Cancel |
| 361 | 404 | </Button> |
| 362 | 405 | </div> |
| @@ -368,15 +411,16 @@ export const FunctionCreator: React.FC = () => { | ||
| 368 | 411 | <Card className="bg-black/50 backdrop-blur-md border-blue-500 p-4 overflow-hidden"> |
| 369 | 412 | <DynamicCodeEditor |
| 370 | 413 | value={code} |
| 371 | language={language === 'typescript' ? 'tsx' : 'py'} | |
| 372 | placeholder={`Enter ${language === 'typescript' ? 'TypeScript' : 'Python'} code...`} | |
| 414 | language={language === "typescript" ? "tsx" : "py"} | |
| 415 | placeholder={`Enter ${language === "typescript" ? "TypeScript" : "Python"} code...`} | |
| 373 | 416 | onChange={(evn) => setCode(evn.target.value)} |
| 374 | 417 | padding={15} |
| 375 | 418 | style={{ |
| 376 | 419 | fontSize: 14, |
| 377 | 420 | backgroundColor: "transparent", |
| 378 | fontFamily: 'ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace', | |
| 379 | minHeight: '300px', | |
| 421 | fontFamily: | |
| 422 | "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace", | |
| 423 | minHeight: "300px", | |
| 380 | 424 | }} |
| 381 | 425 | className="text-white rounded overflow-auto" |
| 382 | 426 | /> |
| @@ -402,7 +446,8 @@ export const FunctionCreator: React.FC = () => { | ||
| 402 | 446 | <p className="flex items-start gap-2"> |
| 403 | 447 | <span className="text-blue-400">ℹ️</span> |
| 404 | 448 | <span> |
| 405 | Your code runs in an isolated, secure environment. Each execution is ephemeral and cannot access external resources without explicit configuration. | |
| 449 | Your code runs in an isolated, secure environment. Each execution is ephemeral and | |
| 450 | cannot access external resources without explicit configuration. | |
| 406 | 451 | </span> |
| 407 | 452 | </p> |
| 408 | 453 | </div> |
src/components/playground/ObjectViewer.tsx +14 −7
| @@ -1,9 +1,10 @@ | ||
| 1 | 'use client' | |
| 1 | "use client" | |
| 2 | 2 | |
| 3 | import React, { Suspense, useRef } from 'react' | |
| 4 | import { Canvas, useFrame, useLoader } from '@react-three/fiber' | |
| 5 | import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei' | |
| 6 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' | |
| 3 | import { Environment, OrbitControls, PerspectiveCamera } from "@react-three/drei" | |
| 4 | import { Canvas, useLoader } from "@react-three/fiber" | |
| 5 | import type React from "react" | |
| 6 | import { Suspense, useRef } from "react" | |
| 7 | import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js" | |
| 7 | 8 | import { Card } from "@/components/ui/card" |
| 8 | 9 | |
| 9 | 10 | const LoadingSpinner: React.FC = () => ( |
| @@ -14,7 +15,7 @@ const LoadingSpinner: React.FC = () => ( | ||
| 14 | 15 | |
| 15 | 16 | export const ObjectViewer: React.FC = () => { |
| 16 | 17 | const ChicagoModel = () => { |
| 17 | const gltf = useLoader(GLTFLoader, '/chicago.glb') | |
| 18 | const gltf = useLoader(GLTFLoader, "/chicago.glb") | |
| 18 | 19 | const modelRef = useRef() |
| 19 | 20 | |
| 20 | 21 | return <primitive object={gltf.scene} ref={modelRef} scale={[0.01, 0.01, 0.01]} /> |
| @@ -31,7 +32,13 @@ export const ObjectViewer: React.FC = () => { | ||
| 31 | 32 | <PerspectiveCamera makeDefault position={[0, 50, 100]} /> |
| 32 | 33 | <Environment preset="city" background={false} /> |
| 33 | 34 | <ChicagoModel /> |
| 34 | <OrbitControls target={[0, 0, 0]} maxPolarAngle={Math.PI / 2} enableZoom={true} minDistance={1} maxDistance={1000} /> | |
| 35 | <OrbitControls | |
| 36 | target={[0, 0, 0]} | |
| 37 | maxPolarAngle={Math.PI / 2} | |
| 38 | enableZoom={true} | |
| 39 | minDistance={1} | |
| 40 | maxDistance={1000} | |
| 41 | /> | |
| 35 | 42 | </Canvas> |
| 36 | 43 | </Suspense> |
| 37 | 44 | </div> |
src/components/ui/avatar.tsx +2 −5
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use client" |
| 2 | 2 | |
| 3 | import * as React from "react" | |
| 4 | 3 | import * as AvatarPrimitive from "@radix-ui/react-avatar" |
| 4 | import * as React from "react" | |
| 5 | 5 | |
| 6 | 6 | import { cn } from "@/lib/utils" |
| 7 | 7 | |
| @@ -11,10 +11,7 @@ const Avatar = React.forwardRef< | ||
| 11 | 11 | >(({ className, ...props }, ref) => ( |
| 12 | 12 | <AvatarPrimitive.Root |
| 13 | 13 | ref={ref} |
| 14 | className={cn( | |
| 15 | "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", | |
| 16 | className | |
| 17 | )} | |
| 14 | className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)} | |
| 18 | 15 | {...props} |
| 19 | 16 | /> |
| 20 | 17 | )) |
src/components/ui/button.tsx +5 −12
| @@ -1,6 +1,6 @@ | ||
| 1 | import * as React from "react" | |
| 2 | 1 | import { Slot } from "@radix-ui/react-slot" |
| 3 | 2 | import { cva, type VariantProps } from "class-variance-authority" |
| 3 | import * as React from "react" | |
| 4 | 4 | |
| 5 | 5 | import { cn } from "@/lib/utils" |
| 6 | 6 | |
| @@ -9,14 +9,11 @@ const buttonVariants = cva( | ||
| 9 | 9 | { |
| 10 | 10 | variants: { |
| 11 | 11 | variant: { |
| 12 | default: | |
| 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", | |
| 14 | destructive: | |
| 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", | |
| 12 | default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", | |
| 13 | destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", | |
| 16 | 14 | outline: |
| 17 | 15 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", |
| 18 | secondary: | |
| 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", | |
| 16 | secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", | |
| 20 | 17 | ghost: "hover:bg-accent hover:text-accent-foreground", |
| 21 | 18 | link: "text-primary underline-offset-4 hover:underline", |
| 22 | 19 | }, |
| @@ -44,11 +41,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( | ||
| 44 | 41 | ({ className, variant, size, asChild = false, ...props }, ref) => { |
| 45 | 42 | const Comp = asChild ? Slot : "button" |
| 46 | 43 | return ( |
| 47 | <Comp | |
| 48 | className={cn(buttonVariants({ variant, size, className }))} | |
| 49 | ref={ref} | |
| 50 | {...props} | |
| 51 | /> | |
| 44 | <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> | |
| 52 | 45 | ) |
| 53 | 46 | } |
| 54 | 47 | ) |
src/components/ui/card.tsx +34 −54
| @@ -2,75 +2,55 @@ import * as React from "react" | ||
| 2 | 2 | |
| 3 | 3 | import { cn } from "@/lib/utils" |
| 4 | 4 | |
| 5 | const Card = React.forwardRef< | |
| 6 | HTMLDivElement, | |
| 7 | React.HTMLAttributes<HTMLDivElement> | |
| 8 | >(({ className, ...props }, ref) => ( | |
| 9 | <div | |
| 10 | ref={ref} | |
| 11 | className={cn( | |
| 12 | "rounded-xl border bg-card text-card-foreground shadow", | |
| 13 | className | |
| 14 | )} | |
| 15 | {...props} | |
| 16 | /> | |
| 17 | )) | |
| 5 | const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( | |
| 6 | ({ className, ...props }, ref) => ( | |
| 7 | <div | |
| 8 | ref={ref} | |
| 9 | className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} | |
| 10 | {...props} | |
| 11 | /> | |
| 12 | ) | |
| 13 | ) | |
| 18 | 14 | Card.displayName = "Card" |
| 19 | 15 | |
| 20 | const CardHeader = React.forwardRef< | |
| 21 | HTMLDivElement, | |
| 22 | React.HTMLAttributes<HTMLDivElement> | |
| 23 | >(({ className, ...props }, ref) => ( | |
| 24 | <div | |
| 25 | ref={ref} | |
| 26 | className={cn("flex flex-col space-y-1.5 p-6", className)} | |
| 27 | {...props} | |
| 28 | /> | |
| 29 | )) | |
| 16 | const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( | |
| 17 | ({ className, ...props }, ref) => ( | |
| 18 | <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> | |
| 19 | ) | |
| 20 | ) | |
| 30 | 21 | CardHeader.displayName = "CardHeader" |
| 31 | 22 | |
| 32 | const CardTitle = React.forwardRef< | |
| 33 | HTMLParagraphElement, | |
| 34 | React.HTMLAttributes<HTMLHeadingElement> | |
| 35 | >(({ className, ...props }, ref) => ( | |
| 36 | <h3 | |
| 37 | ref={ref} | |
| 38 | className={cn("font-semibold leading-none tracking-tight", className)} | |
| 39 | {...props} | |
| 40 | /> | |
| 41 | )) | |
| 23 | const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( | |
| 24 | ({ className, ...props }, ref) => ( | |
| 25 | <h3 | |
| 26 | ref={ref} | |
| 27 | className={cn("font-semibold leading-none tracking-tight", className)} | |
| 28 | {...props} | |
| 29 | /> | |
| 30 | ) | |
| 31 | ) | |
| 42 | 32 | CardTitle.displayName = "CardTitle" |
| 43 | 33 | |
| 44 | 34 | const CardDescription = React.forwardRef< |
| 45 | 35 | HTMLParagraphElement, |
| 46 | 36 | React.HTMLAttributes<HTMLParagraphElement> |
| 47 | 37 | >(({ className, ...props }, ref) => ( |
| 48 | <p | |
| 49 | ref={ref} | |
| 50 | className={cn("text-sm text-muted-foreground", className)} | |
| 51 | {...props} | |
| 52 | /> | |
| 38 | <p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> | |
| 53 | 39 | )) |
| 54 | 40 | CardDescription.displayName = "CardDescription" |
| 55 | 41 | |
| 56 | const CardContent = React.forwardRef< | |
| 57 | HTMLDivElement, | |
| 58 | React.HTMLAttributes<HTMLDivElement> | |
| 59 | >(({ className, ...props }, ref) => ( | |
| 60 | <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> | |
| 61 | )) | |
| 42 | const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( | |
| 43 | ({ className, ...props }, ref) => ( | |
| 44 | <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> | |
| 45 | ) | |
| 46 | ) | |
| 62 | 47 | CardContent.displayName = "CardContent" |
| 63 | 48 | |
| 64 | const CardFooter = React.forwardRef< | |
| 65 | HTMLDivElement, | |
| 66 | React.HTMLAttributes<HTMLDivElement> | |
| 67 | >(({ className, ...props }, ref) => ( | |
| 68 | <div | |
| 69 | ref={ref} | |
| 70 | className={cn("flex items-center p-6 pt-0", className)} | |
| 71 | {...props} | |
| 72 | /> | |
| 73 | )) | |
| 49 | const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( | |
| 50 | ({ className, ...props }, ref) => ( | |
| 51 | <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} /> | |
| 52 | ) | |
| 53 | ) | |
| 74 | 54 | CardFooter.displayName = "CardFooter" |
| 75 | 55 | |
| 76 | 56 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } |
src/components/ui/dialog.tsx +6 −24
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | "use client" |
| 2 | 2 | |
| 3 | import * as React from "react" | |
| 4 | 3 | import * as DialogPrimitive from "@radix-ui/react-dialog" |
| 5 | 4 | import { Cross2Icon } from "@radix-ui/react-icons" |
| 5 | import * as React from "react" | |
| 6 | 6 | |
| 7 | 7 | import { cn } from "@/lib/utils" |
| 8 | 8 | |
| @@ -53,29 +53,14 @@ const DialogContent = React.forwardRef< | ||
| 53 | 53 | )) |
| 54 | 54 | DialogContent.displayName = DialogPrimitive.Content.displayName |
| 55 | 55 | |
| 56 | const DialogHeader = ({ | |
| 57 | className, | |
| 58 | ...props | |
| 59 | }: React.HTMLAttributes<HTMLDivElement>) => ( | |
| 60 | <div | |
| 61 | className={cn( | |
| 62 | "flex flex-col space-y-1.5 text-center sm:text-left", | |
| 63 | className | |
| 64 | )} | |
| 65 | {...props} | |
| 66 | /> | |
| 56 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
| 57 | <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} /> | |
| 67 | 58 | ) |
| 68 | 59 | DialogHeader.displayName = "DialogHeader" |
| 69 | 60 | |
| 70 | const DialogFooter = ({ | |
| 71 | className, | |
| 72 | ...props | |
| 73 | }: React.HTMLAttributes<HTMLDivElement>) => ( | |
| 61 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
| 74 | 62 | <div |
| 75 | className={cn( | |
| 76 | "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", | |
| 77 | className | |
| 78 | )} | |
| 63 | className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} | |
| 79 | 64 | {...props} |
| 80 | 65 | /> |
| 81 | 66 | ) |
| @@ -87,10 +72,7 @@ const DialogTitle = React.forwardRef< | ||
| 87 | 72 | >(({ className, ...props }, ref) => ( |
| 88 | 73 | <DialogPrimitive.Title |
| 89 | 74 | ref={ref} |
| 90 | className={cn( | |
| 91 | "text-lg font-semibold leading-none tracking-tight", | |
| 92 | className | |
| 93 | )} | |
| 75 | className={cn("text-lg font-semibold leading-none tracking-tight", className)} | |
| 94 | 76 | {...props} |
| 95 | 77 | /> |
| 96 | 78 | )) |
src/components/ui/input.tsx +1 −2
| @@ -2,8 +2,7 @@ import * as React from "react" | ||
| 2 | 2 | |
| 3 | 3 | import { cn } from "@/lib/utils" |
| 4 | 4 | |
| 5 | export interface InputProps | |
| 6 | extends React.InputHTMLAttributes<HTMLInputElement> {} | |
| 5 | export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} | |
| 7 | 6 | |
| 8 | 7 | const Input = React.forwardRef<HTMLInputElement, InputProps>( |
| 9 | 8 | ({ className, type, ...props }, ref) => { |
src/components/ui/label.tsx +3 −8
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | "use client" |
| 2 | 2 | |
| 3 | import * as React from "react" | |
| 4 | 3 | import * as LabelPrimitive from "@radix-ui/react-label" |
| 5 | 4 | import { cva, type VariantProps } from "class-variance-authority" |
| 5 | import * as React from "react" | |
| 6 | 6 | |
| 7 | 7 | import { cn } from "@/lib/utils" |
| 8 | 8 | |
| @@ -12,14 +12,9 @@ const labelVariants = cva( | ||
| 12 | 12 | |
| 13 | 13 | const Label = React.forwardRef< |
| 14 | 14 | React.ElementRef<typeof LabelPrimitive.Root>, |
| 15 | React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & | |
| 16 | VariantProps<typeof labelVariants> | |
| 15 | React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants> | |
| 17 | 16 | >(({ className, ...props }, ref) => ( |
| 18 | <LabelPrimitive.Root | |
| 19 | ref={ref} | |
| 20 | className={cn(labelVariants(), className)} | |
| 21 | {...props} | |
| 22 | /> | |
| 17 | <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} /> | |
| 23 | 18 | )) |
| 24 | 19 | Label.displayName = LabelPrimitive.Root.displayName |
| 25 | 20 |
src/components/ui/sheet.tsx +6 −25
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | "use client" |
| 2 | 2 | |
| 3 | import * as React from "react" | |
| 4 | 3 | import * as SheetPrimitive from "@radix-ui/react-dialog" |
| 5 | 4 | import { Cross2Icon } from "@radix-ui/react-icons" |
| 6 | 5 | import { cva, type VariantProps } from "class-variance-authority" |
| 6 | import * as React from "react" | |
| 7 | 7 | |
| 8 | 8 | import { cn } from "@/lib/utils" |
| 9 | 9 | |
| @@ -59,11 +59,7 @@ const SheetContent = React.forwardRef< | ||
| 59 | 59 | >(({ side = "right", className, children, ...props }, ref) => ( |
| 60 | 60 | <SheetPortal> |
| 61 | 61 | <SheetOverlay /> |
| 62 | <SheetPrimitive.Content | |
| 63 | ref={ref} | |
| 64 | className={cn(sheetVariants({ side }), className)} | |
| 65 | {...props} | |
| 66 | > | |
| 62 | <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> | |
| 67 | 63 | <SheetPrimitive.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 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> |
| 68 | 64 | <Cross2Icon className="h-4 w-4" /> |
| 69 | 65 | <span className="sr-only">Close</span> |
| @@ -74,29 +70,14 @@ const SheetContent = React.forwardRef< | ||
| 74 | 70 | )) |
| 75 | 71 | SheetContent.displayName = SheetPrimitive.Content.displayName |
| 76 | 72 | |
| 77 | const SheetHeader = ({ | |
| 78 | className, | |
| 79 | ...props | |
| 80 | }: React.HTMLAttributes<HTMLDivElement>) => ( | |
| 81 | <div | |
| 82 | className={cn( | |
| 83 | "flex flex-col space-y-2 text-center sm:text-left", | |
| 84 | className | |
| 85 | )} | |
| 86 | {...props} | |
| 87 | /> | |
| 73 | const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
| 74 | <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} /> | |
| 88 | 75 | ) |
| 89 | 76 | SheetHeader.displayName = "SheetHeader" |
| 90 | 77 | |
| 91 | const SheetFooter = ({ | |
| 92 | className, | |
| 93 | ...props | |
| 94 | }: React.HTMLAttributes<HTMLDivElement>) => ( | |
| 78 | const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( | |
| 95 | 79 | <div |
| 96 | className={cn( | |
| 97 | "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", | |
| 98 | className | |
| 99 | )} | |
| 80 | className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} | |
| 100 | 81 | {...props} |
| 101 | 82 | /> |
| 102 | 83 | ) |
src/components/ui/spinner.tsx added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import { type HTMLAttributes } from "react" | |
| 2 | import { cn } from "@/lib/utils" | |
| 3 | ||
| 4 | interface SpinnerProps extends HTMLAttributes<HTMLDivElement> { | |
| 5 | size?: "sm" | "md" | "lg" | |
| 6 | } | |
| 7 | ||
| 8 | export function Spinner({ size = "md", className, ...props }: SpinnerProps) { | |
| 9 | const sizeClasses = { | |
| 10 | sm: "w-4 h-4 border-2", | |
| 11 | md: "w-8 h-8 border-3", | |
| 12 | lg: "w-12 h-12 border-4", | |
| 13 | } | |
| 14 | ||
| 15 | return ( | |
| 16 | <div | |
| 17 | className={cn( | |
| 18 | "inline-block animate-spin rounded-full border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]", | |
| 19 | sizeClasses[size], | |
| 20 | className | |
| 21 | )} | |
| 22 | role="status" | |
| 23 | {...props} | |
| 24 | > | |
| 25 | <span className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]"> | |
| 26 | Loading... | |
| 27 | </span> | |
| 28 | </div> | |
| 29 | ) | |
| 30 | } |
src/components/ui/tabs.tsx +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use client" |
| 2 | 2 | |
| 3 | import * as React from "react" | |
| 4 | 3 | import * as TabsPrimitive from "@radix-ui/react-tabs" |
| 4 | import * as React from "react" | |
| 5 | 5 | |
| 6 | 6 | import { cn } from "@/lib/utils" |
| 7 | 7 |
src/components/ui/textarea.tsx +1 −2
| @@ -2,8 +2,7 @@ import * as React from "react" | ||
| 2 | 2 | |
| 3 | 3 | import { cn } from "@/lib/utils" |
| 4 | 4 | |
| 5 | export interface TextareaProps | |
| 6 | extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} | |
| 5 | export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} | |
| 7 | 6 | |
| 8 | 7 | const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( |
| 9 | 8 | ({ className, ...props }, ref) => { |
src/components/ui/toast.tsx +2 −3
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | "use client" |
| 2 | 2 | |
| 3 | import * as React from "react" | |
| 4 | 3 | import { Cross2Icon } from "@radix-ui/react-icons" |
| 5 | 4 | import * as ToastPrimitives from "@radix-ui/react-toast" |
| 6 | 5 | import { cva, type VariantProps } from "class-variance-authority" |
| 6 | import * as React from "react" | |
| 7 | 7 | |
| 8 | 8 | import { cn } from "@/lib/utils" |
| 9 | 9 | |
| @@ -42,8 +42,7 @@ const toastVariants = cva( | ||
| 42 | 42 | |
| 43 | 43 | const Toast = React.forwardRef< |
| 44 | 44 | React.ElementRef<typeof ToastPrimitives.Root>, |
| 45 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & | |
| 46 | VariantProps<typeof toastVariants> | |
| 45 | React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants> | |
| 47 | 46 | >(({ className, variant, ...props }, ref) => { |
| 48 | 47 | return ( |
| 49 | 48 | <ToastPrimitives.Root |
src/components/ui/toaster.tsx +17 −15
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | "use client" |
| 2 | 2 | |
| 3 | import { useToast } from "@/hooks/use-toast" | |
| 4 | 3 | import { |
| 5 | 4 | Toast, |
| 6 | 5 | ToastClose, |
| @@ -9,26 +8,29 @@ import { | ||
| 9 | 8 | ToastTitle, |
| 10 | 9 | ToastViewport, |
| 11 | 10 | } from "@/components/ui/toast" |
| 11 | import { useToast } from "@/hooks/use-toast" | |
| 12 | 12 | |
| 13 | 13 | export function Toaster() { |
| 14 | 14 | const { toasts } = useToast() |
| 15 | 15 | |
| 16 | 16 | return ( |
| 17 | 17 | <ToastProvider> |
| 18 | {toasts.map(function ({ id, title, description, action, ...props }) { | |
| 19 | return ( | |
| 20 | <Toast key={id} {...props} className="bg-black/50 backdrop-blur-md border-blue-500 hover:border-blue-300 transition-colors"> | |
| 21 | <div className="grid gap-1"> | |
| 22 | {title && <ToastTitle className="text-blue-300">{title}</ToastTitle>} | |
| 23 | {description && ( | |
| 24 | <ToastDescription className="text-gray-300">{description}</ToastDescription> | |
| 25 | )} | |
| 26 | </div> | |
| 27 | {action} | |
| 28 | <ToastClose className="text-blue-300 hover:text-blue-100" /> | |
| 29 | </Toast> | |
| 30 | ) | |
| 31 | })} | |
| 18 | {toasts.map(({ id, title, description, action, ...props }) => ( | |
| 19 | <Toast | |
| 20 | key={id} | |
| 21 | {...props} | |
| 22 | className="bg-black/50 backdrop-blur-md border-blue-500 hover:border-blue-300 transition-colors" | |
| 23 | > | |
| 24 | <div className="grid gap-1"> | |
| 25 | {title && <ToastTitle className="text-blue-300">{title}</ToastTitle>} | |
| 26 | {description && ( | |
| 27 | <ToastDescription className="text-gray-300">{description}</ToastDescription> | |
| 28 | )} | |
| 29 | </div> | |
| 30 | {action} | |
| 31 | <ToastClose className="text-blue-300 hover:text-blue-100" /> | |
| 32 | </Toast> | |
| 33 | ))} | |
| 32 | 34 | <ToastViewport /> |
| 33 | 35 | </ToastProvider> |
| 34 | 36 | ) |
src/components/ui/tooltip.tsx +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use client" |
| 2 | 2 | |
| 3 | import * as React from "react" | |
| 4 | 3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" |
| 4 | import * as React from "react" | |
| 5 | 5 | |
| 6 | 6 | import { cn } from "@/lib/utils" |
| 7 | 7 |
src/hooks/use-toast.ts +3 −8
| @@ -3,10 +3,7 @@ | ||
| 3 | 3 | // Inspired by react-hot-toast library |
| 4 | 4 | import * as React from "react" |
| 5 | 5 | |
| 6 | import type { | |
| 7 | ToastActionElement, | |
| 8 | ToastProps, | |
| 9 | } from "@/components/ui/toast" | |
| 6 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast" | |
| 10 | 7 | |
| 11 | 8 | const TOAST_LIMIT = 1 |
| 12 | 9 | const TOAST_REMOVE_DELAY = 1000000 |
| @@ -85,9 +82,7 @@ export const reducer = (state: State, action: Action): State => { | ||
| 85 | 82 | case "UPDATE_TOAST": |
| 86 | 83 | return { |
| 87 | 84 | ...state, |
| 88 | toasts: state.toasts.map((t) => | |
| 89 | t.id === action.toast.id ? { ...t, ...action.toast } : t | |
| 90 | ), | |
| 85 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), | |
| 91 | 86 | } |
| 92 | 87 | |
| 93 | 88 | case "DISMISS_TOAST": { |
| @@ -182,7 +177,7 @@ function useToast() { | ||
| 182 | 177 | listeners.splice(index, 1) |
| 183 | 178 | } |
| 184 | 179 | } |
| 185 | }, [state]) | |
| 180 | }, []) | |
| 186 | 181 | |
| 187 | 182 | return { |
| 188 | 183 | ...state, |
src/lib/logger.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | import pino from "pino" | |
| 2 | ||
| 3 | const isDevelopment = process.env.NODE_ENV === "development" | |
| 4 | const isProduction = process.env.NODE_ENV === "production" | |
| 5 | ||
| 6 | // Create logger with environment-appropriate config | |
| 7 | export const logger = pino({ | |
| 8 | level: process.env.LOG_LEVEL || (isDevelopment ? "debug" : "info"), | |
| 9 | ||
| 10 | // Pretty print in development, structured JSON in production | |
| 11 | ...(isDevelopment && { | |
| 12 | transport: { | |
| 13 | target: "pino-pretty", | |
| 14 | options: { | |
| 15 | colorize: true, | |
| 16 | translateTime: "HH:MM:ss", | |
| 17 | ignore: "pid,hostname", | |
| 18 | }, | |
| 19 | }, | |
| 20 | }), | |
| 21 | ||
| 22 | // Base fields for all logs | |
| 23 | base: { | |
| 24 | env: process.env.NODE_ENV, | |
| 25 | }, | |
| 26 | ||
| 27 | // Redact sensitive fields | |
| 28 | redact: { | |
| 29 | paths: ["req.headers.authorization", "req.headers.cookie", "*.password", "*.token"], | |
| 30 | remove: true, | |
| 31 | }, | |
| 32 | ||
| 33 | // Serialize errors properly | |
| 34 | serializers: { | |
| 35 | err: pino.stdSerializers.err, | |
| 36 | req: pino.stdSerializers.req, | |
| 37 | res: pino.stdSerializers.res, | |
| 38 | }, | |
| 39 | }) | |
| 40 | ||
| 41 | // Helper for API route logging | |
| 42 | export function createRouteLogger(routeName: string) { | |
| 43 | return logger.child({ route: routeName }) | |
| 44 | } | |
| 45 | ||
| 46 | // Helper for scheduled function logging | |
| 47 | export function createSchedulerLogger(functionId: string) { | |
| 48 | return logger.child({ scheduler: true, functionId }) | |
| 49 | } |
src/lib/projects.ts +6 −6
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | export const projectRepoMap: Record<string, string> = { |
| 2 | "qr_io": "qr-io/qr.io", | |
| 3 | "csgomlbot": "trevato/csgomlbot", | |
| 4 | "issue_completer": "trevato/issue_completer", | |
| 5 | "csv_semantic_search": "trevato/csv_semantic_search", | |
| 6 | "autonomous_plane": "trevato/autonomous_plane" | |
| 7 | }; | |
| 2 | qr_io: "qr-io/qr.io", | |
| 3 | csgomlbot: "trevato/csgomlbot", | |
| 4 | issue_completer: "trevato/issue_completer", | |
| 5 | csv_semantic_search: "trevato/csv_semantic_search", | |
| 6 | autonomous_plane: "trevato/autonomous_plane", | |
| 7 | } |
src/lib/utils.ts +1 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | import { clsx, type ClassValue } from "clsx" | |
| 1 | import { type ClassValue, clsx } from "clsx" | |
| 2 | 2 | import { twMerge } from "tailwind-merge" |
| 3 | 3 | |
| 4 | 4 | export function cn(...inputs: ClassValue[]) { |
src/styles/github-markdown.css +34 −11
| @@ -1,32 +1,53 @@ | ||
| 1 | 1 | .markdown-body { |
| 2 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji; | |
| 2 | font-family: | |
| 3 | -apple-system, | |
| 4 | BlinkMacSystemFont, | |
| 5 | Segoe UI, | |
| 6 | Helvetica, | |
| 7 | Arial, | |
| 8 | sans-serif, | |
| 9 | Apple Color Emoji, | |
| 10 | Segoe UI Emoji; | |
| 3 | 11 | font-size: 16px; |
| 4 | 12 | line-height: 1.5; |
| 5 | 13 | word-wrap: break-word; |
| 6 | 14 | } |
| 7 | 15 | |
| 8 | .markdown-body h1, .markdown-body h2 { | |
| 9 | padding-bottom: .3em; | |
| 16 | .markdown-body h1, | |
| 17 | .markdown-body h2 { | |
| 18 | padding-bottom: 0.3em; | |
| 10 | 19 | border-bottom: 1px solid #21262d; |
| 11 | 20 | } |
| 12 | 21 | |
| 13 | .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { | |
| 22 | .markdown-body h1, | |
| 23 | .markdown-body h2, | |
| 24 | .markdown-body h3, | |
| 25 | .markdown-body h4, | |
| 26 | .markdown-body h5, | |
| 27 | .markdown-body h6 { | |
| 14 | 28 | margin-top: 24px; |
| 15 | 29 | margin-bottom: 16px; |
| 16 | 30 | font-weight: 600; |
| 17 | 31 | line-height: 1.25; |
| 18 | 32 | } |
| 19 | 33 | |
| 20 | .markdown-body p, .markdown-body blockquote, .markdown-body ul, .markdown-body ol, .markdown-body dl, .markdown-body table, .markdown-body pre, .markdown-body details { | |
| 34 | .markdown-body p, | |
| 35 | .markdown-body blockquote, | |
| 36 | .markdown-body ul, | |
| 37 | .markdown-body ol, | |
| 38 | .markdown-body dl, | |
| 39 | .markdown-body table, | |
| 40 | .markdown-body pre, | |
| 41 | .markdown-body details { | |
| 21 | 42 | margin-top: 0; |
| 22 | 43 | margin-bottom: 16px; |
| 23 | 44 | } |
| 24 | 45 | |
| 25 | 46 | .markdown-body code { |
| 26 | padding: .2em .4em; | |
| 47 | padding: 0.2em 0.4em; | |
| 27 | 48 | margin: 0; |
| 28 | 49 | font-size: 85%; |
| 29 | background-color: rgba(110,118,129,0.4); | |
| 50 | background-color: rgba(110, 118, 129, 0.4); | |
| 30 | 51 | border-radius: 6px; |
| 31 | 52 | } |
| 32 | 53 | |
| @@ -52,10 +73,11 @@ | ||
| 52 | 73 | .markdown-body blockquote { |
| 53 | 74 | padding: 0 1em; |
| 54 | 75 | color: #8b949e; |
| 55 | border-left: .25em solid #30363d; | |
| 76 | border-left: 0.25em solid #30363d; | |
| 56 | 77 | } |
| 57 | 78 | |
| 58 | .markdown-body ul, .markdown-body ol { | |
| 79 | .markdown-body ul, | |
| 80 | .markdown-body ol { | |
| 59 | 81 | padding-left: 2em; |
| 60 | 82 | } |
| 61 | 83 | |
| @@ -67,7 +89,8 @@ | ||
| 67 | 89 | border-collapse: collapse; |
| 68 | 90 | } |
| 69 | 91 | |
| 70 | .markdown-body table th, .markdown-body table td { | |
| 92 | .markdown-body table th, | |
| 93 | .markdown-body table td { | |
| 71 | 94 | padding: 6px 13px; |
| 72 | 95 | border: 1px solid #30363d; |
| 73 | 96 | } |
| @@ -88,7 +111,7 @@ | ||
| 88 | 111 | } |
| 89 | 112 | |
| 90 | 113 | .markdown-body hr { |
| 91 | height: .25em; | |
| 114 | height: 0.25em; | |
| 92 | 115 | padding: 0; |
| 93 | 116 | margin: 24px 0; |
| 94 | 117 | background-color: #30363d; |
src/utils/supabase/client.ts +1 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | import { createBrowserClient } from '@supabase/ssr' | |
| 1 | import { createBrowserClient } from "@supabase/ssr" | |
| 2 | 2 | |
| 3 | 3 | export function createClient() { |
| 4 | 4 | return createBrowserClient( |
src/utils/supabase/middleware.ts +7 −8
| @@ -1,5 +1,5 @@ | ||
| 1 | import { createServerClient } from '@supabase/ssr' | |
| 2 | import { NextResponse, type NextRequest } from 'next/server' | |
| 1 | import { createServerClient } from "@supabase/ssr" | |
| 2 | import { type NextRequest, NextResponse } from "next/server" | |
| 3 | 3 | |
| 4 | 4 | export async function updateSession(request: NextRequest) { |
| 5 | 5 | let supabaseResponse = NextResponse.next({ |
| @@ -28,15 +28,14 @@ export async function updateSession(request: NextRequest) { | ||
| 28 | 28 | ) |
| 29 | 29 | |
| 30 | 30 | // Get the user session |
| 31 | const { data: { user } } = await supabase.auth.getUser() | |
| 31 | const { | |
| 32 | data: { user }, | |
| 33 | } = await supabase.auth.getUser() | |
| 32 | 34 | |
| 33 | 35 | // Protect API routes that require authentication |
| 34 | if (request.nextUrl.pathname.startsWith('/api/sandbox/')) { | |
| 36 | if (request.nextUrl.pathname.startsWith("/api/sandbox/")) { | |
| 35 | 37 | if (!user) { |
| 36 | return NextResponse.json( | |
| 37 | { error: 'Authentication required' }, | |
| 38 | { status: 401 } | |
| 39 | ) | |
| 38 | return NextResponse.json({ error: "Authentication required" }, { status: 401 }) | |
| 40 | 39 | } |
| 41 | 40 | } |
| 42 | 41 |
src/utils/supabase/server.ts +10 −10
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | "use server" |
| 2 | import { createServerClient, type CookieOptions } from "@supabase/ssr"; | |
| 3 | import { cookies } from "next/headers"; | |
| 2 | import { type CookieOptions, createServerClient } from "@supabase/ssr" | |
| 3 | import { cookies } from "next/headers" | |
| 4 | 4 | |
| 5 | export async function createClient<Database>() { | |
| 6 | const cookieStore = cookies(); | |
| 5 | export async function createClient<_Database>() { | |
| 6 | const cookieStore = cookies() | |
| 7 | 7 | |
| 8 | 8 | // Create a server's supabase client with newly configured cookie, |
| 9 | 9 | // which could be used to maintain user's session |
| @@ -13,12 +13,12 @@ export async function createClient<Database>() { | ||
| 13 | 13 | { |
| 14 | 14 | cookies: { |
| 15 | 15 | get(name: string) { |
| 16 | return cookieStore.get(name)?.value; | |
| 16 | return cookieStore.get(name)?.value | |
| 17 | 17 | }, |
| 18 | 18 | set(name: string, value: string, options: CookieOptions) { |
| 19 | 19 | try { |
| 20 | cookieStore.set({ name, value, ...options }); | |
| 21 | } catch (error) { | |
| 20 | cookieStore.set({ name, value, ...options }) | |
| 21 | } catch (_error) { | |
| 22 | 22 | // The `set` method was called from a Server Component. |
| 23 | 23 | // This can be ignored if you have middleware refreshing |
| 24 | 24 | // user sessions. |
| @@ -26,8 +26,8 @@ export async function createClient<Database>() { | ||
| 26 | 26 | }, |
| 27 | 27 | remove(name: string, options: CookieOptions) { |
| 28 | 28 | try { |
| 29 | cookieStore.set({ name, value: "", ...options }); | |
| 30 | } catch (error) { | |
| 29 | cookieStore.set({ name, value: "", ...options }) | |
| 30 | } catch (_error) { | |
| 31 | 31 | // The `delete` method was called from a Server Component. |
| 32 | 32 | // This can be ignored if you have middleware refreshing |
| 33 | 33 | // user sessions. |
| @@ -35,5 +35,5 @@ export async function createClient<Database>() { | ||
| 35 | 35 | }, |
| 36 | 36 | }, |
| 37 | 37 | } |
| 38 | ); | |
| 38 | ) | |
| 39 | 39 | } |
supabase/functions/execute-scheduled/index.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | import { serve } from "https://deno.land/[email protected]/http/server.ts" | |
| 2 | import { createClient } from "https://esm.sh/@supabase/supabase-js@2" | |
| 3 | ||
| 4 | const corsHeaders = { | |
| 5 | "Access-Control-Allow-Origin": "*", | |
| 6 | "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", | |
| 7 | } | |
| 8 | ||
| 9 | serve(async (req) => { | |
| 10 | // Handle CORS preflight | |
| 11 | if (req.method === "OPTIONS") { | |
| 12 | return new Response("ok", { headers: corsHeaders }) | |
| 13 | } | |
| 14 | ||
| 15 | try { | |
| 16 | // Verify authorization | |
| 17 | const authHeader = req.headers.get("authorization") | |
| 18 | const expectedAuth = `Bearer ${Deno.env.get("CRON_SECRET")}` | |
| 19 | ||
| 20 | if (authHeader !== expectedAuth) { | |
| 21 | return new Response(JSON.stringify({ error: "Unauthorized" }), { | |
| 22 | status: 401, | |
| 23 | headers: { ...corsHeaders, "Content-Type": "application/json" }, | |
| 24 | }) | |
| 25 | } | |
| 26 | ||
| 27 | // Create Supabase client with service role (automatic in Edge Functions) | |
| 28 | const supabaseClient = createClient( | |
| 29 | Deno.env.get("SUPABASE_URL") ?? "", | |
| 30 | Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "", | |
| 31 | { | |
| 32 | auth: { | |
| 33 | autoRefreshToken: false, | |
| 34 | persistSession: false, | |
| 35 | }, | |
| 36 | } | |
| 37 | ) | |
| 38 | ||
| 39 | // Fetch all functions that are due to run | |
| 40 | const now = new Date().toISOString() | |
| 41 | const { data: dueFunctions, error: fetchError } = await supabaseClient | |
| 42 | .from("scheduled_functions") | |
| 43 | .select("*") | |
| 44 | .eq("enabled", true) | |
| 45 | .lte("next_run_at", now) | |
| 46 | .limit(50) // Process max 50 functions per minute | |
| 47 | ||
| 48 | if (fetchError) { | |
| 49 | throw new Error(`Failed to fetch due functions: ${fetchError.message}`) | |
| 50 | } | |
| 51 | ||
| 52 | if (!dueFunctions || dueFunctions.length === 0) { | |
| 53 | return new Response( | |
| 54 | JSON.stringify({ | |
| 55 | success: true, | |
| 56 | message: "No functions due to run", | |
| 57 | processed: 0, | |
| 58 | }), | |
| 59 | { | |
| 60 | headers: { ...corsHeaders, "Content-Type": "application/json" }, | |
| 61 | } | |
| 62 | ) | |
| 63 | } | |
| 64 | ||
| 65 | // Process each function | |
| 66 | const results = await Promise.allSettled( | |
| 67 | dueFunctions.map((func) => executeFunction(func, supabaseClient)) | |
| 68 | ) | |
| 69 | ||
| 70 | const successful = results.filter((r) => r.status === "fulfilled").length | |
| 71 | const failed = results.filter((r) => r.status === "rejected").length | |
| 72 | ||
| 73 | return new Response( | |
| 74 | JSON.stringify({ | |
| 75 | success: true, | |
| 76 | message: `Processed ${dueFunctions.length} functions`, | |
| 77 | processed: dueFunctions.length, | |
| 78 | successful, | |
| 79 | failed, | |
| 80 | }), | |
| 81 | { | |
| 82 | headers: { ...corsHeaders, "Content-Type": "application/json" }, | |
| 83 | } | |
| 84 | ) | |
| 85 | } catch (error) { | |
| 86 | console.error("Edge function error:", error) | |
| 87 | return new Response( | |
| 88 | JSON.stringify({ | |
| 89 | error: "Execution failed", | |
| 90 | details: error instanceof Error ? error.message : "Unknown error", | |
| 91 | }), | |
| 92 | { | |
| 93 | status: 500, | |
| 94 | headers: { ...corsHeaders, "Content-Type": "application/json" }, | |
| 95 | } | |
| 96 | ) | |
| 97 | } | |
| 98 | }) | |
| 99 | ||
| 100 | async function executeFunction(func: any, supabase: any) { | |
| 101 | const startTime = Date.now() | |
| 102 | let status: "success" | "error" | "timeout" = "error" | |
| 103 | let output: string | null = null | |
| 104 | let error: string | null = null | |
| 105 | ||
| 106 | try { | |
| 107 | // Execute the function via sandbox API | |
| 108 | const appUrl = Deno.env.get("NEXT_PUBLIC_APP_URL") || "https://trevato.dev" | |
| 109 | const response = await fetch(`${appUrl}/api/sandbox/execute`, { | |
| 110 | method: "POST", | |
| 111 | headers: { | |
| 112 | "Content-Type": "application/json", | |
| 113 | }, | |
| 114 | body: JSON.stringify({ | |
| 115 | code: func.code, | |
| 116 | language: func.language, | |
| 117 | envVars: func.env_vars || {}, | |
| 118 | }), | |
| 119 | signal: AbortSignal.timeout(5 * 60 * 1000), // 5 minute timeout | |
| 120 | }) | |
| 121 | ||
| 122 | const result = await response.json() | |
| 123 | ||
| 124 | if (response.ok && result.success) { | |
| 125 | status = "success" | |
| 126 | output = result.output || result.result || "Execution completed" | |
| 127 | } else { | |
| 128 | status = "error" | |
| 129 | error = result.error || result.details || "Execution failed" | |
| 130 | } | |
| 131 | } catch (err) { | |
| 132 | if (err instanceof Error) { | |
| 133 | if (err.name === "TimeoutError" || err.name === "AbortError") { | |
| 134 | status = "timeout" | |
| 135 | error = "Execution timed out after 5 minutes" | |
| 136 | } else { | |
| 137 | status = "error" | |
| 138 | error = err.message | |
| 139 | } | |
| 140 | } else { | |
| 141 | status = "error" | |
| 142 | error = "Unknown execution error" | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | const duration = Date.now() - startTime | |
| 147 | ||
| 148 | // Calculate next run time using cron-parser | |
| 149 | let nextRun: string | |
| 150 | try { | |
| 151 | // Use dynamic import for cron-parser in Deno | |
| 152 | const { parseExpression } = await import("npm:[email protected]") | |
| 153 | const interval = parseExpression(func.cron_expression) | |
| 154 | nextRun = interval.next().toDate().toISOString() | |
| 155 | } catch (_err) { | |
| 156 | // If cron parsing fails, disable the function | |
| 157 | await supabase | |
| 158 | .from("scheduled_functions") | |
| 159 | .update({ enabled: false }) | |
| 160 | .eq("id", func.id) | |
| 161 | ||
| 162 | throw new Error("Invalid cron expression, function disabled") | |
| 163 | } | |
| 164 | ||
| 165 | // Update function with next run time | |
| 166 | await supabase | |
| 167 | .from("scheduled_functions") | |
| 168 | .update({ | |
| 169 | last_run_at: new Date().toISOString(), | |
| 170 | next_run_at: nextRun, | |
| 171 | execution_count: func.execution_count + 1, | |
| 172 | }) | |
| 173 | .eq("id", func.id) | |
| 174 | ||
| 175 | // Log execution history | |
| 176 | await supabase.from("execution_history").insert({ | |
| 177 | function_id: func.id, | |
| 178 | status, | |
| 179 | output: output?.substring(0, 10000), // Limit output size | |
| 180 | error: error?.substring(0, 1000), // Limit error size | |
| 181 | duration_ms: duration, | |
| 182 | }) | |
| 183 | ||
| 184 | return { functionId: func.id, status, duration } | |
| 185 | } |
supabase/migrations/20251123_create_scheduled_functions.sql added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | -- Create scheduled_functions table | |
| 2 | create table if not exists public.scheduled_functions ( | |
| 3 | id uuid primary key default gen_random_uuid(), | |
| 4 | user_id uuid references auth.users(id) on delete cascade not null, | |
| 5 | name text not null, | |
| 6 | code text not null, | |
| 7 | language text not null check (language in ('typescript', 'python')), | |
| 8 | cron_expression text not null, | |
| 9 | env_vars jsonb default '{}'::jsonb, | |
| 10 | enabled boolean default true, | |
| 11 | created_at timestamp with time zone default now(), | |
| 12 | updated_at timestamp with time zone default now(), | |
| 13 | last_run_at timestamp with time zone, | |
| 14 | next_run_at timestamp with time zone, | |
| 15 | execution_count integer default 0, | |
| 16 | ||
| 17 | constraint name_not_empty check (char_length(trim(name)) > 0), | |
| 18 | constraint code_not_empty check (char_length(trim(code)) > 0), | |
| 19 | constraint valid_cron check (cron_expression ~ '^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$') | |
| 20 | ); | |
| 21 | ||
| 22 | -- Create execution_history table | |
| 23 | create table if not exists public.execution_history ( | |
| 24 | id uuid primary key default gen_random_uuid(), | |
| 25 | function_id uuid references public.scheduled_functions(id) on delete cascade not null, | |
| 26 | executed_at timestamp with time zone default now(), | |
| 27 | status text not null check (status in ('success', 'error', 'timeout')), | |
| 28 | output text, | |
| 29 | error text, | |
| 30 | duration_ms integer, | |
| 31 | ||
| 32 | constraint valid_duration check (duration_ms >= 0) | |
| 33 | ); | |
| 34 | ||
| 35 | -- Create indexes for performance | |
| 36 | create index if not exists idx_scheduled_functions_user_id on public.scheduled_functions(user_id); | |
| 37 | create index if not exists idx_scheduled_functions_next_run on public.scheduled_functions(next_run_at) where enabled = true; | |
| 38 | create index if not exists idx_execution_history_function_id on public.execution_history(function_id); | |
| 39 | create index if not exists idx_execution_history_executed_at on public.execution_history(executed_at desc); | |
| 40 | ||
| 41 | -- Enable Row Level Security | |
| 42 | alter table public.scheduled_functions enable row level security; | |
| 43 | alter table public.execution_history enable row level security; | |
| 44 | ||
| 45 | -- RLS Policies for scheduled_functions | |
| 46 | create policy "Users can view their own scheduled functions" | |
| 47 | on public.scheduled_functions for select | |
| 48 | using (auth.uid() = user_id); | |
| 49 | ||
| 50 | create policy "Users can create their own scheduled functions" | |
| 51 | on public.scheduled_functions for insert | |
| 52 | with check (auth.uid() = user_id); | |
| 53 | ||
| 54 | create policy "Users can update their own scheduled functions" | |
| 55 | on public.scheduled_functions for update | |
| 56 | using (auth.uid() = user_id); | |
| 57 | ||
| 58 | create policy "Users can delete their own scheduled functions" | |
| 59 | on public.scheduled_functions for delete | |
| 60 | using (auth.uid() = user_id); | |
| 61 | ||
| 62 | -- RLS Policies for execution_history (read through scheduled_functions ownership) | |
| 63 | create policy "Users can view execution history for their functions" | |
| 64 | on public.execution_history for select | |
| 65 | using ( | |
| 66 | exists ( | |
| 67 | select 1 from public.scheduled_functions | |
| 68 | where scheduled_functions.id = execution_history.function_id | |
| 69 | and scheduled_functions.user_id = auth.uid() | |
| 70 | ) | |
| 71 | ); | |
| 72 | ||
| 73 | -- Function to update updated_at timestamp | |
| 74 | create or replace function public.handle_updated_at() | |
| 75 | returns trigger as $$ | |
| 76 | begin | |
| 77 | new.updated_at = now(); | |
| 78 | return new; | |
| 79 | end; | |
| 80 | $$ language plpgsql; | |
| 81 | ||
| 82 | -- Trigger to automatically update updated_at | |
| 83 | create trigger set_updated_at | |
| 84 | before update on public.scheduled_functions | |
| 85 | for each row | |
| 86 | execute function public.handle_updated_at(); | |
| 87 | ||
| 88 | -- Function to calculate next run time from cron expression | |
| 89 | create or replace function public.calculate_next_run(cron_expr text, from_time timestamp with time zone default now()) | |
| 90 | returns timestamp with time zone as $$ | |
| 91 | declare | |
| 92 | parts text[]; | |
| 93 | minute_part text; | |
| 94 | hour_part text; | |
| 95 | next_run timestamp with time zone; | |
| 96 | current_minute int; | |
| 97 | current_hour int; | |
| 98 | target_minute int; | |
| 99 | target_hour int; | |
| 100 | begin | |
| 101 | -- Parse cron expression (minute hour day month weekday) | |
| 102 | parts := string_to_array(cron_expr, ' '); | |
| 103 | minute_part := parts[1]; | |
| 104 | hour_part := parts[2]; | |
| 105 | ||
| 106 | -- Start from the next minute | |
| 107 | next_run := date_trunc('minute', from_time) + interval '1 minute'; | |
| 108 | ||
| 109 | -- Simple implementation: only handle minute and hour | |
| 110 | -- For full cron support, you'd use a library like pg_cron | |
| 111 | ||
| 112 | -- If specific minute is set (not * or */n) | |
| 113 | if minute_part ~ '^\d+$' then | |
| 114 | target_minute := minute_part::int; | |
| 115 | current_minute := extract(minute from next_run)::int; | |
| 116 | ||
| 117 | if current_minute > target_minute then | |
| 118 | -- Move to next hour | |
| 119 | next_run := next_run + interval '1 hour'; | |
| 120 | end if; | |
| 121 | ||
| 122 | next_run := date_trunc('hour', next_run) + (target_minute || ' minutes')::interval; | |
| 123 | end if; | |
| 124 | ||
| 125 | -- If specific hour is set (not * or */n) | |
| 126 | if hour_part ~ '^\d+$' then | |
| 127 | target_hour := hour_part::int; | |
| 128 | current_hour := extract(hour from next_run)::int; | |
| 129 | ||
| 130 | if current_hour > target_hour or (current_hour = target_hour and minute_part ~ '^\d+$' and extract(minute from next_run)::int >= minute_part::int) then | |
| 131 | -- Move to next day | |
| 132 | next_run := next_run + interval '1 day'; | |
| 133 | end if; | |
| 134 | ||
| 135 | next_run := date_trunc('day', next_run) + (target_hour || ' hours')::interval; | |
| 136 | ||
| 137 | if minute_part ~ '^\d+$' then | |
| 138 | next_run := next_run + (minute_part::int || ' minutes')::interval; | |
| 139 | end if; | |
| 140 | end if; | |
| 141 | ||
| 142 | return next_run; | |
| 143 | end; | |
| 144 | $$ language plpgsql immutable; | |
| 145 | ||
| 146 | -- Grant necessary permissions | |
| 147 | grant usage on schema public to anon, authenticated; | |
| 148 | grant all on public.scheduled_functions to authenticated; | |
| 149 | grant all on public.execution_history to authenticated; | |
| 150 | grant execute on function public.calculate_next_run to authenticated; |
tailwind.config.ts +54 −54
| @@ -1,63 +1,63 @@ | ||
| 1 | import type { Config } from "tailwindcss"; | |
| 1 | import type { Config } from "tailwindcss" | |
| 2 | 2 | |
| 3 | 3 | const config: Config = { |
| 4 | darkMode: ["class"], | |
| 5 | content: [ | |
| 4 | darkMode: ["class"], | |
| 5 | content: [ | |
| 6 | 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", |
| 7 | 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", |
| 8 | 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", |
| 9 | 9 | ], |
| 10 | 10 | theme: { |
| 11 | extend: { | |
| 12 | colors: { | |
| 13 | background: 'hsl(var(--background))', | |
| 14 | foreground: 'hsl(var(--foreground))', | |
| 15 | card: { | |
| 16 | DEFAULT: 'hsl(var(--card))', | |
| 17 | foreground: 'hsl(var(--card-foreground))' | |
| 18 | }, | |
| 19 | popover: { | |
| 20 | DEFAULT: 'hsl(var(--popover))', | |
| 21 | foreground: 'hsl(var(--popover-foreground))' | |
| 22 | }, | |
| 23 | primary: { | |
| 24 | DEFAULT: 'hsl(var(--primary))', | |
| 25 | foreground: 'hsl(var(--primary-foreground))' | |
| 26 | }, | |
| 27 | secondary: { | |
| 28 | DEFAULT: 'hsl(var(--secondary))', | |
| 29 | foreground: 'hsl(var(--secondary-foreground))' | |
| 30 | }, | |
| 31 | muted: { | |
| 32 | DEFAULT: 'hsl(var(--muted))', | |
| 33 | foreground: 'hsl(var(--muted-foreground))' | |
| 34 | }, | |
| 35 | accent: { | |
| 36 | DEFAULT: 'hsl(var(--accent))', | |
| 37 | foreground: 'hsl(var(--accent-foreground))' | |
| 38 | }, | |
| 39 | destructive: { | |
| 40 | DEFAULT: 'hsl(var(--destructive))', | |
| 41 | foreground: 'hsl(var(--destructive-foreground))' | |
| 42 | }, | |
| 43 | border: 'hsl(var(--border))', | |
| 44 | input: 'hsl(var(--input))', | |
| 45 | ring: 'hsl(var(--ring))', | |
| 46 | chart: { | |
| 47 | '1': 'hsl(var(--chart-1))', | |
| 48 | '2': 'hsl(var(--chart-2))', | |
| 49 | '3': 'hsl(var(--chart-3))', | |
| 50 | '4': 'hsl(var(--chart-4))', | |
| 51 | '5': 'hsl(var(--chart-5))' | |
| 52 | } | |
| 53 | }, | |
| 54 | borderRadius: { | |
| 55 | lg: 'var(--radius)', | |
| 56 | md: 'calc(var(--radius) - 2px)', | |
| 57 | sm: 'calc(var(--radius) - 4px)' | |
| 58 | } | |
| 59 | } | |
| 11 | extend: { | |
| 12 | colors: { | |
| 13 | background: "hsl(var(--background))", | |
| 14 | foreground: "hsl(var(--foreground))", | |
| 15 | card: { | |
| 16 | DEFAULT: "hsl(var(--card))", | |
| 17 | foreground: "hsl(var(--card-foreground))", | |
| 18 | }, | |
| 19 | popover: { | |
| 20 | DEFAULT: "hsl(var(--popover))", | |
| 21 | foreground: "hsl(var(--popover-foreground))", | |
| 22 | }, | |
| 23 | primary: { | |
| 24 | DEFAULT: "hsl(var(--primary))", | |
| 25 | foreground: "hsl(var(--primary-foreground))", | |
| 26 | }, | |
| 27 | secondary: { | |
| 28 | DEFAULT: "hsl(var(--secondary))", | |
| 29 | foreground: "hsl(var(--secondary-foreground))", | |
| 30 | }, | |
| 31 | muted: { | |
| 32 | DEFAULT: "hsl(var(--muted))", | |
| 33 | foreground: "hsl(var(--muted-foreground))", | |
| 34 | }, | |
| 35 | accent: { | |
| 36 | DEFAULT: "hsl(var(--accent))", | |
| 37 | foreground: "hsl(var(--accent-foreground))", | |
| 38 | }, | |
| 39 | destructive: { | |
| 40 | DEFAULT: "hsl(var(--destructive))", | |
| 41 | foreground: "hsl(var(--destructive-foreground))", | |
| 42 | }, | |
| 43 | border: "hsl(var(--border))", | |
| 44 | input: "hsl(var(--input))", | |
| 45 | ring: "hsl(var(--ring))", | |
| 46 | chart: { | |
| 47 | "1": "hsl(var(--chart-1))", | |
| 48 | "2": "hsl(var(--chart-2))", | |
| 49 | "3": "hsl(var(--chart-3))", | |
| 50 | "4": "hsl(var(--chart-4))", | |
| 51 | "5": "hsl(var(--chart-5))", | |
| 52 | }, | |
| 53 | }, | |
| 54 | borderRadius: { | |
| 55 | lg: "var(--radius)", | |
| 56 | md: "calc(var(--radius) - 2px)", | |
| 57 | sm: "calc(var(--radius) - 4px)", | |
| 58 | }, | |
| 59 | }, | |
| 60 | 60 | }, |
| 61 | 61 | plugins: [require("tailwindcss-animate")], |
| 62 | }; | |
| 63 | export default config; | |
| 62 | } | |
| 63 | export default config |
test-results/.last-run.json +2 −4
| @@ -1,6 +1,4 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "status": "failed", |
| 3 | "failedTests": [ | |
| 4 | "632d6c1a91c2a57dc030-1a0dbb6af9ef5c74ef9a" | |
| 5 | ] | |
| 6 | }↵ | |
| 3 | "failedTests": ["632d6c1a91c2a57dc030-1a0dbb6af9ef5c74ef9a"] | |
| 4 | } |
tsconfig.json +4 −17
| @@ -1,10 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "compilerOptions": { |
| 3 | "lib": [ | |
| 4 | "dom", | |
| 5 | "dom.iterable", | |
| 6 | "esnext" | |
| 7 | ], | |
| 3 | "lib": ["dom", "dom.iterable", "esnext"], | |
| 8 | 4 | "allowJs": true, |
| 9 | 5 | "skipLibCheck": true, |
| 10 | 6 | "strict": true, |
| @@ -22,19 +18,10 @@ | ||
| 22 | 18 | } |
| 23 | 19 | ], |
| 24 | 20 | "paths": { |
| 25 | "@/*": [ | |
| 26 | "./src/*" | |
| 27 | ] | |
| 21 | "@/*": ["./src/*"] | |
| 28 | 22 | }, |
| 29 | 23 | "target": "ES2017" |
| 30 | 24 | }, |
| 31 | "include": [ | |
| 32 | "next-env.d.ts", | |
| 33 | "**/*.ts", | |
| 34 | "**/*.tsx", | |
| 35 | ".next/types/**/*.ts" | |
| 36 | ], | |
| 37 | "exclude": [ | |
| 38 | "node_modules" | |
| 39 | ] | |
| 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], | |
| 26 | "exclude": ["node_modules", "supabase/functions"] | |
| 40 | 27 | } |
vercel.json added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | { | |
| 2 | "crons": [ | |
| 3 | { | |
| 4 | "path": "/api/cron/execute-scheduled", | |
| 5 | "schedule": "* * * * *" | |
| 6 | } | |
| 7 | ] | |
| 8 | } |
vitest.config.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | import path from "node:path" | |
| 2 | import react from "@vitejs/plugin-react" | |
| 3 | import { defineConfig } from "vitest/config" | |
| 4 | ||
| 5 | export default defineConfig({ | |
| 6 | plugins: [react()], | |
| 7 | test: { | |
| 8 | environment: "jsdom", | |
| 9 | globals: true, | |
| 10 | setupFiles: ["./vitest.setup.ts"], | |
| 11 | css: true, | |
| 12 | coverage: { | |
| 13 | provider: "v8", | |
| 14 | reporter: ["text", "json", "html"], | |
| 15 | exclude: [ | |
| 16 | "node_modules/", | |
| 17 | "vitest.setup.ts", | |
| 18 | "**/*.config.{js,ts}", | |
| 19 | "**/types.ts", | |
| 20 | "**/*.d.ts", | |
| 21 | ], | |
| 22 | }, | |
| 23 | }, | |
| 24 | resolve: { | |
| 25 | alias: { | |
| 26 | "@": path.resolve(__dirname, "./src"), | |
| 27 | }, | |
| 28 | }, | |
| 29 | }) |
vitest.setup.ts added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | import "@testing-library/jest-dom" |