minor additions
This commit is contained in:
parent
c55b7796a6
commit
a5072eb751
|
|
@ -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
101
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
.next
|
||||
out
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@ export default function LoginPage() {
|
|||
<LoginForm />
|
||||
</Suspense>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/signup" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue