minor additions

This commit is contained in:
Kesku 2025-06-23 12:14:14 +01:00
parent c55b7796a6
commit a5072eb751
26 changed files with 9346 additions and 1793 deletions

7
.gitignore vendored
View File

@ -2,10 +2,12 @@
# dependencies
/apps/web/node_modules
/apps/desktop/node_modules
# next.js
# Build outputs
/apps/web/.next/
/apps/web/out/
/apps/desktop/dist
# debug
/apps/web/npm-debug.log*
@ -19,3 +21,6 @@
# typescript
/apps/web/next-env.d.ts
/apps/web/yarn.lock
# macos
.DS_Store

101
README.md
View File

@ -1,54 +1,87 @@
# OpenCut (prev AppCut)
# OpenCut
A free, open-source video editor for web, desktop, and mobile.
A free, open-source video editor that works completely offline in your browser.
## Why?
- **Privacy**: Your videos stay on your device
- **Free features**: Every basic feature of CapCut is paywalled now
- **Simple**: People want editors that are easy to use - CapCut proved that
- **Privacy**: Your videos never leave your device - everything is processed client-side
- **Free features**: No paywalls, subscriptions, or account required
- **Simple**: Easy to use interface inspired by CapCut
- **Offline**: Works without internet connection once loaded
## Features
- Timeline-based editing
- Multi-track support
- Real-time preview
- No watermarks or subscriptions
- Drag & drop media import
- No watermarks, accounts, or subscriptions
- Complete offline functionality
## Project Structure
## Quick Start
- `apps/web/` Main Next.js web application
- `src/components/` UI and editor components
- `src/hooks/` Custom React hooks
- `src/lib/` Utility and API logic
- `src/stores/` State management (Zustand, etc.)
- `src/types/` TypeScript types
### Web Version
```bash
cd apps/web
npm install
npm run dev
```
## Getting Started
Visit `http://localhost:3000` and click "Start Editing" - no setup required!
1. **Clone the repository:**
```bash
git clone <repo-url>
cd OpenCut
```
2. **Install dependencies:**
```bash
cd apps/web
npm install
# or, with Bun
bun install
```
3. **Run the development server:**
```bash
npm run dev
# or, with Bun
bun run dev
```
4. **Open in browser:**
Visit [http://localhost:3000](http://localhost:3000)
### Desktop Version
```bash
cd apps/desktop
npm install
npm run dev
```
Creates a native desktop app experience.
## Architecture
OpenCut is designed as a **privacy-first, offline-capable** video editor:
- **No authentication required** - just start editing
- **Client-side processing** - your videos stay on your device
- **Optional database** - only needed for the waitlist feature on the website
- **No servers required** - the editor works completely offline
### Optional Features (Web only)
The waitlist signup is the only feature that uses a server. Set these environment variables if you want it:
```bash
# Optional: For waitlist functionality
DATABASE_URL="postgresql://..."
UPSTASH_REDIS_REST_URL="..."
UPSTASH_REDIS_REST_TOKEN="..."
```
**The video editor works perfectly without any of these!**
## Production Build
### Desktop App
```bash
cd apps/desktop
npm run build
```
Creates installers for macOS, Windows, and Linux.
### Web App
```bash
cd apps/web
npm run build
```
Generates a static site that can be hosted anywhere.
## Contributing
All contributions welcome! The core principle is to keep video editing completely client-side and privacy-focused.
## License
MIT [Details](LICENSE)

52
apps/desktop/README.md Normal file
View File

@ -0,0 +1,52 @@
# OpenCut Desktop
Electron wrapper for the OpenCut web application.
## Development
To run the desktop app in development mode:
```bash
npm install
npm run dev
```
This will:
1. Start the web development server on localhost:3000
2. Wait for the server to be ready
3. Launch Electron pointing to the web server
## Production Build
To build a production desktop application:
```bash
npm install
npm run build
```
This will:
1. Set the `ELECTRON_BUILD=true` environment variable
2. Build the web app as a static export to `../web/out/`
3. Package the Electron app with electron-builder
The built applications will be in the `dist/` directory.
## Platform-specific Builds
- **macOS**: Creates a `.dmg` installer
- **Windows**: Creates an NSIS installer
- **Linux**: Creates an AppImage
## Configuration
The desktop app is configured via:
- `electron-builder.json` - Build and packaging settings
- `main.js` - Electron main process
- `package.json` - Dependencies and scripts
## Differences from Web Version
- No server-side database calls (uses static export)
- Shows "Start Editing" button instead of waitlist signup
- Includes security hardening for desktop environment

View File

@ -0,0 +1,31 @@
{
"appId": "com.opencut.app",
"productName": "OpenCut",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"package.json",
{
"from": "../web/out",
"to": "web",
"filter": ["**/*"]
}
],
"asar": false,
"mac": {
"category": "public.app-category.video",
"target": "dmg"
},
"win": {
"target": "nsis"
},
"linux": {
"target": "AppImage"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}

78
apps/desktop/fix-paths.js Normal file
View File

@ -0,0 +1,78 @@
const fs = require('fs');
const path = require('path');
// Fix asset paths in generated HTML files for Electron
function fixAssetPaths(dir) {
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
fixAssetPaths(filePath);
} else if (file.endsWith('.html')) {
let content = fs.readFileSync(filePath, 'utf8');
// Replace all absolute paths with relative paths
// CSS and JS files in attributes
content = content.replace(/href="\/_next\//g, 'href="./_next/');
content = content.replace(/src="\/_next\//g, 'src="./_next/');
// Images and icons
content = content.replace(/href="\/favicon/g, 'href="./favicon');
content = content.replace(/src="\/logo/g, 'src="./logo');
content = content.replace(/src="\/([^"]*\.(png|jpg|jpeg|gif|svg))/g, 'src="./$1');
// Fix paths in inline scripts and JSON data
content = content.replace(/"\/_next\//g, '"./_next/');
content = content.replace(/'\/_next\//g, "'./_next/");
// Fix other asset references
content = content.replace(/"\/static\//g, '"./static/');
content = content.replace(/'\/static\//g, "'./static/");
// Fix favicon in JSON data
content = content.replace(/"href":"\/favicon/g, '"href":"./favicon');
content = content.replace(/"src":"\/favicon/g, '"src":"./favicon');
// Fix any remaining standalone asset paths
content = content.replace(/href="\/([^"]*\.(css|js|woff2?|ttf|eot))/g, 'href="./$1"');
content = content.replace(/src="\/([^"]*\.(js|css|woff2?|ttf|eot))/g, 'src="./$1"');
// Fix paths in Next.js inline JSON data - more comprehensive
content = content.replace(/"static\/chunks\//g, '"_next/static/chunks/');
content = content.replace(/,\\"static\/chunks\//g, ',\\"_next/static/chunks/');
content = content.replace(/\[\"static\/chunks\//g, '["_next/static/chunks/');
// Fix favicon references in JSON metadata
content = content.replace(/"rel":"icon","href":"\/favicon/g, '"rel":"icon","href":"./favicon');
content = content.replace(/"href":"\/favicon\.ico"/g, '"href":"./favicon.ico"');
// Fix any script or link tags that might have been missed
content = content.replace(/<script[^>]+src="\/(?!http)/g, (match) => {
return match.replace('src="/', 'src="./');
});
content = content.replace(/<link[^>]+href="\/(?!http)/g, (match) => {
return match.replace('href="/', 'href="./');
});
// Fix paths in Next.js build manifest data
content = content.replace(/\\"\/([^"]*\.(js|css|woff2?|ttf|eot))\\"/g, '\\"./$1\\"');
content = content.replace(/\\"static\//g, '\\"_next/static/');
fs.writeFileSync(filePath, content);
console.log(`Fixed paths in ${filePath}`);
}
});
}
// Fix paths in the out directory
const outDir = path.join(__dirname, '../web/out');
if (fs.existsSync(outDir)) {
fixAssetPaths(outDir);
console.log('Asset path fixing complete!');
} else {
console.error('Out directory not found:', outDir);
}

109
apps/desktop/main.js Normal file
View File

@ -0,0 +1,109 @@
const { app, BrowserWindow, protocol } = require('electron');
const path = require('path');
const isDev = !app.isPackaged;
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: {
contextIsolation: true,
enableRemoteModule: false,
nodeIntegration: false,
// Allow loading local content
webSecurity: false,
},
show: false, // Don't show until ready
});
if (isDev) {
mainWindow.loadURL('http://localhost:3000');
mainWindow.webContents.openDevTools();
} else {
// Load from the web directory in the app bundle
const indexPath = path.join(__dirname, 'web', 'index.html');
console.log('Loading file from:', indexPath);
// Set up proper base URL for relative assets
const webDir = path.join(__dirname, 'web');
// Ensure proper MIME types for assets
mainWindow.webContents.session.webRequest.onBeforeRequest((details, callback) => {
if (details.url.startsWith('file://')) {
// Handle file requests properly
callback({});
} else {
callback({});
}
});
mainWindow.loadFile(indexPath, {
// This helps with resolving relative paths
query: {},
hash: ''
});
// Open DevTools in production to debug
mainWindow.webContents.openDevTools();
// Log any console errors
mainWindow.webContents.on('console-message', (event, level, message) => {
console.log(`Console [${level}]:`, message);
});
// Log when page fails to load
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('Failed to load:', errorCode, errorDescription);
});
// Handle navigation to ensure proper path resolution
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith('file://') && !url.startsWith('http://localhost')) {
event.preventDefault();
}
});
}
// Show window when ready to prevent visual flash
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
// Handle window closed
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// Register file protocol for better asset handling
app.whenReady().then(() => {
// Register a custom protocol to handle file loading
protocol.registerFileProtocol('file', (request, callback) => {
const pathname = decodeURI(request.url.replace('file:///', ''));
callback(pathname);
});
createWindow();
// macOS specific - re-create window when dock icon is clicked
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// Security: Prevent new window creation
app.on('web-contents-created', (event, contents) => {
contents.on('new-window', (event, navigationUrl) => {
event.preventDefault();
});
});

4369
apps/desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
apps/desktop/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "opencut-desktop",
"version": "0.1.0",
"main": "main.js",
"private": true,
"description": "Electron build for OpenCut",
"author": "OpenCut",
"scripts": {
"dev": "concurrently \"npm --prefix ../web run dev\" \"wait-on http://localhost:3000 && electron .\"",
"build": "cross-env ELECTRON_BUILD=true npm --prefix ../web run build && node fix-paths.js && electron-builder"
},
"devDependencies": {
"electron": "^36.5.0",
"concurrently": "^8.2.2",
"wait-on": "^7.1.1",
"electron-builder": "^24.6.0",
"cross-env": "^7.0.3"
}
}

3
apps/web/.eslintignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
.next
out

3
apps/web/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

View File

@ -1,23 +0,0 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
// Load the right env file based on environment
if (process.env.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
} else {
dotenv.config({ path: ".env.local" });
}
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
export default {
schema: "./src/lib/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
out: "./migrations",
strict: process.env.NODE_ENV === "production",
} satisfies Config;

View File

@ -1,54 +0,0 @@
CREATE TABLE "accounts" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "sessions_token_unique" UNIQUE("token")
);
--> statement-breakpoint
ALTER TABLE "sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean NOT NULL,
"image" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE "verifications" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp,
"updated_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "verifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;

View File

@ -1,319 +0,0 @@
{
"id": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1750581188229,
"tag": "0000_hot_the_fallen",
"breakpoints": true
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -2,16 +2,11 @@
"name": "next-template",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.2",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
"lint": "next lint"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
@ -19,9 +14,7 @@
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@types/pg": "^8.15.4",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.4.1",
"better-auth": "^1.2.7",
"class-variance-authority": "^0.7.1",
@ -29,7 +22,6 @@
"cmdk": "^1.0.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.1",
@ -37,7 +29,6 @@
"motion": "^12.18.1",
"next": "^15.2.0",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"radix-ui": "^1.4.2",
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
@ -54,11 +45,13 @@
"zustand": "^5.0.2"
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.14",
"@types/bun": "latest",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
"eslint-config-next": "^15.3.4",
"postcss": "^8",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.1",

View File

@ -1,65 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { waitlist } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { waitlistRateLimit } from "@/lib/rate-limit";
import { z } from "zod";
const waitlistSchema = z.object({
email: z.string().email("Invalid email format").min(1, "Email is required"),
});
export async function POST(request: NextRequest) {
// Rate limit check
const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success } = await waitlistRateLimit.limit(identifier);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 }
);
}
try {
const body = await request.json();
const { email } = waitlistSchema.parse(body);
// Check if email already exists
const existingEmail = await db
.select()
.from(waitlist)
.where(eq(waitlist.email, email.toLowerCase()))
.limit(1);
if (existingEmail.length > 0) {
return NextResponse.json(
{ error: "Email already registered" },
{ status: 409 }
);
}
// Add to waitlist
await db.insert(waitlist).values({
id: nanoid(),
email: email.toLowerCase(),
});
return NextResponse.json(
{ message: "Successfully joined waitlist!" },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
return NextResponse.json({ error: firstError.message }, { status: 400 });
}
console.error("Waitlist signup error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -86,7 +86,7 @@ export default function LoginPage() {
<LoginForm />
</Suspense>
<div className="mt-4 text-center text-sm">
Don't have an account?{" "}
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="underline">
Sign up
</Link>

View File

@ -1,17 +1,11 @@
import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { getWaitlistCount } from "@/lib/waitlist";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic";
export default async function Home() {
const signupCount = await getWaitlistCount();
export default function Home() {
return (
<div>
<Header />
<Hero signupCount={signupCount} />
<Hero />
</div>
);
}

View File

@ -2,70 +2,10 @@
import { motion } from "motion/react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
interface HeroProps {
signupCount: number;
}
export function Hero({ signupCount }: HeroProps) {
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
toast({
title: "Email required",
description: "Please enter your email address.",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const response = await fetch("/api/waitlist", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: email.trim() }),
});
const data = await response.json();
if (response.ok) {
toast({
title: "Welcome to the waitlist! 🎉",
description: "You'll be notified when we launch.",
});
setEmail("");
} else {
toast({
title: "Oops!",
description: data.error || "Something went wrong. Please try again.",
variant: "destructive",
});
}
} catch (error) {
toast({
title: "Network error",
description: "Please check your connection and try again.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
export function Hero() {
return (
<div className="relative min-h-screen flex flex-col items-center justify-center text-center px-4">
<motion.div
@ -104,41 +44,15 @@ export function Hero({ signupCount }: HeroProps) {
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }}
>
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
<Input
type="email"
placeholder="Enter your email"
className="h-11 text-base flex-1"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
required
/>
<Button
type="submit"
size="lg"
className="px-6 h-11 text-base"
disabled={isSubmitting}
>
<Link href="/editor">
<Button size="lg" className="px-8 h-12 text-lg">
<span className="relative z-10">
{isSubmitting ? "Joining..." : "Join waitlist"}
Get Started
</span>
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
<ArrowRight className="relative z-10 ml-2 h-5 w-5 inline-block" />
</Button>
</form>
</Link>
</motion.div>
{signupCount > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }}
className="mt-6 inline-flex items-center gap-2 bg-muted/30 px-4 py-2 rounded-full text-sm text-muted-foreground"
>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>{signupCount.toLocaleString()} people already joined</span>
</motion.div>
)}
</motion.div>
<motion.div

View File

@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { type DialogProps } from "radix-ui";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";

View File

@ -1,13 +1,7 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
secret: process.env.BETTER_AUTH_SECRET!,
secret: process.env.BETTER_AUTH_SECRET || "default-secret-for-development",
user: {
deleteUser: {
enabled: true,

View File

@ -1,14 +0,0 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 3,
idleTimeoutMillis: 10000,
connectionTimeoutMillis: 15000,
query_timeout: 20000,
statement_timeout: 20000,
});
export const db = drizzle(pool, { schema });

View File

@ -1,69 +0,0 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified")
.$defaultFn(() => false)
.notNull(),
image: text("image"),
createdAt: timestamp("created_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp("updated_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
}).enableRLS();
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
}).enableRLS();
export const accounts = pgTable("accounts", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
}).enableRLS();
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").$defaultFn(
() => /* @__PURE__ */ new Date()
),
updatedAt: timestamp("updated_at").$defaultFn(
() => /* @__PURE__ */ new Date()
),
}).enableRLS();
export const waitlist = pgTable("waitlist", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
}).enableRLS();

View File

@ -1,14 +0,0 @@
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export const waitlistRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute
analytics: true,
});

View File

@ -1,15 +0,0 @@
import { db } from "@/lib/db";
import { waitlist } from "@/lib/db/schema";
import { sql } from "drizzle-orm";
export async function getWaitlistCount() {
try {
const result = await db
.select({ count: sql<number>`count(*)` })
.from(waitlist);
return result[0]?.count || 0;
} catch (error) {
console.error("Failed to fetch waitlist count:", error);
return 0;
}
}