mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
.
This commit is contained in:
12
packages/web/.cta.json
Normal file
12
packages/web/.cta.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"projectName": "web",
|
||||
"mode": "file-router",
|
||||
"typescript": true,
|
||||
"tailwind": true,
|
||||
"packageManager": "npm",
|
||||
"git": false,
|
||||
"addOnOptions": {},
|
||||
"version": 1,
|
||||
"framework": "react-cra",
|
||||
"chosenAddOns": ["start", "cloudflare"]
|
||||
}
|
||||
45
packages/web/.env.example
Normal file
45
packages/web/.env.example
Normal file
@@ -0,0 +1,45 @@
|
||||
# Neon PostgreSQL (https://console.neon.tech)
|
||||
# Format: postgresql://<user>:<password>@<host>/<database>?sslmode=require
|
||||
DATABASE_URL=postgresql://user:password@ep-xxx.region.aws.neon.tech/neondb?sslmode=require
|
||||
ELECTRIC_URL=http://localhost:3100
|
||||
BETTER_AUTH_SECRET=your-strong-secret-at-least-32-chars
|
||||
APP_BASE_URL=http://localhost:5000
|
||||
|
||||
# Optional: Electric Cloud credentials (for production)
|
||||
ELECTRIC_SOURCE_ID=
|
||||
ELECTRIC_SOURCE_SECRET=
|
||||
|
||||
# Optional: OpenRouter for AI chat responses (https://openrouter.ai/keys)
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_MODEL=google/gemini-2.0-flash-001
|
||||
|
||||
# Optional: Flowglad billing (enable billing UI + metering)
|
||||
FLOWGLAD_SECRET_KEY=
|
||||
VITE_FLOWGLAD_ENABLED=false
|
||||
|
||||
# Optional: Resend for production email OTP (https://resend.com/api-keys)
|
||||
# In dev mode, OTP codes are logged to terminal instead
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
|
||||
# Optional: Gemini for canvas image generation
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# ===========================================
|
||||
# PRODUCTION DEPLOYMENT (Cloudflare Workers)
|
||||
# ===========================================
|
||||
# Neon PostgreSQL DATABASE_URL format:
|
||||
# postgresql://<user>:<password>@<endpoint>.neon.tech/<database>?sslmode=require
|
||||
#
|
||||
# Set these as secrets in Cloudflare:
|
||||
# wrangler secret put DATABASE_URL
|
||||
# wrangler secret put BETTER_AUTH_SECRET
|
||||
# wrangler secret put ELECTRIC_URL
|
||||
# wrangler secret put ELECTRIC_SOURCE_ID
|
||||
# wrangler secret put ELECTRIC_SOURCE_SECRET
|
||||
# wrangler secret put OPENROUTER_API_KEY
|
||||
# wrangler secret put RESEND_API_KEY
|
||||
#
|
||||
# Or set APP_BASE_URL/RESEND_FROM_EMAIL as variables:
|
||||
# wrangler vars set APP_BASE_URL https://your-domain.com
|
||||
# wrangler vars set RESEND_FROM_EMAIL noreply@your-domain.com
|
||||
17
packages/web/.gitignore
vendored
Normal file
17
packages/web/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
count.txt
|
||||
.env
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
.output
|
||||
.vinxi
|
||||
todos.json
|
||||
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
!.env.example
|
||||
11
packages/web/.vscode/settings.json
vendored
Normal file
11
packages/web/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files.watcherExclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
}
|
||||
}
|
||||
46
packages/web/docker-compose.yml
Normal file
46
packages/web/docker-compose.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: gen
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: gen-postgres
|
||||
command: "-c wal_level=logical"
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=password
|
||||
- POSTGRES_DB=electric
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
neon-proxy:
|
||||
image: ghcr.io/timowilhelm/local-neon-http-proxy:main
|
||||
container_name: gen-neon-proxy
|
||||
environment:
|
||||
- PG_CONNECTION_STRING=postgres://postgres:password@postgres:5432/electric
|
||||
ports:
|
||||
- "4444:4444"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
electric:
|
||||
image: electricsql/electric:latest
|
||||
container_name: gen-electric
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:password@postgres:5432/electric
|
||||
ELECTRIC_INSECURE: "true"
|
||||
ports:
|
||||
- "3100:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
12
packages/web/drizzle.config.ts
Normal file
12
packages/web/drizzle.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import "dotenv/config"
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
casing: "snake_case",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
})
|
||||
102
packages/web/drizzle/0000_freezing_black_crow.sql
Normal file
102
packages/web/drizzle/0000_freezing_black_crow.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
CREATE TABLE "accounts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"accountId" text NOT NULL,
|
||||
"providerId" text NOT NULL,
|
||||
"userId" text NOT NULL,
|
||||
"accessToken" text,
|
||||
"refreshToken" text,
|
||||
"idToken" text,
|
||||
"accessTokenExpiresAt" timestamp,
|
||||
"refreshTokenExpiresAt" timestamp,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"updatedAt" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "canvas" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"owner_id" text NOT NULL,
|
||||
"name" text DEFAULT 'Untitled Canvas' NOT NULL,
|
||||
"width" integer DEFAULT 1024 NOT NULL,
|
||||
"height" integer DEFAULT 1024 NOT NULL,
|
||||
"default_model" text DEFAULT 'gemini-2.0-flash-exp-image-generation' NOT NULL,
|
||||
"default_style" text DEFAULT 'default' NOT NULL,
|
||||
"background_prompt" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "canvas_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"canvas_id" uuid NOT NULL,
|
||||
"name" text DEFAULT 'Untitled Image' NOT NULL,
|
||||
"prompt" text DEFAULT '' NOT NULL,
|
||||
"model_id" text DEFAULT 'gemini-2.0-flash-exp-image-generation' NOT NULL,
|
||||
"model_used" text,
|
||||
"style_id" text DEFAULT 'default' NOT NULL,
|
||||
"width" integer DEFAULT 512 NOT NULL,
|
||||
"height" integer DEFAULT 512 NOT NULL,
|
||||
"position" jsonb NOT NULL,
|
||||
"rotation" double precision DEFAULT 0 NOT NULL,
|
||||
"content_base64" text,
|
||||
"image_url" text,
|
||||
"metadata" jsonb,
|
||||
"branch_parent_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_messages" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "chat_messages_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"thread_id" integer NOT NULL,
|
||||
"role" varchar(32) NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_threads" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "chat_threads_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"title" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"expiresAt" timestamp NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"updatedAt" timestamp NOT NULL,
|
||||
"ipAddress" text,
|
||||
"userAgent" text,
|
||||
"userId" text NOT NULL,
|
||||
CONSTRAINT "sessions_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"emailVerified" boolean NOT NULL,
|
||||
"image" text,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"updatedAt" timestamp NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verifications" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expiresAt" timestamp NOT NULL,
|
||||
"createdAt" timestamp,
|
||||
"updatedAt" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "canvas" ADD CONSTRAINT "canvas_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "canvas_images" ADD CONSTRAINT "canvas_images_canvas_id_canvas_id_fk" FOREIGN KEY ("canvas_id") REFERENCES "public"."canvas"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "canvas_images" ADD CONSTRAINT "canvas_images_branch_parent_id_canvas_images_id_fk" FOREIGN KEY ("branch_parent_id") REFERENCES "public"."canvas_images"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_thread_id_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
23
packages/web/drizzle/0001_loving_captain_midlands.sql
Normal file
23
packages/web/drizzle/0001_loving_captain_midlands.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE "context_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "context_items_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" text NOT NULL,
|
||||
"type" varchar(32) NOT NULL,
|
||||
"url" text,
|
||||
"name" text NOT NULL,
|
||||
"content" text,
|
||||
"refreshing" boolean DEFAULT false NOT NULL,
|
||||
"parent_id" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "thread_context_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "thread_context_items_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"thread_id" integer NOT NULL,
|
||||
"context_item_id" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "context_items" ADD CONSTRAINT "context_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_context_items" ADD CONSTRAINT "thread_context_items_thread_id_chat_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."chat_threads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "thread_context_items" ADD CONSTRAINT "thread_context_items_context_item_id_context_items_id_fk" FOREIGN KEY ("context_item_id") REFERENCES "public"."context_items"("id") ON DELETE cascade ON UPDATE no action;
|
||||
31
packages/web/drizzle/0002_uneven_the_renegades.sql
Normal file
31
packages/web/drizzle/0002_uneven_the_renegades.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE "blocks" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "blocks_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "browser_session_tabs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_id" uuid NOT NULL,
|
||||
"title" text DEFAULT '' NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"favicon_url" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "browser_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"browser" varchar(32) DEFAULT 'safari' NOT NULL,
|
||||
"tab_count" integer DEFAULT 0 NOT NULL,
|
||||
"is_favorite" boolean DEFAULT false NOT NULL,
|
||||
"captured_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "canvas" ALTER COLUMN "default_model" SET DEFAULT 'gemini-2.5-flash-image-preview';--> statement-breakpoint
|
||||
ALTER TABLE "chat_threads" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "browser_session_tabs" ADD CONSTRAINT "browser_session_tabs_session_id_browser_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."browser_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "browser_sessions" ADD CONSTRAINT "browser_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
684
packages/web/drizzle/meta/0000_snapshot.json
Normal file
684
packages/web/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,684 @@
|
||||
{
|
||||
"id": "3e047a36-a388-45ed-b92a-d1a5f7bdddfa",
|
||||
"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
|
||||
},
|
||||
"accountId": {
|
||||
"name": "accountId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"providerId": {
|
||||
"name": "providerId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"accessToken": {
|
||||
"name": "accessToken",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refreshToken": {
|
||||
"name": "refreshToken",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"idToken": {
|
||||
"name": "idToken",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"accessTokenExpiresAt": {
|
||||
"name": "accessTokenExpiresAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refreshTokenExpiresAt": {
|
||||
"name": "refreshTokenExpiresAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_userId_users_id_fk": {
|
||||
"name": "accounts_userId_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.canvas": {
|
||||
"name": "canvas",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Untitled Canvas'"
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1024
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1024
|
||||
},
|
||||
"default_model": {
|
||||
"name": "default_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'gemini-2.0-flash-exp-image-generation'"
|
||||
},
|
||||
"default_style": {
|
||||
"name": "default_style",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'default'"
|
||||
},
|
||||
"background_prompt": {
|
||||
"name": "background_prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"canvas_owner_id_users_id_fk": {
|
||||
"name": "canvas_owner_id_users_id_fk",
|
||||
"tableFrom": "canvas",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.canvas_images": {
|
||||
"name": "canvas_images",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"canvas_id": {
|
||||
"name": "canvas_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Untitled Image'"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"model_id": {
|
||||
"name": "model_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'gemini-2.0-flash-exp-image-generation'"
|
||||
},
|
||||
"model_used": {
|
||||
"name": "model_used",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"style_id": {
|
||||
"name": "style_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'default'"
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 512
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 512
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"rotation": {
|
||||
"name": "rotation",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"content_base64": {
|
||||
"name": "content_base64",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"branch_parent_id": {
|
||||
"name": "branch_parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"canvas_images_canvas_id_canvas_id_fk": {
|
||||
"name": "canvas_images_canvas_id_canvas_id_fk",
|
||||
"tableFrom": "canvas_images",
|
||||
"tableTo": "canvas",
|
||||
"columnsFrom": [
|
||||
"canvas_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"canvas_images_branch_parent_id_canvas_images_id_fk": {
|
||||
"name": "canvas_images_branch_parent_id_canvas_images_id_fk",
|
||||
"tableFrom": "canvas_images",
|
||||
"tableTo": "canvas_images",
|
||||
"columnsFrom": [
|
||||
"branch_parent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_messages": {
|
||||
"name": "chat_messages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "chat_messages_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"chat_messages_thread_id_chat_threads_id_fk": {
|
||||
"name": "chat_messages_thread_id_chat_threads_id_fk",
|
||||
"tableFrom": "chat_messages",
|
||||
"tableTo": "chat_threads",
|
||||
"columnsFrom": [
|
||||
"thread_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_threads": {
|
||||
"name": "chat_threads",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "chat_threads_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expiresAt": {
|
||||
"name": "expiresAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ipAddress": {
|
||||
"name": "ipAddress",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"userAgent": {
|
||||
"name": "userAgent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_userId_users_id_fk": {
|
||||
"name": "sessions_userId_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"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
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"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
|
||||
},
|
||||
"expiresAt": {
|
||||
"name": "expiresAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
861
packages/web/drizzle/meta/0001_snapshot.json
Normal file
861
packages/web/drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,861 @@
|
||||
{
|
||||
"id": "49ea3b31-322c-4596-8274-73d9a0e7b6f2",
|
||||
"prevId": "3e047a36-a388-45ed-b92a-d1a5f7bdddfa",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"accountId": {
|
||||
"name": "accountId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"providerId": {
|
||||
"name": "providerId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"accessToken": {
|
||||
"name": "accessToken",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refreshToken": {
|
||||
"name": "refreshToken",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"idToken": {
|
||||
"name": "idToken",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"accessTokenExpiresAt": {
|
||||
"name": "accessTokenExpiresAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refreshTokenExpiresAt": {
|
||||
"name": "refreshTokenExpiresAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_userId_users_id_fk": {
|
||||
"name": "accounts_userId_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.canvas": {
|
||||
"name": "canvas",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Untitled Canvas'"
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1024
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1024
|
||||
},
|
||||
"default_model": {
|
||||
"name": "default_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'gemini-2.0-flash-exp-image-generation'"
|
||||
},
|
||||
"default_style": {
|
||||
"name": "default_style",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'default'"
|
||||
},
|
||||
"background_prompt": {
|
||||
"name": "background_prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"canvas_owner_id_users_id_fk": {
|
||||
"name": "canvas_owner_id_users_id_fk",
|
||||
"tableFrom": "canvas",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.canvas_images": {
|
||||
"name": "canvas_images",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"canvas_id": {
|
||||
"name": "canvas_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'Untitled Image'"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"model_id": {
|
||||
"name": "model_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'gemini-2.0-flash-exp-image-generation'"
|
||||
},
|
||||
"model_used": {
|
||||
"name": "model_used",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"style_id": {
|
||||
"name": "style_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'default'"
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 512
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 512
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"rotation": {
|
||||
"name": "rotation",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"content_base64": {
|
||||
"name": "content_base64",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"branch_parent_id": {
|
||||
"name": "branch_parent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"canvas_images_canvas_id_canvas_id_fk": {
|
||||
"name": "canvas_images_canvas_id_canvas_id_fk",
|
||||
"tableFrom": "canvas_images",
|
||||
"tableTo": "canvas",
|
||||
"columnsFrom": [
|
||||
"canvas_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"canvas_images_branch_parent_id_canvas_images_id_fk": {
|
||||
"name": "canvas_images_branch_parent_id_canvas_images_id_fk",
|
||||
"tableFrom": "canvas_images",
|
||||
"tableTo": "canvas_images",
|
||||
"columnsFrom": [
|
||||
"branch_parent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_messages": {
|
||||
"name": "chat_messages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "chat_messages_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"chat_messages_thread_id_chat_threads_id_fk": {
|
||||
"name": "chat_messages_thread_id_chat_threads_id_fk",
|
||||
"tableFrom": "chat_messages",
|
||||
"tableTo": "chat_threads",
|
||||
"columnsFrom": [
|
||||
"thread_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_threads": {
|
||||
"name": "chat_threads",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "chat_threads_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.context_items": {
|
||||
"name": "context_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "context_items_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refreshing": {
|
||||
"name": "refreshing",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"context_items_user_id_users_id_fk": {
|
||||
"name": "context_items_user_id_users_id_fk",
|
||||
"tableFrom": "context_items",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expiresAt": {
|
||||
"name": "expiresAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ipAddress": {
|
||||
"name": "ipAddress",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"userAgent": {
|
||||
"name": "userAgent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_userId_users_id_fk": {
|
||||
"name": "sessions_userId_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.thread_context_items": {
|
||||
"name": "thread_context_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "thread_context_items_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"thread_id": {
|
||||
"name": "thread_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"context_item_id": {
|
||||
"name": "context_item_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"thread_context_items_thread_id_chat_threads_id_fk": {
|
||||
"name": "thread_context_items_thread_id_chat_threads_id_fk",
|
||||
"tableFrom": "thread_context_items",
|
||||
"tableTo": "chat_threads",
|
||||
"columnsFrom": [
|
||||
"thread_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"thread_context_items_context_item_id_context_items_id_fk": {
|
||||
"name": "thread_context_items_context_item_id_context_items_id_fk",
|
||||
"tableFrom": "thread_context_items",
|
||||
"tableTo": "context_items",
|
||||
"columnsFrom": [
|
||||
"context_item_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"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
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"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
|
||||
},
|
||||
"expiresAt": {
|
||||
"name": "expiresAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
1058
packages/web/drizzle/meta/0002_snapshot.json
Normal file
1058
packages/web/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
27
packages/web/drizzle/meta/_journal.json
Normal file
27
packages/web/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1764686923088,
|
||||
"tag": "0000_freezing_black_crow",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1764866473191,
|
||||
"tag": "0001_loving_captain_midlands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1765916542205,
|
||||
"tag": "0002_uneven_the_renegades",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
20
packages/web/env.d.ts
vendored
Normal file
20
packages/web/env.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Environment bindings for the web worker
|
||||
* This extends the auto-generated worker-configuration.d.ts
|
||||
*/
|
||||
|
||||
import type { WorkerRpc } from "../worker/src/rpc"
|
||||
|
||||
declare module "cloudflare:workers" {
|
||||
interface Env {
|
||||
// Service binding to the worker RPC
|
||||
WORKER_RPC: Service<WorkerRpc>
|
||||
}
|
||||
}
|
||||
|
||||
// For compatibility with TanStack Start
|
||||
declare global {
|
||||
interface CloudflareEnv extends Env {}
|
||||
}
|
||||
|
||||
export {}
|
||||
71
packages/web/package.json
Normal file
71
packages/web/package.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "@linsa/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5613 --strictPort",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest run",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"preview": "npm run build && vite preview",
|
||||
"cf-typegen": "wrangler types",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"seed": "tsx scripts/seed.ts",
|
||||
"migrate": "drizzle-kit migrate",
|
||||
"migrate:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^2.0.79",
|
||||
"@ai-sdk/react": "^2.0.109",
|
||||
"@cloudflare/vite-plugin": "^1.17.0",
|
||||
"@electric-sql/client": "^1.2.0",
|
||||
"@flowglad/react": "0.15.0",
|
||||
"@flowglad/server": "0.15.0",
|
||||
"@openrouter/ai-sdk-provider": "^1.4.1",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/electric-db-collection": "^0.2.12",
|
||||
"@tanstack/react-db": "^0.1.55",
|
||||
"@tanstack/react-devtools": "^0.8.2",
|
||||
"@tanstack/react-router": "^1.140.0",
|
||||
"@tanstack/react-router-devtools": "^1.140.0",
|
||||
"@tanstack/react-router-ssr-query": "^1.140.0",
|
||||
"@tanstack/react-start": "^1.140.0",
|
||||
"@tanstack/router-plugin": "^1.140.0",
|
||||
"ai": "^5.0.108",
|
||||
"better-auth": "^1.4.5",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"framer-motion": "^12.23.25",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.556.0",
|
||||
"postgres": "^3.4.7",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.5.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"jsdom": "^27.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.15",
|
||||
"web-vitals": "^5.1.0",
|
||||
"wrangler": "^4.53.0"
|
||||
}
|
||||
}
|
||||
BIN
packages/web/public/favicon.ico
Normal file
BIN
packages/web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
packages/web/public/logo192.png
Normal file
BIN
packages/web/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
packages/web/public/logo512.png
Normal file
BIN
packages/web/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
packages/web/public/manifest.json
Normal file
25
packages/web/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "TanStack App",
|
||||
"name": "Create TanStack App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
6
packages/web/public/robots.txt
Normal file
6
packages/web/public/robots.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
# robots.txt for Linsa
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: https://linsa.io/sitemap.xml
|
||||
9
packages/web/public/sitemap.xml
Normal file
9
packages/web/public/sitemap.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://linsa.io/</loc>
|
||||
<lastmod>2025-12-13</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
BIN
packages/web/public/tanstack-circle-logo.png
Normal file
BIN
packages/web/public/tanstack-circle-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
1
packages/web/public/tanstack-word-logo-white.svg
Normal file
1
packages/web/public/tanstack-word-logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
283
packages/web/readme.md
Normal file
283
packages/web/readme.md
Normal file
@@ -0,0 +1,283 @@
|
||||
Welcome to your new TanStack app!
|
||||
|
||||
# Getting Started
|
||||
|
||||
To run this application:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
# Building For Production
|
||||
|
||||
To build this application for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||
|
||||
## Routing
|
||||
|
||||
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
|
||||
|
||||
### Adding A Route
|
||||
|
||||
To add a new route to your application just add another a new file in the `./src/routes` directory.
|
||||
|
||||
TanStack will automatically generate the content of the route file for you.
|
||||
|
||||
Now that you have two routes you can use a `Link` component to navigate between them.
|
||||
|
||||
### Adding Links
|
||||
|
||||
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
|
||||
|
||||
```tsx
|
||||
import { Link } from '@tanstack/react-router';
|
||||
```
|
||||
|
||||
Then anywhere in your JSX you can use it like so:
|
||||
|
||||
```tsx
|
||||
<Link to="/about">About</Link>
|
||||
```
|
||||
|
||||
This will create a link that will navigate to the `/about` route.
|
||||
|
||||
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
|
||||
|
||||
### Using A Layout
|
||||
|
||||
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `<Outlet />` component.
|
||||
|
||||
Here is an example layout that includes a header:
|
||||
|
||||
```tsx
|
||||
import { Outlet, createRootRoute } from '@tanstack/react-router';
|
||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
|
||||
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<header>
|
||||
<nav>
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/about">About</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.
|
||||
|
||||
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||
|
||||
## Data Fetching
|
||||
|
||||
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
||||
|
||||
For example:
|
||||
|
||||
```tsx
|
||||
const peopleRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/people',
|
||||
loader: async () => {
|
||||
const response = await fetch('https://swapi.dev/api/people');
|
||||
return response.json() as Promise<{
|
||||
results: {
|
||||
name: string;
|
||||
}[];
|
||||
}>;
|
||||
},
|
||||
component: () => {
|
||||
const data = peopleRoute.useLoaderData();
|
||||
return (
|
||||
<ul>
|
||||
{data.results.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
|
||||
|
||||
### React-Query
|
||||
|
||||
React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.
|
||||
|
||||
First add your dependencies:
|
||||
|
||||
```bash
|
||||
npm install @tanstack/react-query @tanstack/react-query-devtools
|
||||
```
|
||||
|
||||
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
|
||||
|
||||
```tsx
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// ...
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// ...
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
You can also add TanStack Query Devtools to the root route (optional).
|
||||
|
||||
```tsx
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<Outlet />
|
||||
<ReactQueryDevtools buttonPosition="top-right" />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
Now you can use `useQuery` to fetch your data.
|
||||
|
||||
```tsx
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['people'],
|
||||
queryFn: () =>
|
||||
fetch('https://swapi.dev/api/people')
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.results as { name: string }[]),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul>
|
||||
{data.map((person) => (
|
||||
<li key={person.name}>{person.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
|
||||
|
||||
## State Management
|
||||
|
||||
Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.
|
||||
|
||||
First you need to add TanStack Store as a dependency:
|
||||
|
||||
```bash
|
||||
npm install @tanstack/store
|
||||
```
|
||||
|
||||
Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
|
||||
|
||||
```tsx
|
||||
import { useStore } from '@tanstack/react-store';
|
||||
import { Store } from '@tanstack/store';
|
||||
import './App.css';
|
||||
|
||||
const countStore = new Store(0);
|
||||
|
||||
function App() {
|
||||
const count = useStore(countStore);
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => countStore.setState((n) => n + 1)}>Increment - {count}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
|
||||
|
||||
Let's check this out by doubling the count using derived state.
|
||||
|
||||
```tsx
|
||||
import { useStore } from '@tanstack/react-store';
|
||||
import { Store, Derived } from '@tanstack/store';
|
||||
import './App.css';
|
||||
|
||||
const countStore = new Store(0);
|
||||
|
||||
const doubledStore = new Derived({
|
||||
fn: () => countStore.state * 2,
|
||||
deps: [countStore],
|
||||
});
|
||||
doubledStore.mount();
|
||||
|
||||
function App() {
|
||||
const count = useStore(countStore);
|
||||
const doubledCount = useStore(doubledStore);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => countStore.setState((n) => n + 1)}>Increment - {count}</button>
|
||||
<div>Doubled - {doubledCount}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
|
||||
|
||||
Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.
|
||||
|
||||
You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).
|
||||
|
||||
# Demo files
|
||||
|
||||
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
|
||||
|
||||
# Learn More
|
||||
|
||||
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
|
||||
70
packages/web/scripts/db-connect.ts
Normal file
70
packages/web/scripts/db-connect.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Test PlanetScale Postgres connection
|
||||
*
|
||||
* Run: pnpm tsx scripts/db-connect.ts
|
||||
*/
|
||||
|
||||
import "dotenv/config"
|
||||
import postgres from "postgres"
|
||||
|
||||
const CONNECTION_STRING = process.env.DATABASE_URL
|
||||
|
||||
if (!CONNECTION_STRING) {
|
||||
console.error("❌ DATABASE_URL is required in .env")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sql = postgres(CONNECTION_STRING, {
|
||||
ssl: "require",
|
||||
max: 1,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
})
|
||||
|
||||
async function testConnection() {
|
||||
console.log("🔌 Connecting to PlanetScale Postgres...")
|
||||
|
||||
try {
|
||||
// Test basic connection
|
||||
const [result] = await sql`SELECT NOW() as time, current_database() as db`
|
||||
console.log("✅ Connected!")
|
||||
console.log(` Database: ${result.db}`)
|
||||
console.log(` Server time: ${result.time}`)
|
||||
|
||||
// List all databases
|
||||
const databases = await sql`
|
||||
SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname
|
||||
`
|
||||
console.log(`\n📁 Databases:`)
|
||||
for (const d of databases) {
|
||||
console.log(` - ${d.datname}`)
|
||||
}
|
||||
|
||||
// List tables in current db
|
||||
const tables = await sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`
|
||||
|
||||
if (tables.length > 0) {
|
||||
console.log(`\n📋 Tables (${tables.length}):`)
|
||||
for (const t of tables) {
|
||||
console.log(` - ${t.table_name}`)
|
||||
}
|
||||
} else {
|
||||
console.log("\n📋 No tables found in public schema")
|
||||
}
|
||||
|
||||
// Show version
|
||||
const [version] = await sql`SELECT version()`
|
||||
console.log(`\n🐘 ${version.version}`)
|
||||
} catch (err) {
|
||||
console.error("❌ Connection failed:", err)
|
||||
} finally {
|
||||
await sql.end()
|
||||
}
|
||||
}
|
||||
|
||||
testConnection()
|
||||
250
packages/web/scripts/db-query.ts
Normal file
250
packages/web/scripts/db-query.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Production Database Query Tool
|
||||
* Allows CRUD operations on the production database
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL="..." pnpm tsx scripts/db-query.ts
|
||||
*
|
||||
* Commands (interactive):
|
||||
* tables - List all tables
|
||||
* users - List all users
|
||||
* threads - List chat threads
|
||||
* sql <query> - Run raw SQL
|
||||
* insert-user <email> <name> - Create a user
|
||||
* delete-user <id> - Delete a user
|
||||
* help - Show commands
|
||||
* exit - Exit
|
||||
*/
|
||||
|
||||
import "dotenv/config"
|
||||
import postgres from "postgres"
|
||||
import * as readline from "readline"
|
||||
|
||||
const CONNECTION_STRING = process.env.DATABASE_URL
|
||||
|
||||
if (!CONNECTION_STRING) {
|
||||
console.error("❌ DATABASE_URL is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sql = postgres(CONNECTION_STRING, {
|
||||
ssl: "require",
|
||||
max: 1,
|
||||
})
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
async function listTables() {
|
||||
const tables = await sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`
|
||||
console.log("\n📋 Tables:")
|
||||
if (tables.length === 0) {
|
||||
console.log(" (no tables found)")
|
||||
} else {
|
||||
for (const t of tables) {
|
||||
const count = await sql`
|
||||
SELECT COUNT(*) as count FROM ${sql(t.table_name)}
|
||||
`
|
||||
console.log(` - ${t.table_name} (${count[0].count} rows)`)
|
||||
}
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
async function listUsers() {
|
||||
try {
|
||||
const users = await sql`SELECT id, name, email, "createdAt" FROM users ORDER BY "createdAt" DESC LIMIT 20`
|
||||
console.log("\n👥 Users:")
|
||||
if (users.length === 0) {
|
||||
console.log(" (no users)")
|
||||
} else {
|
||||
for (const u of users) {
|
||||
console.log(` - ${u.id}: ${u.name} <${u.email}> (${u.createdAt})`)
|
||||
}
|
||||
}
|
||||
console.log()
|
||||
} catch (e) {
|
||||
console.log(" ❌ users table not found or error:", (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function listThreads() {
|
||||
try {
|
||||
const threads = await sql`
|
||||
SELECT id, title, user_id, created_at
|
||||
FROM chat_threads
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
`
|
||||
console.log("\n💬 Chat Threads:")
|
||||
if (threads.length === 0) {
|
||||
console.log(" (no threads)")
|
||||
} else {
|
||||
for (const t of threads) {
|
||||
console.log(` - #${t.id}: "${t.title}" (user: ${t.user_id})`)
|
||||
}
|
||||
}
|
||||
console.log()
|
||||
} catch (e) {
|
||||
console.log(" ❌ chat_threads table not found or error:", (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function runSQL(query: string) {
|
||||
try {
|
||||
const result = await sql.unsafe(query)
|
||||
console.log("\n✅ Result:")
|
||||
console.log(result)
|
||||
console.log()
|
||||
} catch (e) {
|
||||
console.log("❌ Error:", (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function insertUser(email: string, name: string) {
|
||||
try {
|
||||
const id = `user_${Date.now()}`
|
||||
await sql`
|
||||
INSERT INTO users (id, name, email, "emailVerified", "createdAt", "updatedAt")
|
||||
VALUES (${id}, ${name}, ${email}, false, NOW(), NOW())
|
||||
`
|
||||
console.log(`✅ Created user: ${id}`)
|
||||
} catch (e) {
|
||||
console.log("❌ Error:", (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: string) {
|
||||
try {
|
||||
const result = await sql`DELETE FROM users WHERE id = ${id}`
|
||||
console.log(`✅ Deleted ${result.count} user(s)`)
|
||||
} catch (e) {
|
||||
console.log("❌ Error:", (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
📖 Commands:
|
||||
tables - List all tables with row counts
|
||||
users - List users (max 20)
|
||||
threads - List chat threads (max 20)
|
||||
sql <query> - Run raw SQL query
|
||||
insert-user <email> <name> - Create a new user
|
||||
delete-user <id> - Delete a user by ID
|
||||
drop-all - Drop all tables (dangerous!)
|
||||
help - Show this help
|
||||
exit - Exit the tool
|
||||
`)
|
||||
}
|
||||
|
||||
async function dropAll() {
|
||||
const confirm = await prompt("⚠️ This will DROP ALL TABLES. Type 'DROP' to confirm: ")
|
||||
if (confirm !== "DROP") {
|
||||
console.log("Aborted.")
|
||||
return
|
||||
}
|
||||
|
||||
const tables = [
|
||||
"thread_context_items",
|
||||
"context_items",
|
||||
"canvas_images",
|
||||
"canvas",
|
||||
"chat_messages",
|
||||
"chat_threads",
|
||||
"verifications",
|
||||
"accounts",
|
||||
"sessions",
|
||||
"users",
|
||||
]
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
await sql`DROP TABLE IF EXISTS ${sql(table)} CASCADE`
|
||||
console.log(` ✓ Dropped ${table}`)
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${table}: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
console.log("\n✓ All tables dropped")
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🔌 Connected to production database")
|
||||
console.log('Type "help" for commands, "exit" to quit.\n')
|
||||
|
||||
// Check initial connection
|
||||
try {
|
||||
const [result] = await sql`SELECT current_database() as db`
|
||||
console.log(`Database: ${result.db}\n`)
|
||||
} catch (e) {
|
||||
console.error("❌ Connection failed:", (e as Error).message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const input = await prompt("db> ")
|
||||
const [cmd, ...args] = input.trim().split(/\s+/)
|
||||
|
||||
switch (cmd.toLowerCase()) {
|
||||
case "tables":
|
||||
await listTables()
|
||||
break
|
||||
case "users":
|
||||
await listUsers()
|
||||
break
|
||||
case "threads":
|
||||
await listThreads()
|
||||
break
|
||||
case "sql":
|
||||
await runSQL(args.join(" "))
|
||||
break
|
||||
case "insert-user":
|
||||
if (args.length < 2) {
|
||||
console.log("Usage: insert-user <email> <name>")
|
||||
} else {
|
||||
await insertUser(args[0], args.slice(1).join(" "))
|
||||
}
|
||||
break
|
||||
case "delete-user":
|
||||
if (args.length < 1) {
|
||||
console.log("Usage: delete-user <id>")
|
||||
} else {
|
||||
await deleteUser(args[0])
|
||||
}
|
||||
break
|
||||
case "drop-all":
|
||||
await dropAll()
|
||||
break
|
||||
case "help":
|
||||
showHelp()
|
||||
break
|
||||
case "exit":
|
||||
case "quit":
|
||||
case "q":
|
||||
console.log("Bye!")
|
||||
await sql.end()
|
||||
rl.close()
|
||||
process.exit(0)
|
||||
case "":
|
||||
break
|
||||
default:
|
||||
console.log(`Unknown command: ${cmd}. Type "help" for commands.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
149
packages/web/scripts/migrate-safe.ts
Normal file
149
packages/web/scripts/migrate-safe.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Safe production migration script
|
||||
* Usage: DATABASE_URL="..." pnpm tsx scripts/migrate-safe.ts [option]
|
||||
* Options: check | auth | drizzle | both
|
||||
*/
|
||||
import postgres from "postgres"
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
if (!DATABASE_URL) {
|
||||
console.error("❌ DATABASE_URL is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sql = postgres(DATABASE_URL)
|
||||
|
||||
async function checkConnection() {
|
||||
try {
|
||||
await sql`SELECT 1`
|
||||
console.log("✓ Connected to database")
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error("✗ Connection failed:", (e as Error).message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function listTables() {
|
||||
const tables = await sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`
|
||||
if (tables.length === 0) {
|
||||
console.log(" (no tables)")
|
||||
} else {
|
||||
tables.forEach((t) => console.log(" -", t.table_name))
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuthTables() {
|
||||
const cols = await sql`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'verifications'
|
||||
ORDER BY ordinal_position
|
||||
`
|
||||
if (cols.length === 0) {
|
||||
console.log(" verifications: NOT EXISTS (will be created)")
|
||||
return false
|
||||
}
|
||||
const colNames = cols.map((c) => c.column_name)
|
||||
if (colNames.includes("expiresAt")) {
|
||||
console.log(" verifications: ✓ Correct (camelCase)")
|
||||
return true
|
||||
} else {
|
||||
console.log(" verifications: ⚠ Wrong columns:", colNames.join(", "))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function createAuthTables() {
|
||||
console.log("Dropping existing auth tables...")
|
||||
await sql`DROP TABLE IF EXISTS verifications CASCADE`
|
||||
await sql`DROP TABLE IF EXISTS accounts CASCADE`
|
||||
await sql`DROP TABLE IF EXISTS sessions CASCADE`
|
||||
await sql`DROP TABLE IF EXISTS users CASCADE`
|
||||
|
||||
console.log("Creating auth tables with camelCase columns...")
|
||||
await sql.unsafe(`
|
||||
CREATE TABLE users (
|
||||
id text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL UNIQUE,
|
||||
"emailVerified" boolean NOT NULL DEFAULT false,
|
||||
image text,
|
||||
"createdAt" timestamp NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id text PRIMARY KEY,
|
||||
"expiresAt" timestamp NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"updatedAt" timestamp NOT NULL,
|
||||
"ipAddress" text,
|
||||
"userAgent" text,
|
||||
"userId" text NOT NULL REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE accounts (
|
||||
id text PRIMARY KEY,
|
||||
"accountId" text NOT NULL,
|
||||
"providerId" text NOT NULL,
|
||||
"userId" text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
"accessToken" text,
|
||||
"refreshToken" text,
|
||||
"idToken" text,
|
||||
"accessTokenExpiresAt" timestamp,
|
||||
"refreshTokenExpiresAt" timestamp,
|
||||
scope text,
|
||||
password text,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"updatedAt" timestamp NOT NULL
|
||||
);
|
||||
CREATE TABLE verifications (
|
||||
id text PRIMARY KEY,
|
||||
identifier text NOT NULL,
|
||||
value text NOT NULL,
|
||||
"expiresAt" timestamp NOT NULL,
|
||||
"createdAt" timestamp NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
console.log("✓ Auth tables created")
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const option = process.argv[2] || "check"
|
||||
|
||||
if (!(await checkConnection())) {
|
||||
await sql.end()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (option === "check") {
|
||||
console.log("\nCurrent tables:")
|
||||
await listTables()
|
||||
console.log("\nAuth tables status:")
|
||||
await checkAuthTables()
|
||||
} else if (option === "auth") {
|
||||
await createAuthTables()
|
||||
} else if (option === "drizzle") {
|
||||
console.log("Run: DATABASE_URL=\"...\" pnpm drizzle-kit push --force")
|
||||
} else if (option === "both") {
|
||||
await createAuthTables()
|
||||
console.log("\nNow run: DATABASE_URL=\"...\" pnpm drizzle-kit push --force")
|
||||
} else {
|
||||
console.log("Unknown option:", option)
|
||||
console.log("Options: check | auth | drizzle | both")
|
||||
}
|
||||
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
sql.end()
|
||||
process.exit(1)
|
||||
})
|
||||
222
packages/web/scripts/push-schema.ts
Normal file
222
packages/web/scripts/push-schema.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Push schema directly to PlanetScale Postgres
|
||||
* Bypasses drizzle-kit permission issues
|
||||
*
|
||||
* Run: DATABASE_URL="..." pnpm tsx scripts/push-schema.ts
|
||||
*
|
||||
* NOTE: PlanetScale API tokens may not have CREATE permissions.
|
||||
* If you get "permission denied for schema public", you need to:
|
||||
* 1. Go to PlanetScale dashboard
|
||||
* 2. Create a new password with "Admin" role
|
||||
* 3. Use that connection string instead
|
||||
* OR run the SQL manually in PlanetScale's web console
|
||||
*/
|
||||
|
||||
import "dotenv/config"
|
||||
import postgres from "postgres"
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
// Allow disabling SSL for local/dev databases while keeping require for prod.
|
||||
const parsed = new URL(databaseUrl)
|
||||
const hostname = parsed.hostname
|
||||
const explicitSsl = process.env.DATABASE_SSL?.toLowerCase()
|
||||
const isLocalHost =
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname.endsWith(".local") ||
|
||||
hostname.endsWith(".localtest.me")
|
||||
|
||||
const ssl =
|
||||
explicitSsl === "disable"
|
||||
? false
|
||||
: explicitSsl === "require"
|
||||
? "require"
|
||||
: isLocalHost
|
||||
? false
|
||||
: "require"
|
||||
|
||||
const sql = postgres(databaseUrl, {
|
||||
ssl,
|
||||
max: 1,
|
||||
})
|
||||
|
||||
async function pushSchema() {
|
||||
console.log("🚀 Pushing schema to PlanetScale Postgres...")
|
||||
|
||||
// Check if we have CREATE permissions
|
||||
const [user] = await sql`SELECT current_user`
|
||||
console.log(` Connected as: ${user.current_user}`)
|
||||
|
||||
try {
|
||||
// Better-auth tables (camelCase columns)
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" text PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL UNIQUE,
|
||||
"emailVerified" boolean NOT NULL DEFAULT false,
|
||||
"image" text,
|
||||
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`
|
||||
console.log("✅ Created users table")
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
"id" text PRIMARY KEY,
|
||||
"expiresAt" timestamptz NOT NULL,
|
||||
"token" text NOT NULL UNIQUE,
|
||||
"createdAt" timestamptz NOT NULL,
|
||||
"updatedAt" timestamptz NOT NULL,
|
||||
"ipAddress" text,
|
||||
"userAgent" text,
|
||||
"userId" text NOT NULL REFERENCES "users"("id") ON DELETE cascade
|
||||
)
|
||||
`
|
||||
console.log("✅ Created sessions table")
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "accounts" (
|
||||
"id" text PRIMARY KEY,
|
||||
"accountId" text NOT NULL,
|
||||
"providerId" text NOT NULL,
|
||||
"userId" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"accessToken" text,
|
||||
"refreshToken" text,
|
||||
"idToken" text,
|
||||
"accessTokenExpiresAt" timestamptz,
|
||||
"refreshTokenExpiresAt" timestamptz,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"createdAt" timestamptz NOT NULL,
|
||||
"updatedAt" timestamptz NOT NULL
|
||||
)
|
||||
`
|
||||
console.log("✅ Created accounts table")
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "verifications" (
|
||||
"id" text PRIMARY KEY,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expiresAt" timestamptz NOT NULL,
|
||||
"createdAt" timestamptz DEFAULT now(),
|
||||
"updatedAt" timestamptz DEFAULT now()
|
||||
)
|
||||
`
|
||||
console.log("✅ Created verifications table")
|
||||
|
||||
// App tables (snake_case for Electric sync)
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "chat_threads" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"title" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`
|
||||
console.log("✅ Created chat_threads table")
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "chat_messages" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"thread_id" integer NOT NULL REFERENCES "chat_threads"("id") ON DELETE cascade,
|
||||
"role" varchar(32) NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`
|
||||
console.log("✅ Created chat_messages table")
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "canvas" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"owner_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"name" text NOT NULL DEFAULT 'Untitled Canvas',
|
||||
"width" integer NOT NULL DEFAULT 1024,
|
||||
"height" integer NOT NULL DEFAULT 1024,
|
||||
"default_model" text NOT NULL DEFAULT 'gemini-2.0-flash-exp-image-generation',
|
||||
"default_style" text NOT NULL DEFAULT 'default',
|
||||
"background_prompt" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`
|
||||
console.log("✅ Created canvas table")
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "canvas_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"canvas_id" uuid NOT NULL REFERENCES "canvas"("id") ON DELETE cascade,
|
||||
"name" text NOT NULL DEFAULT 'Untitled Image',
|
||||
"prompt" text NOT NULL DEFAULT '',
|
||||
"model_id" text NOT NULL DEFAULT 'gemini-2.0-flash-exp-image-generation',
|
||||
"model_used" text,
|
||||
"style_id" text NOT NULL DEFAULT 'default',
|
||||
"width" integer NOT NULL DEFAULT 512,
|
||||
"height" integer NOT NULL DEFAULT 512,
|
||||
"position" jsonb NOT NULL DEFAULT '{"x": 0, "y": 0}',
|
||||
"rotation" double precision NOT NULL DEFAULT 0,
|
||||
"content_base64" text,
|
||||
"image_url" text,
|
||||
"metadata" jsonb,
|
||||
"branch_parent_id" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`
|
||||
console.log("✅ Created canvas_images table")
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "context_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"type" varchar(32) NOT NULL,
|
||||
"url" text,
|
||||
"name" text NOT NULL,
|
||||
"content" text,
|
||||
"refreshing" boolean NOT NULL DEFAULT false,
|
||||
"parent_id" integer,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`
|
||||
console.log("✅ Created context_items table")
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "thread_context_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"thread_id" integer NOT NULL REFERENCES "chat_threads"("id") ON DELETE cascade,
|
||||
"context_item_id" integer NOT NULL REFERENCES "context_items"("id") ON DELETE cascade,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`
|
||||
console.log("✅ Created thread_context_items table")
|
||||
|
||||
console.log("\n🎉 All tables created successfully!")
|
||||
|
||||
// List tables
|
||||
const tables = await sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`
|
||||
console.log("\n📋 Tables in database:")
|
||||
for (const t of tables) {
|
||||
console.log(` - ${t.table_name}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Error:", err)
|
||||
} finally {
|
||||
await sql.end()
|
||||
}
|
||||
}
|
||||
|
||||
pushSchema()
|
||||
118
packages/web/scripts/schema.sql
Normal file
118
packages/web/scripts/schema.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- PlanetScale Postgres Schema
|
||||
-- Run this in PlanetScale's web console if API token doesn't have CREATE permissions
|
||||
|
||||
-- Better-auth tables (camelCase columns)
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" text PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL UNIQUE,
|
||||
"emailVerified" boolean NOT NULL DEFAULT false,
|
||||
"image" text,
|
||||
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
"id" text PRIMARY KEY,
|
||||
"expiresAt" timestamptz NOT NULL,
|
||||
"token" text NOT NULL UNIQUE,
|
||||
"createdAt" timestamptz NOT NULL,
|
||||
"updatedAt" timestamptz NOT NULL,
|
||||
"ipAddress" text,
|
||||
"userAgent" text,
|
||||
"userId" text NOT NULL REFERENCES "users"("id") ON DELETE cascade
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "accounts" (
|
||||
"id" text PRIMARY KEY,
|
||||
"accountId" text NOT NULL,
|
||||
"providerId" text NOT NULL,
|
||||
"userId" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"accessToken" text,
|
||||
"refreshToken" text,
|
||||
"idToken" text,
|
||||
"accessTokenExpiresAt" timestamptz,
|
||||
"refreshTokenExpiresAt" timestamptz,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"createdAt" timestamptz NOT NULL,
|
||||
"updatedAt" timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "verifications" (
|
||||
"id" text PRIMARY KEY,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expiresAt" timestamptz NOT NULL,
|
||||
"createdAt" timestamptz DEFAULT now(),
|
||||
"updatedAt" timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
-- App tables (snake_case for Electric sync)
|
||||
CREATE TABLE IF NOT EXISTS "chat_threads" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"title" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "chat_messages" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"thread_id" integer NOT NULL REFERENCES "chat_threads"("id") ON DELETE cascade,
|
||||
"role" varchar(32) NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "canvas" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"owner_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"name" text NOT NULL DEFAULT 'Untitled Canvas',
|
||||
"width" integer NOT NULL DEFAULT 1024,
|
||||
"height" integer NOT NULL DEFAULT 1024,
|
||||
"default_model" text NOT NULL DEFAULT 'gemini-2.0-flash-exp-image-generation',
|
||||
"default_style" text NOT NULL DEFAULT 'default',
|
||||
"background_prompt" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "canvas_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"canvas_id" uuid NOT NULL REFERENCES "canvas"("id") ON DELETE cascade,
|
||||
"name" text NOT NULL DEFAULT 'Untitled Image',
|
||||
"prompt" text NOT NULL DEFAULT '',
|
||||
"model_id" text NOT NULL DEFAULT 'gemini-2.0-flash-exp-image-generation',
|
||||
"model_used" text,
|
||||
"style_id" text NOT NULL DEFAULT 'default',
|
||||
"width" integer NOT NULL DEFAULT 512,
|
||||
"height" integer NOT NULL DEFAULT 512,
|
||||
"position" jsonb NOT NULL DEFAULT '{"x": 0, "y": 0}',
|
||||
"rotation" double precision NOT NULL DEFAULT 0,
|
||||
"content_base64" text,
|
||||
"image_url" text,
|
||||
"metadata" jsonb,
|
||||
"branch_parent_id" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "context_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"type" varchar(32) NOT NULL,
|
||||
"url" text,
|
||||
"name" text NOT NULL,
|
||||
"content" text,
|
||||
"refreshing" boolean NOT NULL DEFAULT false,
|
||||
"parent_id" integer,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "thread_context_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"thread_id" integer NOT NULL REFERENCES "chat_threads"("id") ON DELETE cascade,
|
||||
"context_item_id" integer NOT NULL REFERENCES "context_items"("id") ON DELETE cascade,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
350
packages/web/scripts/seed.ts
Normal file
350
packages/web/scripts/seed.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import "dotenv/config"
|
||||
import crypto from "node:crypto"
|
||||
import { sql, eq } from "drizzle-orm"
|
||||
import { getDb, getAuthDb } from "../src/db/connection"
|
||||
import {
|
||||
accounts,
|
||||
chat_messages,
|
||||
chat_threads,
|
||||
sessions,
|
||||
users,
|
||||
verifications,
|
||||
} from "../src/db/schema"
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is required in packages/web/.env")
|
||||
}
|
||||
|
||||
const appDb = getDb(databaseUrl)
|
||||
const authDb = getAuthDb(databaseUrl)
|
||||
|
||||
async function ensureTables() {
|
||||
await authDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" text PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL UNIQUE,
|
||||
"emailVerified" boolean NOT NULL DEFAULT false,
|
||||
"image" text,
|
||||
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
await authDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
"id" text PRIMARY KEY,
|
||||
"expiresAt" timestamptz NOT NULL,
|
||||
"token" text NOT NULL UNIQUE,
|
||||
"createdAt" timestamptz NOT NULL,
|
||||
"updatedAt" timestamptz NOT NULL,
|
||||
"ipAddress" text,
|
||||
"userAgent" text,
|
||||
"userId" text NOT NULL REFERENCES "users"("id") ON DELETE cascade
|
||||
);
|
||||
`)
|
||||
|
||||
await authDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "accounts" (
|
||||
"id" text PRIMARY KEY,
|
||||
"accountId" text NOT NULL,
|
||||
"providerId" text NOT NULL,
|
||||
"userId" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"accessToken" text,
|
||||
"refreshToken" text,
|
||||
"idToken" text,
|
||||
"accessTokenExpiresAt" timestamptz,
|
||||
"refreshTokenExpiresAt" timestamptz,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"createdAt" timestamptz NOT NULL,
|
||||
"updatedAt" timestamptz NOT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
await authDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "verifications" (
|
||||
"id" text PRIMARY KEY,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expiresAt" timestamptz NOT NULL,
|
||||
"createdAt" timestamptz DEFAULT now(),
|
||||
"updatedAt" timestamptz DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
// Backfill camelCase columns when an older snake_case seed created the tables.
|
||||
// Add missing legacy snake_case columns first so COALESCE references are safe.
|
||||
await authDb.execute(sql`
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "email_verified" boolean,
|
||||
ADD COLUMN IF NOT EXISTS "created_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "updated_at" timestamptz
|
||||
`)
|
||||
await authDb.execute(sql`
|
||||
ALTER TABLE "sessions"
|
||||
ADD COLUMN IF NOT EXISTS "expires_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "created_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "updated_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "ip_address" text,
|
||||
ADD COLUMN IF NOT EXISTS "user_agent" text,
|
||||
ADD COLUMN IF NOT EXISTS "user_id" text
|
||||
`)
|
||||
await authDb.execute(sql`
|
||||
ALTER TABLE "accounts"
|
||||
ADD COLUMN IF NOT EXISTS "account_id" text,
|
||||
ADD COLUMN IF NOT EXISTS "provider_id" text,
|
||||
ADD COLUMN IF NOT EXISTS "user_id" text,
|
||||
ADD COLUMN IF NOT EXISTS "access_token" text,
|
||||
ADD COLUMN IF NOT EXISTS "refresh_token" text,
|
||||
ADD COLUMN IF NOT EXISTS "id_token" text,
|
||||
ADD COLUMN IF NOT EXISTS "access_token_expires_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "created_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "updated_at" timestamptz
|
||||
`)
|
||||
await authDb.execute(sql`
|
||||
ALTER TABLE "verifications"
|
||||
ADD COLUMN IF NOT EXISTS "expires_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "created_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "updated_at" timestamptz
|
||||
`)
|
||||
|
||||
await authDb.execute(sql`
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "createdAt" timestamptz DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz DEFAULT now()
|
||||
`)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "users" SET "emailVerified" = COALESCE("emailVerified", "email_verified")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "users" SET "createdAt" = COALESCE("createdAt", "created_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "users" SET "updatedAt" = COALESCE("updatedAt", "updated_at")`,
|
||||
)
|
||||
|
||||
await authDb.execute(sql`
|
||||
ALTER TABLE "sessions"
|
||||
ADD COLUMN IF NOT EXISTS "expiresAt" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "token" text,
|
||||
ADD COLUMN IF NOT EXISTS "createdAt" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "ipAddress" text,
|
||||
ADD COLUMN IF NOT EXISTS "userAgent" text,
|
||||
ADD COLUMN IF NOT EXISTS "userId" text
|
||||
`)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "sessions" SET "expiresAt" = COALESCE("expiresAt", "expires_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "sessions" SET "createdAt" = COALESCE("createdAt", "created_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "sessions" SET "updatedAt" = COALESCE("updatedAt", "updated_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "sessions" SET "ipAddress" = COALESCE("ipAddress", "ip_address")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "sessions" SET "userAgent" = COALESCE("userAgent", "user_agent")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "sessions" SET "userId" = COALESCE("userId", "user_id")`,
|
||||
)
|
||||
|
||||
await authDb.execute(sql`
|
||||
ALTER TABLE "accounts"
|
||||
ADD COLUMN IF NOT EXISTS "accountId" text,
|
||||
ADD COLUMN IF NOT EXISTS "providerId" text,
|
||||
ADD COLUMN IF NOT EXISTS "userId" text,
|
||||
ADD COLUMN IF NOT EXISTS "accessToken" text,
|
||||
ADD COLUMN IF NOT EXISTS "refreshToken" text,
|
||||
ADD COLUMN IF NOT EXISTS "idToken" text,
|
||||
ADD COLUMN IF NOT EXISTS "accessTokenExpiresAt" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "refreshTokenExpiresAt" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "createdAt" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz
|
||||
`)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "accountId" = COALESCE("accountId", "account_id")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "providerId" = COALESCE("providerId", "provider_id")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "userId" = COALESCE("userId", "user_id")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "accessToken" = COALESCE("accessToken", "access_token")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "refreshToken" = COALESCE("refreshToken", "refresh_token")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "idToken" = COALESCE("idToken", "id_token")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "accessTokenExpiresAt" = COALESCE("accessTokenExpiresAt", "access_token_expires_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "refreshTokenExpiresAt" = COALESCE("refreshTokenExpiresAt", "refresh_token_expires_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "createdAt" = COALESCE("createdAt", "created_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "accounts" SET "updatedAt" = COALESCE("updatedAt", "updated_at")`,
|
||||
)
|
||||
|
||||
await authDb.execute(sql`
|
||||
ALTER TABLE "verifications"
|
||||
ADD COLUMN IF NOT EXISTS "expiresAt" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "createdAt" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz
|
||||
`)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "verifications" SET "expiresAt" = COALESCE("expiresAt", "expires_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "verifications" SET "createdAt" = COALESCE("createdAt", "created_at")`,
|
||||
)
|
||||
await authDb.execute(
|
||||
sql`UPDATE "verifications" SET "updatedAt" = COALESCE("updatedAt", "updated_at")`,
|
||||
)
|
||||
|
||||
await appDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "chat_threads" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"title" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
await appDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "chat_messages" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"thread_id" integer NOT NULL REFERENCES "chat_threads"("id") ON DELETE cascade,
|
||||
"role" varchar(32) NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
await appDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "context_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||
"type" varchar(32) NOT NULL,
|
||||
"url" text,
|
||||
"name" text NOT NULL,
|
||||
"content" text,
|
||||
"refreshing" boolean NOT NULL DEFAULT false,
|
||||
"parent_id" integer,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
|
||||
await appDb.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "thread_context_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
"thread_id" integer NOT NULL REFERENCES "chat_threads"("id") ON DELETE cascade,
|
||||
"context_item_id" integer NOT NULL REFERENCES "context_items"("id") ON DELETE cascade,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
await ensureTables()
|
||||
|
||||
const demoUserId = "demo-user"
|
||||
const demoEmail = "demo@ai.chat"
|
||||
|
||||
await authDb
|
||||
.insert(users)
|
||||
.values({
|
||||
id: demoUserId,
|
||||
name: "Demo User",
|
||||
email: demoEmail,
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing({ target: users.id })
|
||||
|
||||
// Clear any orphaned auth rows for the demo user to keep data tidy
|
||||
await authDb.delete(sessions).where(eq(sessions.userId, demoUserId))
|
||||
await authDb.delete(accounts).where(eq(accounts.userId, demoUserId))
|
||||
await authDb.delete(verifications).where(eq(verifications.identifier, demoEmail))
|
||||
|
||||
// Find or create a chat thread for the demo user
|
||||
const [existingThread] = await appDb
|
||||
.select()
|
||||
.from(chat_threads)
|
||||
.where(eq(chat_threads.user_id, demoUserId))
|
||||
.limit(1)
|
||||
|
||||
const [thread] =
|
||||
existingThread && existingThread.id
|
||||
? [existingThread]
|
||||
: await appDb
|
||||
.insert(chat_threads)
|
||||
.values({
|
||||
title: "Getting started with AI chat",
|
||||
user_id: demoUserId,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const threadId = thread.id
|
||||
|
||||
await appDb
|
||||
.delete(chat_messages)
|
||||
.where(eq(chat_messages.thread_id, threadId))
|
||||
|
||||
const starterMessages = [
|
||||
{
|
||||
role: "user",
|
||||
content: "How do I get reliable AI chat responses from this app?",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Each thread keeps your message history. You can seed demos like this one, or stream responses from your AI provider. Try adding more messages to this thread.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "Can I hook this up to my own model API?",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"Yes. Point your server-side handler at your model endpoint and persist messages into the database. Electric can sync them live to the client.",
|
||||
},
|
||||
]
|
||||
|
||||
await appDb.insert(chat_messages).values(
|
||||
starterMessages.map((msg) => ({
|
||||
thread_id: threadId,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
created_at: new Date(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
seed()
|
||||
.then(() => {
|
||||
console.log("Seed complete: demo user and chat thread ready.")
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
28
packages/web/src/components/BillingProvider.tsx
Normal file
28
packages/web/src/components/BillingProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { FlowgladProvider } from "@flowglad/react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
type BillingProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function BillingProvider({ children }: BillingProviderProps) {
|
||||
const flowgladEnabled = import.meta.env.VITE_FLOWGLAD_ENABLED === "true"
|
||||
|
||||
// Skip billing entirely when Flowglad isn't configured
|
||||
if (!flowgladEnabled) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
|
||||
// Don't load billing until we know auth state
|
||||
if (isPending) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowgladProvider loadBilling={!!session?.user} serverRoute="/api/flowglad">
|
||||
{children}
|
||||
</FlowgladProvider>
|
||||
)
|
||||
}
|
||||
721
packages/web/src/components/BlockLayout.tsx
Normal file
721
packages/web/src/components/BlockLayout.tsx
Normal file
@@ -0,0 +1,721 @@
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Globe,
|
||||
MessageCircle,
|
||||
Zap,
|
||||
Loader2,
|
||||
Link2,
|
||||
ChevronDown,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Plus,
|
||||
} from "lucide-react"
|
||||
|
||||
import ContextPanel from "./Context-panel"
|
||||
|
||||
type BlockLayoutProps = {
|
||||
activeTab: "blocks" | "marketplace"
|
||||
toolbar?: ReactNode
|
||||
subnav?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type MarketplaceCard = {
|
||||
title: string
|
||||
author: string
|
||||
price: string
|
||||
tone: string
|
||||
accent: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export default function BlockLayout({
|
||||
activeTab,
|
||||
subnav,
|
||||
children,
|
||||
}: BlockLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#05070e] text-white grid grid-cols-1 lg:grid-cols-[1fr_3fr] max-w-[1700px] mx-auto">
|
||||
<aside className="hidden lg:block h-screen overflow-y-auto">
|
||||
<ContextPanel chats={[]} />
|
||||
</aside>
|
||||
<main className="relative h-screen overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0" />
|
||||
<div className="relative h-screen overflow-y-auto">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<BlockNav activeTab={activeTab} />
|
||||
{activeTab === "blocks" ? <PublishButton /> : <MarketplaceSearch />}
|
||||
</div>
|
||||
|
||||
{subnav ? <div className="mt-4">{subnav}</div> : null}
|
||||
|
||||
<div className="mt-6 space-y-6">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockNav({ activeTab }: { activeTab: "blocks" | "marketplace" }) {
|
||||
const tabs = [
|
||||
{ id: "blocks", label: "My Blocks", to: "/blocks" },
|
||||
{ id: "marketplace", label: "Marketplace", to: "/marketplace" },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={tab.to}
|
||||
className="relative pb-0.2 text-2xl -tracking-normal"
|
||||
activeOptions={{ exact: true }}
|
||||
>
|
||||
<span
|
||||
className={`transition-colors duration-200 ${
|
||||
isActive ? "text-white" : "text-white/50 hover:text-white/70"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<span className="absolute inset-x-0 -bottom-0.5 flex h-[0.5px] items-center justify-center">
|
||||
<span className="h-[0.5px] w-full rounded-xl bg-linear-to-r from-amber-200 via-amber-100 to-amber-100/80 blur-[0.2px]" />
|
||||
<span className="absolute h-[14px] w-[120%] -z-10 bg-[radial-gradient(circle_at_center,rgba(255,179,71,0.35),transparent_65%)]" />
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PublishButton() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="relative overflow-hidden rounded-lg border border-amber-400/10 bg-linear-to-b from-[#412b26] to-[#44382a] px-4 py-1.5 text-sm text-white/70 hover:shadow-[0_2px_15px_rgba(68,56,42)] hover:text-white cursor-pointer"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarketplaceSearch() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-white/5 bg-[#0f1117]/40 px-4 py-2 shadow-inner shadow-white/1">
|
||||
<Search className="h-4 w-4 text-white/70" />
|
||||
<input
|
||||
placeholder="Search Marketplace"
|
||||
className="flex-1 bg-[#0f1117]/40 text-white text-sm placeholder:text-white/70 focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MyBlocksView() {
|
||||
const owned: any[] = useMemo(
|
||||
() => [
|
||||
{ name: "Stripe Integration", badge: "Action" },
|
||||
{ name: "Notion", badge: "Action" },
|
||||
{ name: "X API", badge: "Action" },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const custom: any[] = useMemo(
|
||||
() => [
|
||||
{ name: "Gmail", badge: "Action" },
|
||||
{ name: "Documentation Builder", badge: "Action" },
|
||||
{ name: "Electron Docs", badge: "Action" },
|
||||
{ name: "Open Image Editor Ideas", badge: "Action" },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<BlockLayout activeTab="blocks">
|
||||
<div
|
||||
// className="grid gap-6 lg:grid-cols-[1fr_2fr]"
|
||||
className="flex flex-row"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<BlockListGroup title="Owned" items={owned} />
|
||||
<BlockListGroup title="Custom" items={custom} />
|
||||
<button className="group flex gap-2 w-full items-center cursor-pointer text-sm text-white/70 transition hover:text-white">
|
||||
<Plus className="h-4 w-4" />
|
||||
New block
|
||||
</button>
|
||||
</div>
|
||||
<CreateBlockPanel />
|
||||
</div>
|
||||
</BlockLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockListGroup({ title, items }: { title: string; items: any[] }) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between text-sm font-semibold text-white">
|
||||
<span>{title}</span>
|
||||
<ChevronRight className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex items-center justify-between text-sm text-slate-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-5 w-5 items-center justify-center">↗</div>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{item.badge ? (
|
||||
<span className="rounded-lg bg-white/4 px-2 py-1 text-xs text-white/70">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateBlockPanel() {
|
||||
const [blockType, setBlockType] = useState<
|
||||
"text" | "web" | "thread" | "action"
|
||||
>("web")
|
||||
const [options, setOptions] = useState({
|
||||
update: true,
|
||||
deepScan: true,
|
||||
summarise: false,
|
||||
sections: true,
|
||||
updateInterval: "1 hour",
|
||||
deepScanLevel: "5 levels",
|
||||
})
|
||||
|
||||
const blockTypes = [
|
||||
{ id: "text", label: "Text", icon: FileText },
|
||||
{ id: "web", label: "Web", icon: Globe },
|
||||
{ id: "thread", label: "Thread", icon: MessageCircle },
|
||||
{ id: "action", label: "Action", icon: Zap },
|
||||
] as const
|
||||
|
||||
const scanning = [
|
||||
{
|
||||
name: "nikiv.dev",
|
||||
tokens: "2,284",
|
||||
children: [
|
||||
{ name: "/intro", tokens: "508" },
|
||||
{ name: "/code", tokens: "508" },
|
||||
{ name: "/focus", tokens: "508" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Open Image Editor Ideas",
|
||||
tokens: "5,582",
|
||||
children: [
|
||||
{ name: "/intro", tokens: "508" },
|
||||
{ name: "/code", tokens: "508" },
|
||||
{ name: "/focus", tokens: "508" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const initialSelection = useMemo(() => {
|
||||
const map: Record<string, boolean> = {}
|
||||
scanning.forEach((item) => {
|
||||
map[item.name] = true
|
||||
item.children?.forEach((child) => {
|
||||
map[`${item.name}/${child.name}`] = true
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [scanning])
|
||||
const [selectedPaths, setSelectedPaths] = useState<Record<string, boolean>>(
|
||||
() => initialSelection,
|
||||
)
|
||||
|
||||
const togglePath = (path: string) =>
|
||||
setSelectedPaths((prev) => ({ ...prev, [path]: !prev[path] }))
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Record<string, boolean>>(
|
||||
() => {
|
||||
const map: Record<string, boolean> = {}
|
||||
scanning.forEach((item) => {
|
||||
if (item.children?.length) map[item.name] = true
|
||||
})
|
||||
return map
|
||||
},
|
||||
)
|
||||
|
||||
const toggleExpand = (path: string) =>
|
||||
setExpandedPaths((prev) => ({ ...prev, [path]: !prev[path] }))
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-[#181921d9]/50 p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-2xl font-semibold text-white">Create block</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{blockTypes.map((type) => {
|
||||
const isActive = blockType === type.id
|
||||
const Icon = type.icon
|
||||
return (
|
||||
<button
|
||||
key={type.id}
|
||||
type="button"
|
||||
onClick={() => setBlockType(type.id)}
|
||||
className={`group relative flex h-full flex-col cursor-pointer justify-center items-center gap-4 rounded-xl border px-3 py-3 text-sm font-medium transition ${
|
||||
isActive
|
||||
? " bg-linear-to-br border-white/15 shadow-[0_1px_1px_rgba(255, 255, 255, 0.8)] from-blue-300/10 via-blue-400/15 to-purple-400/30"
|
||||
: "border-white/5 bg-white/3 hover:border-white/20 text-white/70 hover:bg-white/6"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
<span>{type.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="gap-2 flex flex-col">
|
||||
<label className="text-sm pb-2 uppercase tracking-[0.2em] text-white">
|
||||
URL
|
||||
</label>
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-black/40 px-4 py-2 shadow-inner shadow-white/5 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://apple.com"
|
||||
className="flex-1 bg-[#0f1117]/40 text-white text-sm placeholder:text-neutral-500 focus:outline-none disabled:opacity-50"
|
||||
style={{ boxShadow: "1px 0.5px 10px 0 rgba(0,0,0,0.4) inset" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 lg:grid-cols-[1.1fr_1.1fr_auto] lg:items-stretch">
|
||||
<OptionRow
|
||||
label="Update every"
|
||||
checked={options.update}
|
||||
onChange={() =>
|
||||
setOptions((prev) => ({ ...prev, update: !prev.update }))
|
||||
}
|
||||
select={{
|
||||
value: options.updateInterval,
|
||||
onChange: (value) =>
|
||||
setOptions((prev) => ({ ...prev, updateInterval: value })),
|
||||
options: ["30 min", "1 hour", "3 hours", "1 day"],
|
||||
}}
|
||||
/>
|
||||
<OptionRow
|
||||
label="Summarise pages"
|
||||
checked={options.summarise}
|
||||
onChange={() =>
|
||||
setOptions((prev) => ({ ...prev, summarise: !prev.summarise }))
|
||||
}
|
||||
/>
|
||||
<CreateCTA />
|
||||
<OptionRow
|
||||
label="Deep scan"
|
||||
checked={options.deepScan}
|
||||
onChange={() =>
|
||||
setOptions((prev) => ({ ...prev, deepScan: !prev.deepScan }))
|
||||
}
|
||||
select={{
|
||||
value: options.deepScanLevel,
|
||||
onChange: (value) =>
|
||||
setOptions((prev) => ({ ...prev, deepScanLevel: value })),
|
||||
options: ["3 levels", "5 levels", "7 levels"],
|
||||
}}
|
||||
/>
|
||||
<OptionRow
|
||||
label="Create sections"
|
||||
checked={options.sections}
|
||||
onChange={() =>
|
||||
setOptions((prev) => ({ ...prev, sections: !prev.sections }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-col text-sm gap-4 text-white/70">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Scanning...
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-white/70">
|
||||
<span className="text-white font-semibold">40</span> pages
|
||||
</p>
|
||||
<p className="text-sm text-white/70">
|
||||
<span className="text-white font-semibold">10</span> tokens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 py-3 text-sm text-slate-200">
|
||||
{scanning.map((item) => (
|
||||
<div key={item.name}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.children ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(item.name)}
|
||||
className="flex h-6 w-6 items-center cursor-pointer justify-center text-white/70"
|
||||
aria-expanded={expandedPaths[item.name]}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-5 w-5 transition ${
|
||||
expandedPaths[item.name] ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-6 w-6" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePath(item.name)}
|
||||
className="flex items-center gap-3 text-left"
|
||||
>
|
||||
<GradientCheckbox checked={selectedPaths[item.name]} />
|
||||
<span className="font-medium text-white">{item.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-white/70 p-2 rounded-md bg-white/4">
|
||||
{item.tokens}
|
||||
</span>
|
||||
</div>
|
||||
{item.children && expandedPaths[item.name] ? (
|
||||
<div className="mt-2 space-y-1 pl-10 text-slate-400">
|
||||
{item.children.map((child) => (
|
||||
<div
|
||||
key={child.name}
|
||||
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-white/2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePath(`${item.name}/${child.name}`)}
|
||||
className="flex items-center gap-3 text-left"
|
||||
>
|
||||
<GradientCheckbox
|
||||
checked={selectedPaths[`${item.name}/${child.name}`]}
|
||||
/>
|
||||
<Link2 className="h-4 w-4 text-white/70" />
|
||||
<span className="text-white">{child.name}</span>
|
||||
</button>
|
||||
<span className="text-xs text-white/70 p-2 rounded-md bg-white/4">
|
||||
{child.tokens}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
select,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
select?: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: string[]
|
||||
}
|
||||
}) {
|
||||
const muted = !checked
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className="flex items-center gap-3 text-left"
|
||||
aria-pressed={checked}
|
||||
>
|
||||
<GradientCheckbox checked={checked} />
|
||||
<span
|
||||
className={`text-lg font-semibold tracking-tight ${
|
||||
muted ? "text-slate-400" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
{select ? (
|
||||
<SoftSelect
|
||||
value={select.value}
|
||||
onChange={select.onChange}
|
||||
options={select.options}
|
||||
disabled={muted}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GradientCheckbox({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex h-5 w-5 items-center cursor-pointer justify-center rounded-md border text-white shadow-[0_10px_24px_rgba(0,0,0,0.35)] transition ${
|
||||
checked
|
||||
? "border-amber-600/20 shadow-[1px_1px_3px_rgba(255,149,87,0.2)] bg-linear-to-b from-red-500/20 via-orange-400/20 to-amber-300/20"
|
||||
: "border-white/10 bg-black/40"
|
||||
}`}
|
||||
>
|
||||
{checked ? (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="#ff9557"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path d="M5 11.5 8.5 15 15 6" />
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SoftSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: string[]
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`appearance-none rounded-lg border px-6 py-1.5 border-none shadow-white/3 shadow-[1px_1px_0.5px_rgba(0,0,0,0.1)] pr-8 text-sm font-semibold transition focus:outline-none ${
|
||||
disabled
|
||||
? "bg-transparent text-slate-500 cursor-not-allowed"
|
||||
: "bg-black/50 text-white"
|
||||
}`}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt} className="bg-[#0c0f18] text-white">
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateCTA() {
|
||||
const disabled = false
|
||||
return (
|
||||
<div className="flex items-center justify-end lg:row-span-2">
|
||||
<button
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
className={
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "flex items-center justify-center gap-2 rounded-xl bg-linear-to-b from-[#e5634f] via-[#ed7246] to-[#c25c29] px-5 py-3 text-base font-semibold text-white shadow-[0_2px_3px_rgba(255,175,71,0.45)] cursor-pointer"
|
||||
}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarketplaceView() {
|
||||
const sections: { title: string; items: MarketplaceCard[] }[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Featured",
|
||||
items: [
|
||||
{
|
||||
title: "Stripe Integration",
|
||||
author: "Stripe",
|
||||
price: "Free",
|
||||
tone: "bg-gradient-to-r from-indigo-400 via-blue-500 to-purple-500",
|
||||
accent: "border-indigo-300/40",
|
||||
},
|
||||
{
|
||||
title: "X API",
|
||||
author: "X",
|
||||
price: "$19.99",
|
||||
tone: "bg-gradient-to-r from-slate-900 via-neutral-800 to-slate-950",
|
||||
accent: "border-slate-500/40",
|
||||
},
|
||||
{
|
||||
title: "Notion",
|
||||
author: "Notion",
|
||||
price: "$11.99",
|
||||
tone: "bg-gradient-to-r from-amber-200 via-amber-100 to-white",
|
||||
accent: "border-amber-200/50",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Trending",
|
||||
items: [
|
||||
{
|
||||
title: "Dev Mode MCP",
|
||||
author: "Figma",
|
||||
price: "Free",
|
||||
tone: "bg-gradient-to-r from-green-400 via-emerald-500 to-green-600",
|
||||
accent: "border-emerald-200/50",
|
||||
},
|
||||
{
|
||||
title: "Gmail API Tools",
|
||||
author: "hunter2",
|
||||
price: "$9.99",
|
||||
tone: "bg-gradient-to-r from-red-400 via-orange-400 to-yellow-400",
|
||||
accent: "border-orange-300/60",
|
||||
},
|
||||
{
|
||||
title: "VS Code",
|
||||
author: "nikiv",
|
||||
price: "Free",
|
||||
tone: "bg-gradient-to-r from-slate-800 via-slate-700 to-slate-900",
|
||||
accent: "border-slate-500/30",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Recently published",
|
||||
items: [
|
||||
{
|
||||
title: "Spotify API",
|
||||
author: "greg3",
|
||||
price: "$6.99",
|
||||
tone: "bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600",
|
||||
accent: "border-emerald-200/50",
|
||||
},
|
||||
{
|
||||
title: "VS Code",
|
||||
author: "nikiv",
|
||||
price: "Free",
|
||||
tone: "bg-gradient-to-r from-slate-800 via-slate-700 to-slate-900",
|
||||
accent: "border-slate-500/30",
|
||||
},
|
||||
{
|
||||
title: "Dev Mode MCP",
|
||||
author: "Figma",
|
||||
price: "$4.99",
|
||||
tone: "bg-gradient-to-r from-lime-400 via-green-500 to-emerald-600",
|
||||
accent: "border-lime-200/50",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<BlockLayout activeTab="marketplace" subnav={<MarketplaceFilters />}>
|
||||
<div className="grid gap-6">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="grid gap-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="sm:text-lg md:text-2xl font-semibold">
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
<button className="text-sm text-white/90 hover:text-white cursor-pointer">
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{section.items.map((item) => (
|
||||
<MarketplaceCardView key={item.title} card={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BlockLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketplaceFilters() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterPill active={true} text="Discover" />
|
||||
<FilterPill text="Featured" />
|
||||
<FilterPill text="Trending" />
|
||||
<FilterPill text="New" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterPill text="Owned" />
|
||||
<FilterPill text="Profile" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterPill({ text, active }: { text: string; active?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-lg px-4 py-2 cursor-pointer text-sm transition ${
|
||||
active
|
||||
? "border border-white/15 inset-shadow-2xl shadow-white rounded-lg bg-transparent text-white font-semibold"
|
||||
: "bg-transparent text-white/70 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MarketplaceCardView({ card }: { card: MarketplaceCard }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-46 flex-col justify-between overflow-hidden rounded-2xl ${card.tone}`}
|
||||
>
|
||||
<div className="flex items-center h-[50%] mt-auto bg-linear-to-b from-[#252734] via-[#282a37] to-[#2c2d37] border border-t border-black/20 rounded-lg rounded-t-none p-4 justify-between">
|
||||
<div>
|
||||
<div className="text-md font-semibold drop-shadow-sm">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="text-sm text-white/80">
|
||||
by <span className="text-white font-semibold">{card.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-black/50 px-3 py-1 text-xs font-semibold text-white">
|
||||
{card.price}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.2),transparent_35%),radial-gradient(circle_at_80%_0%,rgba(255,255,255,0.15),transparent_40%)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
556
packages/web/src/components/Context-panel.tsx
Normal file
556
packages/web/src/components/Context-panel.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react"
|
||||
// import { useMutation } from "@tanstack/react-db"
|
||||
import {
|
||||
Brain,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
Globe,
|
||||
Ellipsis,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
PanelRight,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import type { ChatThread } from "@/db/schema"
|
||||
|
||||
interface UserProfile {
|
||||
name?: string | null
|
||||
email: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
interface ContextPanelProps {
|
||||
chats: ChatThread[]
|
||||
activeChatId?: string | null
|
||||
isAuthenticated?: boolean
|
||||
profile?: UserProfile | null | undefined
|
||||
}
|
||||
|
||||
interface CollapsiblePanelProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
headerActions?: ReactNode
|
||||
children: ReactNode
|
||||
height?: string
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
function CollapsiblePanel({
|
||||
title,
|
||||
icon: Icon,
|
||||
isOpen,
|
||||
onToggle,
|
||||
headerActions,
|
||||
children,
|
||||
height,
|
||||
isDragging = false,
|
||||
}: CollapsiblePanelProps) {
|
||||
const isFlexHeight = height === "flex-1"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border bg-inherit rounded-xl border-slate-500/15 flex flex-col ${
|
||||
!isDragging ? "transition-all duration-300" : ""
|
||||
} ${isFlexHeight && isOpen ? "flex-1" : ""}`}
|
||||
style={!isFlexHeight && isOpen ? { height } : undefined}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between px-2 py-2.5 bg-[#0b0d15] w-full transition-all duration-300 ${
|
||||
isOpen ? "border-b border-slate-500/15 rounded-t-xl" : "rounded-xl"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="w-5 h-5 text-teal-500 transition-transform duration-300"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span className="text-white font-medium text-[13px]">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{headerActions}
|
||||
<div className="relative w-5 h-5 flex items-center justify-center">
|
||||
<ChevronDown
|
||||
onClick={onToggle}
|
||||
className={`absolute cursor-pointer transition-all duration-200 text-neutral-400 group-hover:text-white w-3.5 h-3.5 ${
|
||||
isOpen ? "opacity-100 rotate-0" : "opacity-0 rotate-90"
|
||||
}`}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<ChevronRight
|
||||
onClick={onToggle}
|
||||
className={`absolute cursor-pointer transition-all duration-200 text-neutral-400 group-hover:text-white w-3.5 h-3.5 ${
|
||||
isOpen ? "opacity-0 -rotate-90" : "opacity-100 rotate-0"
|
||||
}`}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isOpen
|
||||
? "opacity-100 bg-[#181921d9]/50 text-neutral-500 font-semibold rounded-b-xl px-2 py-4 overflow-y-auto flex-1"
|
||||
: "opacity-0 max-h-0 py-0"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AddWebsiteModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
buttonRef: RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
function AddWebsiteModal({ isOpen, onClose, buttonRef }: AddWebsiteModalProps) {
|
||||
const [url, setUrl] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
setPosition({
|
||||
top: rect.top - 30,
|
||||
left: rect.right + 12,
|
||||
})
|
||||
}
|
||||
}, [isOpen, buttonRef])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!url.trim()) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Normalize URL - add https:// if no protocol
|
||||
let normalizedUrl = url.trim()
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
normalizedUrl = `https://${normalizedUrl}`
|
||||
}
|
||||
|
||||
const response = await fetch("/api/context-items", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "addUrl",
|
||||
url: normalizedUrl,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json()) as { error?: string }
|
||||
throw new Error(data.error || "Failed to add URL")
|
||||
}
|
||||
|
||||
setUrl("")
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add URL")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isLoading) {
|
||||
handleAdd()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" onClick={onClose}>
|
||||
<div
|
||||
className="absolute bg-[#1e202d]/60 backdrop-blur-md flex flex-col gap-3 rounded-2xl p-5 w-full max-w-[400px] shadow-xl border border-slate-200/5 box-shadow-[1px_0.5px_10px_0_rgba(0,0,0,0.4)_inset]"
|
||||
style={{ top: `${position.top}px`, left: `${position.left}px` }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-white text-sm">Add website</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="example.com"
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-[#0f1117]/40 rounded-lg px-4 py-2 text-white text-sm placeholder:text-neutral-500 focus:outline-none disabled:opacity-50"
|
||||
style={{ boxShadow: "1px 0.5px 10px 0 rgba(0,0,0,0.4) inset" }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={isLoading || !url.trim()}
|
||||
className="px-4 cursor-pointer py-1 w-fit bg-teal-600 hover:bg-teal-700 disabled:bg-teal-600/50 disabled:cursor-not-allowed text-white rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
{isLoading ? "Adding..." : "Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
|
||||
<p className="text-neutral-500 text-xs">
|
||||
URL content will be fetched and made available as context for your
|
||||
chats.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContextPanel({
|
||||
chats,
|
||||
activeChatId = null,
|
||||
isAuthenticated = false,
|
||||
profile = null,
|
||||
}: ContextPanelProps) {
|
||||
// const { remove } = useMutation()
|
||||
const [openSections, setOpenSections] = useState({
|
||||
files: false,
|
||||
web: false,
|
||||
})
|
||||
const [isContextOpen, setIsContextOpen] = useState(true)
|
||||
const [isThreadsOpen, setIsThreadsOpen] = useState(true)
|
||||
const [threadsHeight, setThreadsHeight] = useState(350)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [deletingChatId, setDeletingChatId] = useState<string | null>(null)
|
||||
const [isAddWebsiteModalOpen, setIsAddWebsiteModalOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const addLinkButtonRef = useRef<HTMLButtonElement>(null)
|
||||
// For authenticated users, show email initial or first letter of name
|
||||
// For guests, show "G"
|
||||
const profileInitial = profile?.name?.slice(0, 1) ?? profile?.email?.slice(0, 1)?.toUpperCase() ?? "G"
|
||||
const profileImage = profile?.image ?? null
|
||||
|
||||
const contextItems = [
|
||||
{
|
||||
id: "files",
|
||||
label: "Files",
|
||||
icon: File,
|
||||
count: 0,
|
||||
hasChevron: true,
|
||||
},
|
||||
{
|
||||
id: "web",
|
||||
label: "Web",
|
||||
icon: Globe,
|
||||
count: 0,
|
||||
hasChevron: true,
|
||||
},
|
||||
]
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id as keyof typeof prev],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const newHeight = e.clientY - containerRect.top - 50
|
||||
|
||||
const collapseThreshold = 80
|
||||
const minHeight = 150
|
||||
const maxHeight = containerRect.height - 250
|
||||
|
||||
if (newHeight < collapseThreshold) {
|
||||
setIsThreadsOpen(false)
|
||||
} else if (newHeight >= minHeight && newHeight <= maxHeight) {
|
||||
if (!isThreadsOpen) {
|
||||
setIsThreadsOpen(true)
|
||||
}
|
||||
setThreadsHeight(newHeight)
|
||||
} else if (newHeight >= collapseThreshold && newHeight < minHeight) {
|
||||
if (!isThreadsOpen) {
|
||||
setIsThreadsOpen(true)
|
||||
}
|
||||
setThreadsHeight(minHeight)
|
||||
}
|
||||
},
|
||||
[isThreadsOpen],
|
||||
)
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp])
|
||||
|
||||
const handleDeleteChat = async (
|
||||
event: ReactMouseEvent<HTMLButtonElement>,
|
||||
chatId: string,
|
||||
) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (deletingChatId) return
|
||||
|
||||
try {
|
||||
setDeletingChatId(chatId)
|
||||
// await remove.chat.with({ id: chatId })
|
||||
} catch (error) {
|
||||
console.error("[contextPanel] failed to delete chat", { chatId, error })
|
||||
} finally {
|
||||
setDeletingChatId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Profile display (commented out for now)
|
||||
// const profileUsername = profile?.name ?? null
|
||||
// const profileInitial = profileUsername?.[0]?.toUpperCase() ?? "?"
|
||||
|
||||
const toggleAllPanels = () => {
|
||||
const shouldOpen = !isThreadsOpen && !isContextOpen
|
||||
setIsThreadsOpen(shouldOpen)
|
||||
setIsContextOpen(shouldOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-[calc(100vh-1em)] flex flex-col gap-2 w-full max-w-[300px]"
|
||||
>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<a
|
||||
href="/settings"
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full bg-teal-600 hover:bg-teal-500 transition-colors duration-200 overflow-hidden"
|
||||
aria-label="Profile settings"
|
||||
>
|
||||
{profileImage ? (
|
||||
<img
|
||||
src={profileImage}
|
||||
alt="Profile"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white text-xs font-medium">
|
||||
{profileInitial}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="/login"
|
||||
className="flex items-center gap-2 text-neutral-300 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
<LogIn className="w-4 h-4" strokeWidth={2} />
|
||||
<span className="text-[13px]">Login</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="/settings"
|
||||
className=" text-neutral-400 hover:text-white hover:bg-white/5 transition-colors duration-200"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" strokeWidth={2} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllPanels}
|
||||
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-colors duration-200"
|
||||
aria-label="Toggle panels"
|
||||
>
|
||||
<PanelRight className="w-4 h-4 cursor-pointer" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={isDragging ? { transition: "none" } : undefined}>
|
||||
<CollapsiblePanel
|
||||
title="Threads"
|
||||
icon={MessageCircle}
|
||||
isOpen={isThreadsOpen}
|
||||
onToggle={() => setIsThreadsOpen(!isThreadsOpen)}
|
||||
height={`${threadsHeight}px`}
|
||||
headerActions={
|
||||
<a
|
||||
href="/"
|
||||
className="pr-2 text-neutral-200 hover:text-white rounded-lg text-[11px] cursor-pointer flex items-center gap-1.5 transition-colors duration-200"
|
||||
>
|
||||
<Plus className="w-4 h-4" strokeWidth={2} />
|
||||
<span>New</span>
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-neutral-500 font-semibold">RECENT</p>
|
||||
{chats.length === 0 ? (
|
||||
<p className="px-2 pt-2 text-xs text-neutral-600">
|
||||
Start a conversation to see it here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{chats.map((chat) => {
|
||||
const isActive = chat.id.toString() === activeChatId
|
||||
const displayTitle = chat.title?.trim() ?? "Untitled chat"
|
||||
const isDeleting = deletingChatId === chat.id.toString()
|
||||
|
||||
return (
|
||||
<div key={chat.id} className="group relative">
|
||||
<a
|
||||
href={`/c/${chat.id}`}
|
||||
className={`flex items-center text-[13px] gap-2 py-2 px-2 pr-8 transition-colors duration-200 rounded-lg ${
|
||||
isActive
|
||||
? "bg-white/5 text-white"
|
||||
: "text-neutral-300 hover:text-white hover:bg-white/5"
|
||||
} ${isDeleting ? "opacity-50" : ""}`}
|
||||
>
|
||||
<MessageCircle
|
||||
className={`w-3.5 h-3.5 f ${
|
||||
isActive ? "text-teal-400" : "text-teal-400/50"
|
||||
}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span className="truncate">{displayTitle}</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete chat"
|
||||
disabled={isDeleting}
|
||||
onClick={(event) =>
|
||||
handleDeleteChat(event, chat.id.toString())
|
||||
}
|
||||
className={`absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-neutral-400 transition-all duration-200 opacity-0 invisible group-hover:visible group-hover:opacity-100 focus-visible:visible focus-visible:opacity-100 bg-transparent ${
|
||||
isDeleting
|
||||
? "cursor-wait"
|
||||
: "hover:text-white focus-visible:outline-1 focus-visible:outline-white/50"
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CollapsiblePanel>
|
||||
</div>
|
||||
|
||||
{(isThreadsOpen || isContextOpen) && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="flex items-center justify-center cursor-row-resize group transition-all duration-300 -my-1.5 animate-in fade-in zoom-in-95"
|
||||
>
|
||||
<Ellipsis className="w-6 h-4 text-neutral-600 group-hover:text-neutral-400 transition-all duration-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CollapsiblePanel
|
||||
title="Context"
|
||||
icon={Brain}
|
||||
isOpen={isContextOpen}
|
||||
onToggle={() => setIsContextOpen(!isContextOpen)}
|
||||
height="flex-1"
|
||||
>
|
||||
<div className="flex justify-between text-sm mb-4 px-2">
|
||||
<span className="text-neutral-400">0 tokens</span>
|
||||
<span className="text-neutral-400">1M</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{contextItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isOpen = openSections[item.id as keyof typeof openSections]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => toggleSection(item.id)}
|
||||
className="flex items-center justify-between group py-2 px-2 cursor-pointer transition-colors duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.hasChevron &&
|
||||
(isOpen ? (
|
||||
<ChevronDown
|
||||
className="w-4 h-4 text-neutral-400 group-hover:text-white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className="w-4 h-4 text-neutral-400 group-hover:text-white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
<Icon className="w-4 h-4 text-white" strokeWidth={2} />
|
||||
<span className="text-[13px] text-neutral-300 group-hover:text-white">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500 group-hover:text-neutral-400 transition-colors duration-300">
|
||||
{item.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
ref={addLinkButtonRef}
|
||||
onClick={() => setIsAddWebsiteModalOpen(true)}
|
||||
className="flex items-center gap-2 py-2 pr-4 hover:bg-white/4 box-shadow-[1px_0.5px_10px_0_rgba(0,0,0,0.4)_inset] w-fit rounded-lg cursor-pointer transition-colors duration-200"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-neutral-400" strokeWidth={2} />
|
||||
<span className="text-[13px] text-neutral-200">Add link...</span>
|
||||
</button>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
|
||||
<AddWebsiteModal
|
||||
isOpen={isAddWebsiteModalOpen}
|
||||
onClose={() => setIsAddWebsiteModalOpen(false)}
|
||||
buttonRef={addLinkButtonRef}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
283
packages/web/src/components/Header.tsx
Normal file
283
packages/web/src/components/Header.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Home,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Menu,
|
||||
Network,
|
||||
Palette,
|
||||
SquareFunction,
|
||||
StickyNote,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [groupedExpanded, setGroupedExpanded] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const { data: session } = authClient.useSession()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut()
|
||||
window.location.href = "/"
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="p-4 flex items-center justify-between bg-gray-800 text-white shadow-lg">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<h1 className="ml-4 text-xl font-semibold">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/tanstack-word-logo-white.svg"
|
||||
alt="TanStack Logo"
|
||||
className="h-10"
|
||||
/>
|
||||
</Link>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{session?.user ? (
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-gray-700 rounded-lg transition-colors"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-cyan-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium">
|
||||
{session.user.email?.charAt(0).toUpperCase() || <User size={16} />}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/auth"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-white text-black font-medium rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
<span>Sign in</span>
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<aside
|
||||
className={`fixed top-0 left-0 h-full w-80 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${
|
||||
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-bold">Navigation</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 overflow-y-auto">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<Home size={20} />
|
||||
<span className="font-medium">Home</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/chat"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<Network size={20} />
|
||||
<span className="font-medium">Chat</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/canvas"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<Palette size={20} />
|
||||
<span className="font-medium">Canvas</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/users"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<Network size={20} />
|
||||
<span className="font-medium">Users (Electric)</span>
|
||||
</Link>
|
||||
|
||||
{session?.user ? (
|
||||
<div className="border-t border-gray-700 pt-4 mt-4 mb-4">
|
||||
<div className="flex items-center gap-3 p-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-full bg-cyan-600 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-medium">
|
||||
{session.user.email?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-medium text-sm truncate">{session.user.email}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
handleSignOut()
|
||||
}}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors w-full text-left text-red-400 hover:text-red-300"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span className="font-medium">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/auth"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-white text-black hover:bg-white/90 transition-colors mb-4"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
<span className="font-medium">Sign in</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Demo Links Start */}
|
||||
|
||||
<Link
|
||||
to="/demo/start/server-funcs"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<SquareFunction size={20} />
|
||||
<span className="font-medium">Start - Server Functions</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/demo/start/api-request"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<Network size={20} />
|
||||
<span className="font-medium">Start - API Request</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row justify-between">
|
||||
<Link
|
||||
to="/demo/start/ssr"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex-1 flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex-1 flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
<span className="font-medium">Start - SSR Demos</span>
|
||||
</Link>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
onClick={() =>
|
||||
setGroupedExpanded((prev) => ({
|
||||
...prev,
|
||||
StartSSRDemo: !prev.StartSSRDemo,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{groupedExpanded.StartSSRDemo ? (
|
||||
<ChevronDown size={20} />
|
||||
) : (
|
||||
<ChevronRight size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{groupedExpanded.StartSSRDemo && (
|
||||
<div className="flex flex-col ml-4">
|
||||
<Link
|
||||
to="/demo/start/ssr/spa-mode"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
<span className="font-medium">SPA Mode</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/demo/start/ssr/full-ssr"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
<span className="font-medium">Full SSR</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/demo/start/ssr/data-only"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
||||
activeProps={{
|
||||
className:
|
||||
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
|
||||
}}
|
||||
>
|
||||
<StickyNote size={20} />
|
||||
<span className="font-medium">Data Only</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Links End */}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
112
packages/web/src/components/Settings-panel.tsx
Normal file
112
packages/web/src/components/Settings-panel.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from "react"
|
||||
import {
|
||||
ArrowLeft,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
type LucideIcon,
|
||||
CreditCard,
|
||||
} from "lucide-react"
|
||||
|
||||
type SettingsSection = "preferences" | "profile" | "billing"
|
||||
|
||||
interface UserProfile {
|
||||
name?: string | null
|
||||
email: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
interface SettingsPanelProps {
|
||||
activeSection: SettingsSection
|
||||
onSelect: (section: SettingsSection) => void
|
||||
profile?: UserProfile | null | undefined
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
id: SettingsSection
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: "preferences", label: "Preferences", icon: SlidersHorizontal },
|
||||
{ id: "profile", label: "Profile", icon: UserRound },
|
||||
{ id: "billing", label: "Manage Billing", icon: CreditCard },
|
||||
]
|
||||
|
||||
function Avatar({ profile }: { profile?: UserProfile | null }) {
|
||||
const initial = useMemo(() => {
|
||||
if (!profile) return "G"
|
||||
return (
|
||||
profile.name?.slice(0, 1) ??
|
||||
profile.email?.slice(0, 1)?.toUpperCase() ??
|
||||
"G"
|
||||
)
|
||||
}, [profile])
|
||||
|
||||
if (profile?.image) {
|
||||
return (
|
||||
<img
|
||||
src={profile.image}
|
||||
alt={profile.name ?? profile.email}
|
||||
className="w-9 h-9 rounded-full object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-9 h-9 rounded-full bg-teal-600 text-white text-sm font-semibold grid place-items-center">
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPanel({
|
||||
activeSection,
|
||||
onSelect,
|
||||
profile,
|
||||
}: SettingsPanelProps) {
|
||||
return (
|
||||
<aside className="shrink-0 bg-transparent border border-white/5 rounded-2xl h-[calc(100vh-6em)] sticky top-6 px-2 py-4 items-start flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2 items-start w-full">
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-start gap-2 px-6 py-2.5 text-white/80 hover:text-white text-sm transition-colors w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to app</span>
|
||||
</a>
|
||||
{navItems.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = activeSection === id
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onSelect(id)}
|
||||
className={`w-full justify-start hover:cursor-pointer flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-white/4 text-white"
|
||||
: "text-white/80 hover:bg-white/2 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" strokeWidth={1.8} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!profile ? (
|
||||
<div className="mt-auto space-y-3">
|
||||
<a
|
||||
href={profile ? "/settings" : "/login"}
|
||||
className="block w-full text-center text-sm font-medium text-white bg-teal-600 hover:bg-teal-500 transition-colors rounded-lg py-2"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
493
packages/web/src/components/ShaderBackground.tsx
Normal file
493
packages/web/src/components/ShaderBackground.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
const BLIT_SHADER = `
|
||||
@group(0) @binding(0) var inputTex: texture_2d<f32>;
|
||||
@group(0) @binding(1) var inputSampler: sampler;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) position: vec4f,
|
||||
@location(0) uv: vec2f,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
|
||||
var pos = array<vec2f, 6>(
|
||||
vec2f(-1.0, -1.0),
|
||||
vec2f(1.0, -1.0),
|
||||
vec2f(-1.0, 1.0),
|
||||
vec2f(-1.0, 1.0),
|
||||
vec2f(1.0, -1.0),
|
||||
vec2f(1.0, 1.0),
|
||||
);
|
||||
var uv = array<vec2f, 6>(
|
||||
vec2f(0.0, 1.0),
|
||||
vec2f(1.0, 1.0),
|
||||
vec2f(0.0, 0.0),
|
||||
vec2f(0.0, 0.0),
|
||||
vec2f(1.0, 1.0),
|
||||
vec2f(1.0, 0.0),
|
||||
);
|
||||
var output: VertexOutput;
|
||||
output.position = vec4f(pos[vertexIndex], 0.0, 1.0);
|
||||
output.uv = uv[vertexIndex];
|
||||
return output;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(input: VertexOutput) -> @location(0) vec4f {
|
||||
return textureSample(inputTex, inputSampler, input.uv);
|
||||
}
|
||||
`
|
||||
|
||||
const SHADER_CODE = `
|
||||
struct Time {
|
||||
elapsed: f32,
|
||||
delta: f32,
|
||||
frame: u32,
|
||||
_pad: u32,
|
||||
}
|
||||
|
||||
struct Custom {
|
||||
twist: f32,
|
||||
viz: f32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> time: Time;
|
||||
@group(0) @binding(1) var<uniform> custom: Custom;
|
||||
@group(0) @binding(2) var screen: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
fn w(T: f32) -> vec3f {
|
||||
let Q = vec3f(0.5, 0.5, 0.5);
|
||||
let P = vec3f(0.5, 0.5, 0.5);
|
||||
let J = vec3f(1.0, 1.0, 1.0);
|
||||
let H = vec3f(0.263, 0.416, 0.557);
|
||||
return Q + P * cos(6.28318 * (J * T + H));
|
||||
}
|
||||
|
||||
fn v(z: vec3f) -> vec3f {
|
||||
var x = z + vec3f(12.34, 56.78, 90.12);
|
||||
var a = fract(x * vec3f(0.1031, 0.1030, 0.0973));
|
||||
a = a + dot(a, a.yzx + 19.19);
|
||||
return fract(vec3f(a.x + a.y, a.y + a.z, a.z + a.x) * a.zxy);
|
||||
}
|
||||
|
||||
fn m(s: f32) -> mat2x2<f32> {
|
||||
let n: f32 = sin(s);
|
||||
let r: f32 = cos(s);
|
||||
return mat2x2(r, -n, n, r);
|
||||
}
|
||||
|
||||
fn t(U: vec3<f32>, S: f32) -> f32 {
|
||||
return length(U) - S;
|
||||
}
|
||||
|
||||
fn u(R: vec3<f32>) -> f32 {
|
||||
var d = R;
|
||||
let G = custom.twist * 0.1;
|
||||
d = vec3f(d.xy * m(d.z * 0.05 * sin(G * 0.5)), d.z);
|
||||
let l = 8.0;
|
||||
let k = vec3<i32>(floor(d / l));
|
||||
let i = v(vec3f(f32(k.x), f32(k.y), f32(k.z)) + 1337.0);
|
||||
let K = 1.0;
|
||||
if (i.x >= K) {
|
||||
return 0.9;
|
||||
}
|
||||
var h = (d / l);
|
||||
h = fract(h) - 0.5;
|
||||
let A = (pow(sin(4.0 * time.elapsed), 4.0) + 1.0) / 2.0;
|
||||
let B = custom.viz * 0.4;
|
||||
let C = (i.yzx - vec3f(0.5)) * mix(0.1, 0.3 + B, A);
|
||||
let D = (vec3f(h) + C);
|
||||
let E = mix(0.05, 0.12, i.z) + (custom.viz * 0.15);
|
||||
let F = t(D, E);
|
||||
return F * l;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn main(@builtin(global_invocation_id) e: vec3u) {
|
||||
let c = textureDimensions(screen);
|
||||
if (e.x >= c.x || e.y >= c.y) {
|
||||
return;
|
||||
}
|
||||
let I = vec2f(f32(e.x) + .5, f32(c.y - e.y) - .5);
|
||||
var f = (I * 2.0 - vec2f(f32(c.x), f32(c.y))) / f32(c.y);
|
||||
let y = custom.twist;
|
||||
f = f * m(y * 0.1);
|
||||
let L = 8.0;
|
||||
let M = 0.6 - (custom.viz * 0.2);
|
||||
let N = vec3f(0, 0, -3 + time.elapsed * L);
|
||||
let O = normalize(vec3f(f * M, 1.0));
|
||||
var g = 0.0;
|
||||
var b = vec3<f32>(0);
|
||||
for (var q: i32 = 0; q < 80; q++) {
|
||||
var p = N + O * g;
|
||||
var j = u(p);
|
||||
let o = w(p.z * 0.04 + time.elapsed * 0.2);
|
||||
let V = 0.008 + (custom.viz * 0.01);
|
||||
let W = 8.0;
|
||||
b += o * V * exp(-j * W);
|
||||
if (j < 0.001) {
|
||||
b += o * 2.0;
|
||||
break;
|
||||
}
|
||||
g += j * 0.7 * (1.0 - custom.viz);
|
||||
if (g > 150.0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
b = b / (b + 1.0);
|
||||
b = pow(b, vec3f(1.0 / 2.2));
|
||||
let X = length(f);
|
||||
b *= 1.0 - X * 0.5;
|
||||
textureStore(screen, e.xy, vec4f(b, 1.));
|
||||
}
|
||||
`
|
||||
|
||||
type WebGPUState = {
|
||||
device: GPUDevice
|
||||
context: GPUCanvasContext
|
||||
format: GPUTextureFormat
|
||||
computePipeline: GPUComputePipeline
|
||||
computeBindGroup: GPUBindGroup
|
||||
blitPipeline: GPURenderPipeline
|
||||
blitBindGroup: GPUBindGroup
|
||||
timeBuffer: GPUBuffer
|
||||
customBuffer: GPUBuffer
|
||||
screenTexture: GPUTexture
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function ShaderBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const stateRef = useRef<WebGPUState | null>(null)
|
||||
const frameRef = useRef<number>(0)
|
||||
const startTimeRef = useRef<number>(0)
|
||||
const [supported, setSupported] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
let animationId: number
|
||||
let disposed = false
|
||||
|
||||
const init = async () => {
|
||||
// Check WebGPU support
|
||||
if (!navigator.gpu) {
|
||||
setSupported(false)
|
||||
return
|
||||
}
|
||||
|
||||
const adapter = await navigator.gpu.requestAdapter()
|
||||
if (!adapter) {
|
||||
setSupported(false)
|
||||
return
|
||||
}
|
||||
|
||||
const device = await adapter.requestDevice()
|
||||
if (disposed) return
|
||||
|
||||
const context = canvas.getContext("webgpu")
|
||||
if (!context) {
|
||||
setSupported(false)
|
||||
return
|
||||
}
|
||||
|
||||
const format = navigator.gpu.getPreferredCanvasFormat()
|
||||
const dpr = Math.min(window.devicePixelRatio, 2)
|
||||
const width = Math.floor(canvas.clientWidth * dpr)
|
||||
const height = Math.floor(canvas.clientHeight * dpr)
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
context.configure({
|
||||
device,
|
||||
format,
|
||||
alphaMode: "premultiplied",
|
||||
})
|
||||
|
||||
// Create shader modules
|
||||
const computeModule = device.createShaderModule({
|
||||
code: SHADER_CODE,
|
||||
})
|
||||
const blitModule = device.createShaderModule({
|
||||
code: BLIT_SHADER,
|
||||
})
|
||||
|
||||
// Create buffers
|
||||
const timeBuffer = device.createBuffer({
|
||||
size: 16,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
})
|
||||
|
||||
const customBuffer = device.createBuffer({
|
||||
size: 8,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
})
|
||||
|
||||
// Create screen texture (for compute output)
|
||||
const screenTexture = device.createTexture({
|
||||
size: [width, height],
|
||||
format: "rgba8unorm",
|
||||
usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
|
||||
})
|
||||
|
||||
// Create compute bind group layout and pipeline
|
||||
const computeBindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: GPUShaderStage.COMPUTE,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: GPUShaderStage.COMPUTE,
|
||||
buffer: { type: "uniform" },
|
||||
},
|
||||
{
|
||||
binding: 2,
|
||||
visibility: GPUShaderStage.COMPUTE,
|
||||
storageTexture: { access: "write-only", format: "rgba8unorm" },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const computePipeline = device.createComputePipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [computeBindGroupLayout],
|
||||
}),
|
||||
compute: {
|
||||
module: computeModule,
|
||||
entryPoint: "main",
|
||||
},
|
||||
})
|
||||
|
||||
const computeBindGroup = device.createBindGroup({
|
||||
layout: computeBindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: timeBuffer } },
|
||||
{ binding: 1, resource: { buffer: customBuffer } },
|
||||
{ binding: 2, resource: screenTexture.createView() },
|
||||
],
|
||||
})
|
||||
|
||||
// Create blit pipeline for rendering to canvas
|
||||
const sampler = device.createSampler({
|
||||
magFilter: "linear",
|
||||
minFilter: "linear",
|
||||
})
|
||||
|
||||
const blitBindGroupLayout = device.createBindGroupLayout({
|
||||
entries: [
|
||||
{
|
||||
binding: 0,
|
||||
visibility: GPUShaderStage.FRAGMENT,
|
||||
texture: { sampleType: "float" },
|
||||
},
|
||||
{
|
||||
binding: 1,
|
||||
visibility: GPUShaderStage.FRAGMENT,
|
||||
sampler: { type: "filtering" },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const blitPipeline = device.createRenderPipeline({
|
||||
layout: device.createPipelineLayout({
|
||||
bindGroupLayouts: [blitBindGroupLayout],
|
||||
}),
|
||||
vertex: {
|
||||
module: blitModule,
|
||||
entryPoint: "vs",
|
||||
},
|
||||
fragment: {
|
||||
module: blitModule,
|
||||
entryPoint: "fs",
|
||||
targets: [{ format }],
|
||||
},
|
||||
primitive: {
|
||||
topology: "triangle-list",
|
||||
},
|
||||
})
|
||||
|
||||
const blitBindGroup = device.createBindGroup({
|
||||
layout: blitBindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: screenTexture.createView() },
|
||||
{ binding: 1, resource: sampler },
|
||||
],
|
||||
})
|
||||
|
||||
stateRef.current = {
|
||||
device,
|
||||
context,
|
||||
format,
|
||||
computePipeline,
|
||||
computeBindGroup,
|
||||
blitPipeline,
|
||||
blitBindGroup,
|
||||
timeBuffer,
|
||||
customBuffer,
|
||||
screenTexture,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
|
||||
startTimeRef.current = performance.now()
|
||||
|
||||
// Start render loop
|
||||
const render = () => {
|
||||
if (disposed || !stateRef.current) return
|
||||
|
||||
const state = stateRef.current
|
||||
const elapsed = (performance.now() - startTimeRef.current) / 1000
|
||||
frameRef.current++
|
||||
|
||||
// Update time uniform
|
||||
const timeData = new ArrayBuffer(16)
|
||||
const timeView = new DataView(timeData)
|
||||
timeView.setFloat32(0, elapsed, true)
|
||||
timeView.setFloat32(4, 0.016, true)
|
||||
timeView.setUint32(8, frameRef.current, true)
|
||||
timeView.setUint32(12, 0, true)
|
||||
state.device.queue.writeBuffer(state.timeBuffer, 0, timeData)
|
||||
|
||||
// Update custom uniform (animated values)
|
||||
const twist = Math.sin(elapsed * 0.3) * 2
|
||||
const viz = 0.3 + Math.sin(elapsed * 0.5) * 0.2
|
||||
const customData = new Float32Array([twist, viz])
|
||||
state.device.queue.writeBuffer(state.customBuffer, 0, customData)
|
||||
|
||||
// Create command encoder
|
||||
const encoder = state.device.createCommandEncoder()
|
||||
|
||||
// Run compute shader
|
||||
const computePass = encoder.beginComputePass()
|
||||
computePass.setPipeline(state.computePipeline)
|
||||
computePass.setBindGroup(0, state.computeBindGroup)
|
||||
computePass.dispatchWorkgroups(
|
||||
Math.ceil(state.width / 16),
|
||||
Math.ceil(state.height / 16)
|
||||
)
|
||||
computePass.end()
|
||||
|
||||
// Blit to canvas using render pass
|
||||
const canvasTexture = state.context.getCurrentTexture()
|
||||
const renderPass = encoder.beginRenderPass({
|
||||
colorAttachments: [
|
||||
{
|
||||
view: canvasTexture.createView(),
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||
loadOp: "clear",
|
||||
storeOp: "store",
|
||||
},
|
||||
],
|
||||
})
|
||||
renderPass.setPipeline(state.blitPipeline)
|
||||
renderPass.setBindGroup(0, state.blitBindGroup)
|
||||
renderPass.draw(6)
|
||||
renderPass.end()
|
||||
|
||||
state.device.queue.submit([encoder.finish()])
|
||||
|
||||
animationId = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
render()
|
||||
}
|
||||
|
||||
init().catch((err) => {
|
||||
console.error("WebGPU init error:", err)
|
||||
setSupported(false)
|
||||
})
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
if (!stateRef.current || !canvas) return
|
||||
|
||||
const state = stateRef.current
|
||||
const dpr = Math.min(window.devicePixelRatio, 2)
|
||||
const width = Math.floor(canvas.clientWidth * dpr)
|
||||
const height = Math.floor(canvas.clientHeight * dpr)
|
||||
|
||||
if (width === state.width && height === state.height) return
|
||||
if (width === 0 || height === 0) return
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// Recreate screen texture
|
||||
state.screenTexture.destroy()
|
||||
const screenTexture = state.device.createTexture({
|
||||
size: [width, height],
|
||||
format: "rgba8unorm",
|
||||
usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
|
||||
})
|
||||
|
||||
// Recreate compute bind group
|
||||
const computeBindGroupLayout = state.computePipeline.getBindGroupLayout(0)
|
||||
const computeBindGroup = state.device.createBindGroup({
|
||||
layout: computeBindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: state.timeBuffer } },
|
||||
{ binding: 1, resource: { buffer: state.customBuffer } },
|
||||
{ binding: 2, resource: screenTexture.createView() },
|
||||
],
|
||||
})
|
||||
|
||||
// Recreate blit bind group
|
||||
const sampler = state.device.createSampler({
|
||||
magFilter: "linear",
|
||||
minFilter: "linear",
|
||||
})
|
||||
const blitBindGroupLayout = state.blitPipeline.getBindGroupLayout(0)
|
||||
const blitBindGroup = state.device.createBindGroup({
|
||||
layout: blitBindGroupLayout,
|
||||
entries: [
|
||||
{ binding: 0, resource: screenTexture.createView() },
|
||||
{ binding: 1, resource: sampler },
|
||||
],
|
||||
})
|
||||
|
||||
stateRef.current = {
|
||||
...state,
|
||||
screenTexture,
|
||||
computeBindGroup,
|
||||
blitBindGroup,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (animationId) cancelAnimationFrame(animationId)
|
||||
window.removeEventListener("resize", handleResize)
|
||||
if (stateRef.current) {
|
||||
stateRef.current.screenTexture.destroy()
|
||||
stateRef.current.timeBuffer.destroy()
|
||||
stateRef.current.customBuffer.destroy()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!supported) {
|
||||
// Fallback gradient background
|
||||
return (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950 via-black to-purple-950" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
style={{ background: "black" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
259
packages/web/src/components/VideoPlayer.tsx
Normal file
259
packages/web/src/components/VideoPlayer.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Hls from "hls.js"
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string
|
||||
autoPlay?: boolean
|
||||
muted?: boolean
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
autoPlay = true,
|
||||
muted = false,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(autoPlay)
|
||||
const [isMuted, setIsMuted] = useState(muted)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const hideControlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video || !src) return
|
||||
|
||||
// Check if native HLS is supported (Safari)
|
||||
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = src
|
||||
if (autoPlay) video.play().catch(() => setIsPlaying(false))
|
||||
return
|
||||
}
|
||||
|
||||
// Use HLS.js for other browsers
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 6,
|
||||
})
|
||||
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (autoPlay) video.play().catch(() => setIsPlaying(false))
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
setError("Network error - retrying...")
|
||||
hls.startLoad()
|
||||
break
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
setError("Media error - recovering...")
|
||||
hls.recoverMediaError()
|
||||
break
|
||||
default:
|
||||
setError("Stream error")
|
||||
hls.destroy()
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
hlsRef.current = hls
|
||||
|
||||
return () => {
|
||||
hls.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
} else {
|
||||
setError("HLS playback not supported in this browser")
|
||||
}
|
||||
}, [src, autoPlay])
|
||||
|
||||
const handlePlayPause = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
if (video.paused) {
|
||||
video.play().then(() => setIsPlaying(true))
|
||||
} else {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMute = () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
video.muted = !video.muted
|
||||
setIsMuted(video.muted)
|
||||
}
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const newVolume = parseFloat(e.target.value)
|
||||
video.volume = newVolume
|
||||
setVolume(newVolume)
|
||||
if (newVolume === 0) {
|
||||
setIsMuted(true)
|
||||
video.muted = true
|
||||
} else if (isMuted) {
|
||||
setIsMuted(false)
|
||||
video.muted = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFullscreen = async () => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen()
|
||||
setIsFullscreen(false)
|
||||
} else {
|
||||
await video.requestFullscreen()
|
||||
setIsFullscreen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = () => {
|
||||
setShowControls(true)
|
||||
if (hideControlsTimeoutRef.current) {
|
||||
clearTimeout(hideControlsTimeoutRef.current)
|
||||
}
|
||||
hideControlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying) setShowControls(false)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-900 text-neutral-400">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative h-full w-full bg-black"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="h-full w-full object-contain"
|
||||
playsInline
|
||||
muted={isMuted}
|
||||
onClick={handlePlayPause}
|
||||
/>
|
||||
|
||||
{/* Controls overlay */}
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
|
||||
showControls ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Play/Pause */}
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="text-white transition-transform hover:scale-110"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Live indicator */}
|
||||
<span className="rounded bg-red-600 px-2 py-0.5 text-xs font-bold uppercase text-white">
|
||||
Live
|
||||
</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleMute}
|
||||
className="text-white transition-transform hover:scale-110"
|
||||
>
|
||||
{isMuted || volume === 0 ? (
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-20 accent-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="text-white transition-transform hover:scale-110"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Big play button when paused */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/20 p-4 backdrop-blur-sm transition-transform hover:scale-110"
|
||||
>
|
||||
<svg className="h-16 w-16 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
packages/web/src/components/billing/BillingStatus.tsx
Normal file
47
packages/web/src/components/billing/BillingStatus.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
import { UsageDisplay } from "./UsageDisplay"
|
||||
import { UpgradeButton } from "./UpgradeButton"
|
||||
|
||||
export function BillingStatus() {
|
||||
const billing = useBilling()
|
||||
|
||||
if (!billing.loaded) {
|
||||
return (
|
||||
<div className="p-4 bg-zinc-900 rounded-lg">
|
||||
<div className="animate-pulse h-4 bg-zinc-800 rounded w-24" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasSubscription = billing.currentSubscriptions && billing.currentSubscriptions.length > 0
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-zinc-900 rounded-lg space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-white">
|
||||
{hasSubscription ? "Pro Plan" : "Free Plan"}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{hasSubscription ? "$7.99/month" : "Limited requests"}
|
||||
</p>
|
||||
</div>
|
||||
{!hasSubscription && <UpgradeButton />}
|
||||
</div>
|
||||
|
||||
{hasSubscription && (
|
||||
<>
|
||||
<UsageDisplay />
|
||||
{billing.billingPortalUrl && (
|
||||
<a
|
||||
href={billing.billingPortalUrl}
|
||||
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
Manage subscription
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
packages/web/src/components/billing/BillingStatusNew.tsx
Normal file
26
packages/web/src/components/billing/BillingStatusNew.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
import { UsageDisplayNew } from "./UsageDisplayNew"
|
||||
import { UpgradeButtonNew } from "./UpgradeButtonNew"
|
||||
import { UsageSubmissionForm } from "./UsageSubmissionForm"
|
||||
|
||||
export function BillingStatusNew() {
|
||||
console.log("BillingStatusNew")
|
||||
const billing = useBilling()
|
||||
console.log(billing)
|
||||
console.log("Has currentSubscription:", "currentSubscription" in billing)
|
||||
|
||||
if (!billing.loaded) {
|
||||
return (
|
||||
<div className="p-4 bg-zinc-900 rounded-lg">
|
||||
<div className="animate-pulse h-4 bg-zinc-800 rounded w-24" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="p-4 bg-zinc-900 rounded-lg space-y-3">
|
||||
<UpgradeButtonNew />
|
||||
<UsageSubmissionForm />
|
||||
<UsageDisplayNew />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
packages/web/src/components/billing/UpgradeButton.tsx
Normal file
51
packages/web/src/components/billing/UpgradeButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const PRO_PLAN_PRICE_SLUG = "pro_monthly"
|
||||
|
||||
type UpgradeButtonProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function UpgradeButton({ className, children }: UpgradeButtonProps) {
|
||||
const billing = useBilling()
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!billing.createCheckoutSession) {
|
||||
console.error("[billing] createCheckoutSession not available")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await billing.createCheckoutSession({
|
||||
priceSlug: PRO_PLAN_PRICE_SLUG,
|
||||
successUrl: `${window.location.origin}/settings?billing=success`,
|
||||
cancelUrl: `${window.location.origin}/settings?billing=cancelled`,
|
||||
quantity: 1,
|
||||
autoRedirect: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[billing] Checkout error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const hasSubscription =
|
||||
billing.currentSubscriptions && billing.currentSubscriptions.length > 0
|
||||
|
||||
if (hasSubscription) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={!billing.loaded}
|
||||
className={
|
||||
className ??
|
||||
"px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
{children ?? "Upgrade to Pro"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
46
packages/web/src/components/billing/UpgradeButtonNew.tsx
Normal file
46
packages/web/src/components/billing/UpgradeButtonNew.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const PRO_PLAN_PRICE_SLUG = "single_8_payment"
|
||||
|
||||
type UpgradeButtonProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function UpgradeButtonNew({ className, children }: UpgradeButtonProps) {
|
||||
const billing = useBilling()
|
||||
console.log(billing)
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!billing.createCheckoutSession) {
|
||||
console.error("[billing] createCheckoutSession not available")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await billing.createCheckoutSession({
|
||||
priceSlug: PRO_PLAN_PRICE_SLUG,
|
||||
successUrl: `${window.location.origin}/settings?billing=success`,
|
||||
cancelUrl: `${window.location.origin}/settings?billing=cancelled`,
|
||||
quantity: 1,
|
||||
autoRedirect: true,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[billing] Checkout error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
disabled={!billing.loaded}
|
||||
className={
|
||||
className ??
|
||||
"px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
{children ?? "Buy 500 requests"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
39
packages/web/src/components/billing/UsageDisplay.tsx
Normal file
39
packages/web/src/components/billing/UsageDisplay.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const AI_REQUESTS_METER = "ai_requests"
|
||||
|
||||
export function UsageDisplay() {
|
||||
const billing = useBilling()
|
||||
|
||||
if (!billing.loaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasSubscription = billing.currentSubscriptions && billing.currentSubscriptions.length > 0
|
||||
const usage = billing.checkUsageBalance?.(AI_REQUESTS_METER)
|
||||
|
||||
if (!hasSubscription) {
|
||||
return (
|
||||
<div className="text-xs text-zinc-500">
|
||||
Free tier: 20 requests/day
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const remaining = usage?.availableBalance ?? 0
|
||||
const percentage = Math.min(100, (remaining / 1000) * 100)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-zinc-400 tabular-nums">
|
||||
{remaining} left
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
packages/web/src/components/billing/UsageDisplayNew.tsx
Normal file
48
packages/web/src/components/billing/UsageDisplayNew.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const FREE_METER = "free_requests"
|
||||
const PAID_METER = "premium_requests"
|
||||
|
||||
export function UsageDisplayNew() {
|
||||
const billing = useBilling()
|
||||
console.log(billing)
|
||||
|
||||
if (!billing.loaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const freeUsage = billing.checkUsageBalance?.(FREE_METER)
|
||||
const paidUsage = billing.checkUsageBalance?.(PAID_METER)
|
||||
console.log(freeUsage)
|
||||
console.log(paidUsage)
|
||||
|
||||
const freeRemaining = freeUsage?.availableBalance ?? 0
|
||||
const freePercentage = Math.min(100, (freeRemaining / 1000) * 100)
|
||||
const remaining = paidUsage?.availableBalance ?? 0
|
||||
const percentage = Math.min(100, (remaining / 1000) * 100)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
Free requests
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${freePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-zinc-400 tabular-nums">{freeRemaining} left</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
Paid requests
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-zinc-400 tabular-nums">{remaining} left</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
175
packages/web/src/components/billing/UsageSubmissionForm.tsx
Normal file
175
packages/web/src/components/billing/UsageSubmissionForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from "react"
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
const FREE_METER = "free_requests"
|
||||
const PAID_METER = "premium_requests"
|
||||
|
||||
type MeterSlug = typeof FREE_METER | typeof PAID_METER
|
||||
|
||||
export function UsageSubmissionForm() {
|
||||
const billing = useBilling()
|
||||
const [freeAmount, setFreeAmount] = useState(1)
|
||||
const [paidAmount, setPaidAmount] = useState(1)
|
||||
const [freeError, setFreeError] = useState("")
|
||||
const [paidError, setPaidError] = useState("")
|
||||
const [freeSuccess, setFreeSuccess] = useState("")
|
||||
const [paidSuccess, setPaidSuccess] = useState("")
|
||||
const [freeSubmitting, setFreeSubmitting] = useState(false)
|
||||
const [paidSubmitting, setPaidSubmitting] = useState(false)
|
||||
|
||||
if (!billing.loaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
const freeBalance = billing.checkUsageBalance?.(FREE_METER)
|
||||
const paidBalance = billing.checkUsageBalance?.(PAID_METER)
|
||||
const freeRemaining = freeBalance?.availableBalance ?? 0
|
||||
const paidRemaining = paidBalance?.availableBalance ?? 0
|
||||
|
||||
const handleSubmit = async (meterSlug: MeterSlug, amount: number) => {
|
||||
const isFree = meterSlug === FREE_METER
|
||||
const setError = isFree ? setFreeError : setPaidError
|
||||
const setSuccess = isFree ? setFreeSuccess : setPaidSuccess
|
||||
const setSubmitting = isFree ? setFreeSubmitting : setPaidSubmitting
|
||||
const currentBalance = isFree ? freeRemaining : paidRemaining
|
||||
|
||||
// Clear previous messages
|
||||
setError("")
|
||||
setSuccess("")
|
||||
|
||||
// Client-side validation
|
||||
if (amount <= 0) {
|
||||
setError("Amount must be greater than 0")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentBalance < amount) {
|
||||
setError(`Maximum usage exceeded. Your balance is ${currentBalance}.`)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/usage-events/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ meterSlug, amount }),
|
||||
})
|
||||
|
||||
const data = (await response.json()) as {
|
||||
error?: string
|
||||
success?: boolean
|
||||
currentBalance?: number
|
||||
}
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
setError(data.error || "Failed to submit usage")
|
||||
return
|
||||
}
|
||||
|
||||
// Success!
|
||||
setSuccess(
|
||||
`Successfully recorded ${amount} ${meterSlug.replace("_", " ")}`,
|
||||
)
|
||||
|
||||
// Reset input to default
|
||||
if (isFree) {
|
||||
setFreeAmount(1)
|
||||
} else {
|
||||
setPaidAmount(1)
|
||||
}
|
||||
|
||||
// Reload billing data to update balances
|
||||
if (billing.reload) {
|
||||
await billing.reload()
|
||||
}
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
} catch (error) {
|
||||
console.error("[UsageSubmissionForm] Error:", error)
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Network error occurred",
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t border-white/10 pt-4">
|
||||
<h4 className="text-sm font-medium text-white">Submit Usage</h4>
|
||||
|
||||
{/* Free Requests Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-zinc-400">Free Requests</label>
|
||||
<span className="text-xs text-zinc-500">
|
||||
Balance: {freeRemaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={freeAmount}
|
||||
onChange={(e) => {
|
||||
setFreeAmount(parseInt(e.target.value) || 1)
|
||||
setFreeError("")
|
||||
}}
|
||||
disabled={freeSubmitting}
|
||||
className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSubmit(FREE_METER, freeAmount)}
|
||||
disabled={freeSubmitting || !billing.loaded}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{freeSubmitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
{freeError && <p className="text-xs text-red-400">{freeError}</p>}
|
||||
{freeSuccess && (
|
||||
<p className="text-xs text-emerald-400">{freeSuccess}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Premium Requests Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-zinc-400">Premium Requests</label>
|
||||
<span className="text-xs text-zinc-500">
|
||||
Balance: {paidRemaining}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={paidAmount}
|
||||
onChange={(e) => {
|
||||
setPaidAmount(parseInt(e.target.value) || 1)
|
||||
setPaidError("")
|
||||
}}
|
||||
disabled={paidSubmitting}
|
||||
className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSubmit(PAID_METER, paidAmount)}
|
||||
disabled={paidSubmitting || !billing.loaded}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{paidSubmitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</div>
|
||||
{paidError && <p className="text-xs text-red-400">{paidError}</p>}
|
||||
{paidSuccess && (
|
||||
<p className="text-xs text-emerald-400">{paidSuccess}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/components/billing/index.ts
Normal file
7
packages/web/src/components/billing/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { BillingStatus } from "./BillingStatus"
|
||||
export { BillingStatusNew } from "./BillingStatusNew"
|
||||
export { UpgradeButton } from "./UpgradeButton"
|
||||
export { UpgradeButtonNew } from "./UpgradeButtonNew"
|
||||
export { UsageDisplay } from "./UsageDisplay"
|
||||
export { UsageDisplayNew } from "./UsageDisplayNew"
|
||||
export { UsageSubmissionForm } from "./UsageSubmissionForm"
|
||||
5
packages/web/src/components/blocks/BlockPage.tsx
Normal file
5
packages/web/src/components/blocks/BlockPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MyBlocksView } from "../BlockLayout"
|
||||
|
||||
export default function BlockPage() {
|
||||
return <MyBlocksView />
|
||||
}
|
||||
5
packages/web/src/components/blocks/MarketplacePage.tsx
Normal file
5
packages/web/src/components/blocks/MarketplacePage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MarketplaceView } from "../BlockLayout"
|
||||
|
||||
export default function MarketplacePage() {
|
||||
return <MarketplaceView />
|
||||
}
|
||||
279
packages/web/src/components/canvas/CanvasBoard.tsx
Normal file
279
packages/web/src/components/canvas/CanvasBoard.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
|
||||
import type { CanvasPoint, CanvasSize } from "@/lib/canvas/types"
|
||||
import type { CanvasBox } from "./types"
|
||||
|
||||
const MIN_SIZE = 160
|
||||
const HANDLE_OFFSETS = ["top-left", "top-right", "bottom-left", "bottom-right"] as const
|
||||
|
||||
type DragMode = "move" | "resize"
|
||||
|
||||
type DragState = {
|
||||
id: string
|
||||
mode: DragMode
|
||||
handle?: (typeof HANDLE_OFFSETS)[number]
|
||||
origin: CanvasPoint
|
||||
startPosition: CanvasPoint
|
||||
startSize: CanvasSize
|
||||
latestRect?: {
|
||||
position: CanvasPoint
|
||||
size: CanvasSize
|
||||
}
|
||||
}
|
||||
|
||||
export type CanvasBoardProps = {
|
||||
boxes: CanvasBox[]
|
||||
selectedBoxId: string | null
|
||||
onSelect: (id: string | null) => void
|
||||
onRectChange: (id: string, rect: { position: CanvasPoint; size: CanvasSize }) => void
|
||||
onRectCommit: (id: string, rect: { position: CanvasPoint; size: CanvasSize }) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const CanvasBoard = ({
|
||||
boxes,
|
||||
selectedBoxId,
|
||||
onSelect,
|
||||
onRectChange,
|
||||
onRectCommit,
|
||||
className,
|
||||
}: CanvasBoardProps) => {
|
||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||
const dragStateRef = useRef<DragState | null>(null)
|
||||
|
||||
const startDrag = useCallback(
|
||||
(
|
||||
box: CanvasBox,
|
||||
mode: DragMode,
|
||||
origin: CanvasPoint,
|
||||
handle?: DragState["handle"],
|
||||
) => {
|
||||
const state: DragState = {
|
||||
id: box.id,
|
||||
mode,
|
||||
handle,
|
||||
origin,
|
||||
startPosition: box.position,
|
||||
startSize: { width: box.width, height: box.height },
|
||||
}
|
||||
|
||||
dragStateRef.current = state
|
||||
setDragState(state)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState) return
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const state = dragStateRef.current
|
||||
if (!state) return
|
||||
event.preventDefault()
|
||||
|
||||
const dx = event.clientX - state.origin.x
|
||||
const dy = event.clientY - state.origin.y
|
||||
|
||||
let nextPosition = state.startPosition
|
||||
let nextSize = state.startSize
|
||||
|
||||
if (state.mode === "move") {
|
||||
nextPosition = {
|
||||
x: Math.max(0, Math.round(state.startPosition.x + dx)),
|
||||
y: Math.max(0, Math.round(state.startPosition.y + dy)),
|
||||
}
|
||||
} else if (state.mode === "resize" && state.handle) {
|
||||
const { size, position } = calculateResize(state, dx, dy)
|
||||
nextSize = size
|
||||
nextPosition = position
|
||||
}
|
||||
|
||||
const rect = { position: nextPosition, size: nextSize }
|
||||
dragStateRef.current = { ...state, latestRect: rect }
|
||||
onRectChange(state.id, rect)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
const state = dragStateRef.current
|
||||
if (state?.latestRect) {
|
||||
onRectCommit(state.id, state.latestRect)
|
||||
}
|
||||
dragStateRef.current = null
|
||||
setDragState(null)
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove)
|
||||
window.addEventListener("pointerup", handlePointerUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove)
|
||||
window.removeEventListener("pointerup", handlePointerUp)
|
||||
}
|
||||
}, [dragState, onRectChange, onRectCommit])
|
||||
|
||||
const boardSize = useMemo(() => {
|
||||
const maxX = Math.max(...boxes.map((box) => box.position.x + box.width), 1600)
|
||||
const maxY = Math.max(...boxes.map((box) => box.position.y + box.height), 900)
|
||||
return { width: maxX + 480, height: maxY + 480 }
|
||||
}, [boxes])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex-1 overflow-auto rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_1px_1px,_rgba(255,255,255,0.05)_1px,_transparent_0)] ${className ?? ""}`}
|
||||
onClick={() => onSelect(null)}
|
||||
style={{ backgroundSize: "32px 32px" }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: Math.max(boardSize.width, 1400), height: Math.max(boardSize.height, 1000) }}
|
||||
>
|
||||
{boxes.map((box) => {
|
||||
const selected = box.id === selectedBoxId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={box.id}
|
||||
className={`absolute rounded-[32px] border border-white/10 bg-white/5 p-3 text-white shadow-[0_30px_60px_rgba(0,0,0,0.4)] transition-shadow backdrop-blur ${selected ? "ring-2 ring-white/70" : ""}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSelect(box.id)
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
startDrag(box, "move", { x: event.clientX, y: event.clientY })
|
||||
}}
|
||||
style={{
|
||||
transform: `translate(${box.position.x}px, ${box.position.y}px)`,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
userSelect: "none",
|
||||
cursor: dragState?.id === box.id ? "grabbing" : "grab",
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-white/70">
|
||||
<span className="font-medium text-white">{box.name}</span>
|
||||
<span>
|
||||
{box.width}×{box.height}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex h-[calc(100%-24px)] items-center justify-center overflow-hidden rounded-2xl bg-black/30">
|
||||
{box.imageData ? (
|
||||
<img
|
||||
alt={box.name}
|
||||
className="h-full w-full object-cover"
|
||||
src={`data:image/png;base64,${box.imageData}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 text-center text-sm text-white/60">
|
||||
{box.prompt ? box.prompt : "Add a prompt and generate to see your artwork here."}
|
||||
</div>
|
||||
)}
|
||||
{box.isGenerating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-black/70 text-sm font-semibold">
|
||||
Generating…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selected && (
|
||||
<>
|
||||
{HANDLE_OFFSETS.map((handle) => (
|
||||
<ResizeHandle
|
||||
key={handle}
|
||||
handle={handle}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
startDrag(box, "resize", { x: event.clientX, y: event.clientY }, handle)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ResizeHandleProps = {
|
||||
handle: (typeof HANDLE_OFFSETS)[number]
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
const ResizeHandle = ({ handle, onPointerDown }: ResizeHandleProps) => {
|
||||
const positions: Record<typeof handle, string> = {
|
||||
"top-left": "top-0 left-0 -translate-x-1/2 -translate-y-1/2",
|
||||
"top-right": "top-0 right-0 translate-x-1/2 -translate-y-1/2",
|
||||
"bottom-left": "bottom-0 left-0 -translate-x-1/2 translate-y-1/2",
|
||||
"bottom-right": "bottom-0 right-0 translate-x-1/2 translate-y-1/2",
|
||||
}
|
||||
|
||||
const cursors: Record<typeof handle, string> = {
|
||||
"top-left": "nwse-resize",
|
||||
"top-right": "nesw-resize",
|
||||
"bottom-left": "nesw-resize",
|
||||
"bottom-right": "nwse-resize",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute h-4 w-4 rounded-full border-2 border-black bg-white ${positions[handle]}`}
|
||||
style={{ cursor: cursors[handle] }}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function calculateResize(
|
||||
state: DragState,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { position: CanvasPoint; size: CanvasSize } {
|
||||
let { width, height } = state.startSize
|
||||
let { x, y } = state.startPosition
|
||||
|
||||
const applyWidth = (next: number, anchorRight: boolean) => {
|
||||
const clamped = Math.max(MIN_SIZE, Math.round(next))
|
||||
if (anchorRight) {
|
||||
x = state.startPosition.x + (state.startSize.width - clamped)
|
||||
}
|
||||
width = clamped
|
||||
}
|
||||
|
||||
const applyHeight = (next: number, anchorBottom: boolean) => {
|
||||
const clamped = Math.max(MIN_SIZE, Math.round(next))
|
||||
if (anchorBottom) {
|
||||
y = state.startPosition.y + (state.startSize.height - clamped)
|
||||
}
|
||||
height = clamped
|
||||
}
|
||||
|
||||
switch (state.handle) {
|
||||
case "top-left":
|
||||
applyWidth(state.startSize.width - dx, true)
|
||||
applyHeight(state.startSize.height - dy, true)
|
||||
break
|
||||
case "top-right":
|
||||
applyWidth(state.startSize.width + dx, false)
|
||||
applyHeight(state.startSize.height - dy, true)
|
||||
break
|
||||
case "bottom-left":
|
||||
applyWidth(state.startSize.width - dx, true)
|
||||
applyHeight(state.startSize.height + dy, false)
|
||||
break
|
||||
case "bottom-right":
|
||||
applyWidth(state.startSize.width + dx, false)
|
||||
applyHeight(state.startSize.height + dy, false)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
position: { x: Math.max(0, x), y: Math.max(0, y) },
|
||||
size: { width, height },
|
||||
}
|
||||
}
|
||||
286
packages/web/src/components/canvas/CanvasExperience.tsx
Normal file
286
packages/web/src/components/canvas/CanvasExperience.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
|
||||
import type { SerializedCanvasImage, SerializedCanvasRecord } from "@/lib/canvas/types"
|
||||
import {
|
||||
createCanvasBox,
|
||||
deleteCanvasBox,
|
||||
generateCanvasBoxImage,
|
||||
updateCanvasBox,
|
||||
} from "@/lib/canvas/client"
|
||||
import type { CanvasBox } from "./types"
|
||||
import { CanvasBoard } from "./CanvasBoard"
|
||||
import { CanvasToolbar } from "./CanvasToolbar"
|
||||
import { PromptPanel } from "./PromptPanel"
|
||||
|
||||
export type CanvasExperienceProps = {
|
||||
initialCanvas: SerializedCanvasRecord
|
||||
initialImages: SerializedCanvasImage[]
|
||||
}
|
||||
|
||||
const toBox = (image: SerializedCanvasImage): CanvasBox => ({
|
||||
...image,
|
||||
isGenerating: false,
|
||||
})
|
||||
|
||||
export const CanvasExperience = ({ initialCanvas, initialImages }: CanvasExperienceProps) => {
|
||||
const [canvas] = useState(initialCanvas)
|
||||
const [boxes, setBoxes] = useState<CanvasBox[]>(() => initialImages.map(toBox))
|
||||
const [selectedBoxId, setSelectedBoxId] = useState<string | null>(
|
||||
initialImages[0]?.id ?? null,
|
||||
)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const promptSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const selectedBox = useMemo(
|
||||
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
|
||||
[boxes, selectedBoxId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedBoxId && boxes[0]) {
|
||||
setSelectedBoxId(boxes[0].id)
|
||||
}
|
||||
}, [boxes, selectedBoxId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (promptSaveRef.current) {
|
||||
clearTimeout(promptSaveRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateBoxState = useCallback(
|
||||
(id: string, updater: (box: CanvasBox) => CanvasBox) => {
|
||||
setBoxes((prev) => prev.map((box) => (box.id === id ? updater(box) : box)))
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleRectChange = useCallback(
|
||||
(id: string, rect: { position: CanvasBox["position"]; size: { width: number; height: number } }) => {
|
||||
updateBoxState(id, (box) => ({
|
||||
...box,
|
||||
position: rect.position,
|
||||
width: rect.size.width,
|
||||
height: rect.size.height,
|
||||
}))
|
||||
},
|
||||
[updateBoxState],
|
||||
)
|
||||
|
||||
const handleRectCommit = useCallback(
|
||||
(id: string, rect: { position: CanvasBox["position"]; size: { width: number; height: number } }) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const image = await updateCanvasBox(id, {
|
||||
position: rect.position,
|
||||
size: rect.size,
|
||||
})
|
||||
updateBoxState(id, () => toBox(image))
|
||||
} catch (error) {
|
||||
setBanner("Failed to save position")
|
||||
}
|
||||
})
|
||||
},
|
||||
[startTransition, updateBoxState],
|
||||
)
|
||||
|
||||
const handleAddBox = useCallback(() => {
|
||||
const reference = boxes[boxes.length - 1]
|
||||
const fallbackPosition = reference
|
||||
? { x: reference.position.x + reference.width + 48, y: reference.position.y }
|
||||
: { x: 0, y: 0 }
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const image = await createCanvasBox({
|
||||
canvasId: canvas.id,
|
||||
position: fallbackPosition,
|
||||
})
|
||||
const newBox = toBox(image)
|
||||
setBoxes((prev) => [...prev, newBox])
|
||||
setSelectedBoxId(newBox.id)
|
||||
} catch (error) {
|
||||
setBanner("Failed to add box")
|
||||
}
|
||||
})
|
||||
}, [boxes, canvas.id, startTransition])
|
||||
|
||||
const handleDuplicateBox = useCallback(() => {
|
||||
if (!selectedBox) return
|
||||
const position = {
|
||||
x: selectedBox.position.x + 40,
|
||||
y: selectedBox.position.y + 40,
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const image = await createCanvasBox({
|
||||
canvasId: canvas.id,
|
||||
name: `${selectedBox.name} Copy`,
|
||||
prompt: selectedBox.prompt,
|
||||
position,
|
||||
size: { width: selectedBox.width, height: selectedBox.height },
|
||||
modelId: selectedBox.modelId,
|
||||
styleId: selectedBox.styleId,
|
||||
})
|
||||
const newBox = toBox(image)
|
||||
setBoxes((prev) => [...prev, newBox])
|
||||
setSelectedBoxId(newBox.id)
|
||||
} catch (error) {
|
||||
setBanner("Failed to duplicate box")
|
||||
}
|
||||
})
|
||||
}, [canvas.id, selectedBox, startTransition])
|
||||
|
||||
const handleDeleteBox = useCallback(() => {
|
||||
if (!selectedBoxId) return
|
||||
if (boxes.length === 1) {
|
||||
setBanner("Keep at least one box on the canvas.")
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteCanvasBox(selectedBoxId)
|
||||
setBoxes((prev) => {
|
||||
const filtered = prev.filter((box) => box.id !== selectedBoxId)
|
||||
setSelectedBoxId(filtered[0]?.id ?? null)
|
||||
return filtered
|
||||
})
|
||||
} catch (error) {
|
||||
setBanner("Failed to delete box")
|
||||
}
|
||||
})
|
||||
}, [boxes.length, selectedBoxId, startTransition])
|
||||
|
||||
const schedulePromptSave = useCallback(
|
||||
(id: string, prompt: string) => {
|
||||
if (promptSaveRef.current) {
|
||||
clearTimeout(promptSaveRef.current)
|
||||
}
|
||||
|
||||
promptSaveRef.current = setTimeout(() => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const image = await updateCanvasBox(id, { prompt })
|
||||
updateBoxState(id, () => toBox(image))
|
||||
} catch (error) {
|
||||
setBanner("Failed to save prompt")
|
||||
}
|
||||
})
|
||||
}, 600)
|
||||
},
|
||||
[startTransition, updateBoxState],
|
||||
)
|
||||
|
||||
const handlePromptChange = useCallback(
|
||||
(prompt: string) => {
|
||||
if (!selectedBoxId) return
|
||||
updateBoxState(selectedBoxId, (box) => ({ ...box, prompt }))
|
||||
schedulePromptSave(selectedBoxId, prompt)
|
||||
},
|
||||
[selectedBoxId, schedulePromptSave, updateBoxState],
|
||||
)
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(modelId: string) => {
|
||||
if (!selectedBoxId) return
|
||||
updateBoxState(selectedBoxId, (box) => ({ ...box, modelId }))
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateCanvasBox(selectedBoxId, { modelId })
|
||||
} catch (error) {
|
||||
setBanner("Failed to update model")
|
||||
}
|
||||
})
|
||||
},
|
||||
[selectedBoxId, startTransition, updateBoxState],
|
||||
)
|
||||
|
||||
const handleStyleChange = useCallback(
|
||||
(styleId: string) => {
|
||||
if (!selectedBoxId) return
|
||||
updateBoxState(selectedBoxId, (box) => ({ ...box, styleId }))
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateCanvasBox(selectedBoxId, { styleId })
|
||||
} catch (error) {
|
||||
setBanner("Failed to update style")
|
||||
}
|
||||
})
|
||||
},
|
||||
[selectedBoxId, startTransition, updateBoxState],
|
||||
)
|
||||
|
||||
const handleGenerate = useCallback(() => {
|
||||
if (!selectedBoxId) {
|
||||
setBanner("Select a box before generating.")
|
||||
return
|
||||
}
|
||||
|
||||
const target = boxes.find((box) => box.id === selectedBoxId)
|
||||
if (!target) return
|
||||
|
||||
if (!target.prompt.trim()) {
|
||||
setBanner("Add a prompt first.")
|
||||
return
|
||||
}
|
||||
|
||||
updateBoxState(selectedBoxId, (box) => ({ ...box, isGenerating: true }))
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const image = await generateCanvasBoxImage({
|
||||
imageId: selectedBoxId,
|
||||
prompt: target.prompt,
|
||||
modelId: target.modelId,
|
||||
})
|
||||
setBoxes((prev) =>
|
||||
prev.map((box) =>
|
||||
box.id === selectedBoxId ? { ...toBox(image), isGenerating: false } : box,
|
||||
),
|
||||
)
|
||||
} catch (error) {
|
||||
updateBoxState(selectedBoxId, (box) => ({ ...box, isGenerating: false }))
|
||||
setBanner("Image generation failed")
|
||||
}
|
||||
})
|
||||
}, [boxes, selectedBoxId, startTransition, updateBoxState])
|
||||
|
||||
const toolbarDisabled = isPending
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="relative flex-1">
|
||||
<CanvasBoard
|
||||
boxes={boxes}
|
||||
className="h-full"
|
||||
onRectChange={handleRectChange}
|
||||
onRectCommit={handleRectCommit}
|
||||
onSelect={setSelectedBoxId}
|
||||
selectedBoxId={selectedBoxId}
|
||||
/>
|
||||
<div className="pointer-events-none absolute left-6 top-6">
|
||||
<CanvasToolbar
|
||||
canDelete={boxes.length > 1 && Boolean(selectedBoxId)}
|
||||
canDuplicate={Boolean(selectedBoxId)}
|
||||
disabled={toolbarDisabled}
|
||||
onAdd={handleAddBox}
|
||||
onDelete={handleDeleteBox}
|
||||
onDuplicate={handleDuplicateBox}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PromptPanel
|
||||
box={selectedBox}
|
||||
defaultModel={canvas.defaultModel}
|
||||
isGenerating={selectedBox?.isGenerating}
|
||||
onGenerate={handleGenerate}
|
||||
onModelChange={handleModelChange}
|
||||
onPromptChange={handlePromptChange}
|
||||
onStyleChange={handleStyleChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
packages/web/src/components/canvas/CanvasToolbar.tsx
Normal file
54
packages/web/src/components/canvas/CanvasToolbar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Copy, Plus, Trash } from "lucide-react"
|
||||
|
||||
type CanvasToolbarProps = {
|
||||
disabled?: boolean
|
||||
canDuplicate?: boolean
|
||||
canDelete?: boolean
|
||||
onAdd: () => void
|
||||
onDuplicate: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export const CanvasToolbar = ({
|
||||
disabled,
|
||||
canDuplicate = true,
|
||||
canDelete = true,
|
||||
onAdd,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: CanvasToolbarProps) => {
|
||||
const buttonClass =
|
||||
"h-10 w-10 rounded-2xl border border-white/10 bg-white/10 text-white flex items-center justify-center transition hover:border-white hover:text-white"
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex flex-col gap-3 rounded-[28px] border border-white/10 bg-black/30 p-3 shadow-[0_20px_50px_rgba(0,0,0,0.6)] backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add artboard"
|
||||
className={buttonClass}
|
||||
disabled={disabled}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Duplicate artboard"
|
||||
className={buttonClass}
|
||||
disabled={disabled || !canDuplicate}
|
||||
onClick={onDuplicate}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete artboard"
|
||||
className={buttonClass}
|
||||
disabled={disabled || !canDelete}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
packages/web/src/components/canvas/PromptPanel.tsx
Normal file
108
packages/web/src/components/canvas/PromptPanel.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import type { CanvasBox } from "./types"
|
||||
|
||||
const STYLE_OPTIONS = [
|
||||
{ id: "default", label: "Default" },
|
||||
{ id: "cinematic", label: "Cinematic" },
|
||||
{ id: "illustration", label: "Illustration" },
|
||||
]
|
||||
|
||||
type PromptPanelProps = {
|
||||
box: CanvasBox | null
|
||||
defaultModel: string
|
||||
isGenerating?: boolean
|
||||
onPromptChange: (prompt: string) => void
|
||||
onModelChange: (modelId: string) => void
|
||||
onStyleChange: (styleId: string) => void
|
||||
onGenerate: () => void
|
||||
}
|
||||
|
||||
export const PromptPanel = ({
|
||||
box,
|
||||
defaultModel,
|
||||
isGenerating,
|
||||
onPromptChange,
|
||||
onModelChange,
|
||||
onStyleChange,
|
||||
onGenerate,
|
||||
}: PromptPanelProps) => {
|
||||
const [localPrompt, setLocalPrompt] = useState(box?.prompt ?? "")
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPrompt(box?.prompt ?? "")
|
||||
}, [box?.id, box?.prompt])
|
||||
|
||||
if (!box) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-white/20 bg-black/40 p-6 text-center text-sm text-white/60 backdrop-blur">
|
||||
Select a canvas box to edit its prompt and generate an image.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handlePromptChange = (value: string) => {
|
||||
setLocalPrompt(value)
|
||||
onPromptChange(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 rounded-3xl border border-white/10 bg-black/40 p-4 text-white shadow-[0_20px_60px_rgba(0,0,0,0.55)] backdrop-blur">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-[0.3em] text-white/50" htmlFor="prompt">
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="prompt"
|
||||
className="min-h-[120px] w-full rounded-2xl border border-white/10 bg-black/60 p-3 text-sm text-white focus:border-white/60 focus:outline-none"
|
||||
placeholder="Describe what you want Gemini to draw..."
|
||||
value={localPrompt}
|
||||
onChange={(event) => handlePromptChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-[0.3em] text-white/50" htmlFor="model">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
id="model"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/60 p-3 text-sm text-white focus:border-white/60 focus:outline-none"
|
||||
value={box.modelId || defaultModel}
|
||||
onChange={(event) => onModelChange(event.target.value)}
|
||||
>
|
||||
<option value="gemini-2.0-flash-exp-image-generation">
|
||||
Gemini 2.0 Flash (Image)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-[0.3em] text-white/50" htmlFor="style">
|
||||
Style
|
||||
</label>
|
||||
<select
|
||||
id="style"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/60 p-3 text-sm text-white focus:border-white/60 focus:outline-none"
|
||||
value={box.styleId ?? "default"}
|
||||
onChange={(event) => onStyleChange(event.target.value)}
|
||||
>
|
||||
{STYLE_OPTIONS.map((style) => (
|
||||
<option key={style.id} value={style.id}>
|
||||
{style.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="h-12 rounded-full bg-white text-xs font-semibold uppercase tracking-[0.3em] text-black shadow-[0_15px_40px_rgba(255,255,255,0.35)] transition hover:bg-white/90 disabled:cursor-not-allowed disabled:bg-white/30"
|
||||
disabled={isGenerating || !localPrompt.trim()}
|
||||
onClick={onGenerate}
|
||||
>
|
||||
{isGenerating ? "Generating…" : "Generate"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
packages/web/src/components/canvas/types.ts
Normal file
10
packages/web/src/components/canvas/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {
|
||||
SerializedCanvasImage,
|
||||
SerializedCanvasRecord,
|
||||
} from "@/lib/canvas/types"
|
||||
|
||||
export type CanvasBox = SerializedCanvasImage & {
|
||||
isGenerating?: boolean
|
||||
}
|
||||
|
||||
export type CanvasSnapshot = SerializedCanvasRecord
|
||||
282
packages/web/src/components/chat/ChatInput.tsx
Normal file
282
packages/web/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState, useRef, useEffect, useMemo } from "react"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
const AVAILABLE_MODELS = [
|
||||
{
|
||||
id: "deepseek/deepseek-chat-v3-0324",
|
||||
name: "DeepSeek V3",
|
||||
provider: "DeepSeek",
|
||||
},
|
||||
{
|
||||
id: "google/gemini-2.0-flash-001",
|
||||
name: "Gemini 2.0 Flash",
|
||||
provider: "Google",
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
name: "Claude Sonnet 4",
|
||||
provider: "Anthropic",
|
||||
},
|
||||
{ id: "openai/gpt-4o", name: "GPT-4o", provider: "OpenAI" },
|
||||
] as const
|
||||
|
||||
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"]
|
||||
|
||||
function ModelSparkle() {
|
||||
return (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_1212_3035)">
|
||||
<path
|
||||
d="M16 8.016C13.9242 8.14339 11.9666 9.02545 10.496 10.496C9.02545 11.9666 8.14339 13.9242 8.016 16H7.984C7.85682 13.9241 6.97483 11.9664 5.5042 10.4958C4.03358 9.02518 2.07588 8.14318 0 8.016L0 7.984C2.07588 7.85682 4.03358 6.97483 5.5042 5.5042C6.97483 4.03358 7.85682 2.07588 7.984 0L8.016 0C8.14339 2.07581 9.02545 4.03339 10.496 5.50397C11.9666 6.97455 13.9242 7.85661 16 7.984V8.016Z"
|
||||
fill="url(#paint0_radial_1212_3035)"
|
||||
style={{ stopColor: "#9168C0", stopOpacity: 1 }}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_1212_3035"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(1.588 6.503) rotate(18.6832) scale(17.03 136.421)"
|
||||
>
|
||||
<stop
|
||||
offset="0.067"
|
||||
stopColor="#9168C0"
|
||||
style={{ stopColor: "#9168C0", stopOpacity: 1 }}
|
||||
/>
|
||||
<stop
|
||||
offset="0.343"
|
||||
stopColor="#5684D1"
|
||||
style={{ stopColor: "#5684D1", stopOpacity: 1 }}
|
||||
/>
|
||||
<stop
|
||||
offset="0.672"
|
||||
stopColor="#1BA1E3"
|
||||
style={{ stopColor: "#1BA1E3", stopOpacity: 1 }}
|
||||
/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_1212_3035">
|
||||
<rect
|
||||
width="16"
|
||||
height="16"
|
||||
fill="white"
|
||||
style={{ fill: "white", fillOpacity: 1 }}
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelSelectProps {
|
||||
selectedModel: ModelId
|
||||
onChange: (model: ModelId) => void
|
||||
}
|
||||
|
||||
function ModelSelect({ selectedModel, onChange }: ModelSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const selected = useMemo(
|
||||
() => AVAILABLE_MODELS.find((m) => m.id === selectedModel),
|
||||
[selectedModel],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false)
|
||||
}
|
||||
window.addEventListener("mousedown", handleClickOutside)
|
||||
window.addEventListener("keydown", handleEscape)
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", handleClickOutside)
|
||||
window.removeEventListener("keydown", handleEscape)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative select-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/8 bg-linear-to-b from-[#2d2e39] via-[#1e1f28] to-[#1a1b24] px-3 py-1.5 text-left shadow-inner shadow-black/40 hover:border-white/14 transition-colors min-w-[170px]"
|
||||
>
|
||||
<ModelSparkle />
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{selected?.name ?? "Choose model"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`ml-auto h-4 w-4 text-neutral-500 transition-transform ${open ? "rotate-180 text-neutral-300" : ""}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 bottom-full z-20 my-1 max-w-52 overflow-hidden rounded-2xl border border-white/5 bg-[#0b0c11]/95 backdrop-blur-lg box-shadow-xl">
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-500">
|
||||
Models
|
||||
</div>
|
||||
<div className="flex flex-col py-1">
|
||||
{AVAILABLE_MODELS.map((model) => {
|
||||
const isActive = model.id === selectedModel
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(model.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`flex items-center hover:bg-white/2 rounded-lg gap-3 px-3 py-1 text-sm transition-colors ${isActive ? "text-white" : "text-white/65 hover:text-white"}`}
|
||||
>
|
||||
<ModelSparkle />
|
||||
<div className="flex flex-col cursor-pointer items-start">
|
||||
<span className="text-[13px] font-semibold leading-tight">
|
||||
{model.name}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSubmit: (message: string) => void
|
||||
isLoading: boolean
|
||||
selectedModel: ModelId
|
||||
onModelChange: (model: ModelId) => void
|
||||
limitReached?: boolean
|
||||
remainingRequests?: number
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSubmit,
|
||||
isLoading,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
limitReached = false,
|
||||
remainingRequests,
|
||||
}: ChatInputProps) {
|
||||
const [message, setMessage] = useState("")
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (message.trim() && !isLoading && !limitReached) {
|
||||
onSubmit(message)
|
||||
setMessage("")
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = isLoading || limitReached
|
||||
const sendDisabled = !message.trim() || isDisabled
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
{limitReached && (
|
||||
<div className="max-w-4xl mx-auto mb-3 overflow-hidden rounded-[26px] border border-white/8 bg-linear-to-b from-[#0c1c27] via-[#0a1923] to-[#08141d] shadow-[0_18px_60px_rgba(0,0,0,0.4)]">
|
||||
<div className="flex items-center gap-4 px-6 py-4">
|
||||
<div className="flex-1">
|
||||
<div className="text-lg font-semibold text-white">
|
||||
Sign in to continue chatting
|
||||
</div>
|
||||
<div className="text-sm text-neutral-300">
|
||||
Get more requests with a free account
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/login"
|
||||
className="shrink-0 rounded-2xl px-4 py-2.5 text-sm font-semibold text-white bg-linear-to-b from-[#1ab8b0] via-[#0a8f8b] to-[#0ba58a] shadow-[0_16px_48px_rgba(0,0,0,0.45)] transition hover:brightness-110"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className={`relative min-h-[6.5em] max-w-4xl mx-auto rounded-2xl border border-neutral-700/30 bg-[#181921d9]/90 px-3 p-4 backdrop-blur-lg transition-all hover:border-neutral-600/40 ${limitReached ? "opacity-80" : ""}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
autoFocus
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything..."
|
||||
className="w-full max-h-32 min-h-[24px] resize-none overflow-y-auto bg-transparent text-[15px] text-neutral-100 placeholder-neutral-500 focus:outline-none disabled:opacity-60 scrollbar-hide [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
rows={3}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
||||
{limitReached && (
|
||||
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-linear-to-b from-black/25 via-transparent to-black/30" />
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-0 left-0 p-2 w-full flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelect
|
||||
selectedModel={selectedModel}
|
||||
onChange={onModelChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-neutral-400">
|
||||
{typeof remainingRequests === "number" && (
|
||||
<span className="text-white/70">
|
||||
{remainingRequests} requests remaining
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sendDisabled}
|
||||
className="flex py-2 px-3 cursor-pointer items-center justify-center text-white rounded-[10px] bg-linear-to-b from-[#5b9fbf] via-[#0d817f] to-[#069d7f] transition-colors duration-300 hover:bg-cyan-700 hover:text-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg box-shadow-xl"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0.184054 0.112806C0.258701 0.0518157 0.349387 0.0137035 0.445193 0.00305845C0.540999 -0.00758662 0.637839 0.00968929 0.724054 0.0528059L15.7241 7.55281C15.8073 7.59427 15.8773 7.65812 15.9262 7.73717C15.9751 7.81622 16.001 7.90734 16.001 8.00031C16.001 8.09327 15.9751 8.18439 15.9262 8.26344C15.8773 8.34249 15.8073 8.40634 15.7241 8.44781L0.724054 15.9478C0.637926 15.9909 0.541171 16.0083 0.445423 15.9977C0.349675 15.9872 0.25901 15.9492 0.184331 15.8884C0.109651 15.8275 0.0541361 15.7464 0.0244608 15.6548C-0.00521444 15.5631 -0.0077866 15.4649 0.0170539 15.3718L1.98305 8.00081L0.0170539 0.629806C-0.00790602 0.536702 -0.0054222 0.438369 0.0242064 0.346644C0.053835 0.25492 0.109345 0.173715 0.184054 0.112806ZM2.88405 8.50081L1.27005 14.5568L14.3821 8.00081L1.26905 1.44481L2.88405 7.50081H9.50005C9.63266 7.50081 9.75984 7.55348 9.85361 7.64725C9.94738 7.74102 10.0001 7.8682 10.0001 8.00081C10.0001 8.13341 9.94738 8.26059 9.85361 8.35436C9.75984 8.44813 9.63266 8.50081 9.50005 8.50081H2.88405Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { AVAILABLE_MODELS }
|
||||
45
packages/web/src/components/chat/ChatMessages.tsx
Normal file
45
packages/web/src/components/chat/ChatMessages.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Sparkles } from "lucide-react"
|
||||
import {
|
||||
MessageBubble,
|
||||
TypingIndicator,
|
||||
StreamingMessage,
|
||||
type Message,
|
||||
} from "./MessageBubble"
|
||||
|
||||
export function EmptyChatState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<div className="w-16 h-16 rounded-3xl bg-gradient-to-br from-teal-500/20 to-teal-500/5 flex items-center justify-center mb-6 shadow-lg">
|
||||
<Sparkles className="w-8 h-8 text-teal-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mb-3 text-white">How can I help?</h2>
|
||||
<p className="text-neutral-400 text-sm max-w-sm">
|
||||
Start a conversation below.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[]
|
||||
streamingContent: string
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
streamingContent,
|
||||
isStreaming,
|
||||
}: MessageListProps) {
|
||||
return (
|
||||
<div className="space-y-6 pb-4">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<StreamingMessage content={streamingContent} />
|
||||
)}
|
||||
{isStreaming && !streamingContent && <TypingIndicator />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
618
packages/web/src/components/chat/ChatPage.tsx
Normal file
618
packages/web/src/components/chat/ChatPage.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { useLiveQuery, eq } from "@tanstack/react-db"
|
||||
import { LogIn, Menu, X, LogOut } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
getChatThreadsCollection,
|
||||
getChatMessagesCollection,
|
||||
} from "@/lib/collections"
|
||||
import ContextPanel from "@/components/Context-panel"
|
||||
import { ChatInput, AVAILABLE_MODELS, type ModelId } from "./ChatInput"
|
||||
import { EmptyChatState, MessageList } from "./ChatMessages"
|
||||
import type { Message } from "./MessageBubble"
|
||||
|
||||
const MODEL_STORAGE_KEY = "gen_chat_model"
|
||||
const FREE_REQUEST_LIMIT = 2
|
||||
|
||||
function getStoredModel(): ModelId {
|
||||
if (typeof window === "undefined") return AVAILABLE_MODELS[0].id
|
||||
const stored = localStorage.getItem(MODEL_STORAGE_KEY)
|
||||
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
|
||||
return stored as ModelId
|
||||
}
|
||||
return AVAILABLE_MODELS[0].id
|
||||
}
|
||||
|
||||
function setStoredModel(model: ModelId) {
|
||||
localStorage.setItem(MODEL_STORAGE_KEY, model)
|
||||
}
|
||||
|
||||
async function createThread(title = "New chat") {
|
||||
const res = await fetch("/api/chat/mutations", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ action: "createThread", title }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to create chat")
|
||||
const json = (await res.json()) as {
|
||||
thread: { id: number; title: string; created_at?: string }
|
||||
}
|
||||
return {
|
||||
...json.thread,
|
||||
created_at: json.thread.created_at
|
||||
? new Date(json.thread.created_at)
|
||||
: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
async function addMessage({
|
||||
threadId,
|
||||
role,
|
||||
content,
|
||||
}: {
|
||||
threadId: number
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}) {
|
||||
const res = await fetch("/api/chat/mutations", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
action: "addMessage",
|
||||
threadId,
|
||||
role,
|
||||
content,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to add message")
|
||||
const json = (await res.json()) as {
|
||||
message: { id: number; thread_id: number; role: string; content: string; created_at?: string }
|
||||
}
|
||||
return {
|
||||
...json.message,
|
||||
created_at: json.message.created_at
|
||||
? new Date(json.message.created_at)
|
||||
: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
type DBMessage = {
|
||||
id: number
|
||||
thread_id: number
|
||||
role: string
|
||||
content: string
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
type GuestMessage = {
|
||||
id: number
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
// Guest chat component - saves to database with null user_id
|
||||
function GuestChat() {
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState("")
|
||||
const [guestMessages, setGuestMessages] = useState<GuestMessage[]>([])
|
||||
const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(null)
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(AVAILABLE_MODELS[0].id)
|
||||
const [threadId, setThreadId] = useState<number | null>(null)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel(getStoredModel())
|
||||
}, [])
|
||||
|
||||
const messages: Message[] = useMemo(() => {
|
||||
const msgs = guestMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
// Add pending user message if streaming and not yet in guestMessages
|
||||
if (pendingUserMessage && !msgs.some((m) => m.role === "user" && m.content === pendingUserMessage)) {
|
||||
msgs.push({
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: pendingUserMessage,
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}, [guestMessages, pendingUserMessage])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages, streamingContent])
|
||||
|
||||
const handleModelChange = (model: ModelId) => {
|
||||
setSelectedModel(model)
|
||||
setStoredModel(model)
|
||||
}
|
||||
|
||||
const userMessagesSent = guestMessages.filter((m) => m.role === "user").length
|
||||
const limitReached = userMessagesSent >= FREE_REQUEST_LIMIT
|
||||
|
||||
const handleSubmit = async (userContent: string) => {
|
||||
if (!userContent.trim() || isStreaming || limitReached) return
|
||||
|
||||
// Set pending message immediately so it shows while streaming
|
||||
setPendingUserMessage(userContent)
|
||||
setIsStreaming(true)
|
||||
setStreamingContent("")
|
||||
|
||||
try {
|
||||
const newUserMsg: GuestMessage = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: userContent,
|
||||
}
|
||||
setGuestMessages((prev) => [...prev, newUserMsg])
|
||||
setPendingUserMessage(null) // Clear pending once added to guestMessages
|
||||
|
||||
const apiMessages = [
|
||||
...guestMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: "user" as const, content: userContent },
|
||||
]
|
||||
|
||||
const res = await fetch("/api/chat/guest", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ messages: apiMessages, model: selectedModel, threadId }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`AI request failed: ${res.status}`)
|
||||
}
|
||||
|
||||
// Get thread ID from response header
|
||||
const responseThreadId = res.headers.get("X-Thread-Id")
|
||||
if (responseThreadId && !threadId) {
|
||||
setThreadId(Number(responseThreadId))
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
}
|
||||
|
||||
const newAssistantMsg: GuestMessage = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: accumulated,
|
||||
}
|
||||
setGuestMessages((prev) => [...prev, newAssistantMsg])
|
||||
setStreamingContent("")
|
||||
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error)
|
||||
setStreamingContent("")
|
||||
setPendingUserMessage(null)
|
||||
} finally {
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remainingRequests = Math.max(0, FREE_REQUEST_LIMIT - userMessagesSent)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header - only visible on small screens */}
|
||||
<header className="md:hidden fixed top-0 left-0 right-0 z-40 flex items-center justify-between px-4 py-3 bg-[#07080f]/95 backdrop-blur-sm border-b border-white/5">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="p-2 -ml-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<Link
|
||||
to="/auth"
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium bg-white text-black rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<LogIn size={16} />
|
||||
<span>Sign in</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* Mobile slide-out menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<aside className="absolute top-0 left-0 h-full w-72 bg-[#0a0b10] border-r border-white/5 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<span className="text-white font-medium">Menu</span>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="p-2 -mr-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<ContextPanel
|
||||
chats={[]}
|
||||
activeChatId={null}
|
||||
isAuthenticated={false}
|
||||
profile={null}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 border-t border-white/5">
|
||||
<Link
|
||||
to="/auth"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 text-sm font-medium bg-white text-black rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
<span>Sign in</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-screen max-w-[1700px] mx-auto md:grid md:grid-cols-[280px_1fr] bg-inherit">
|
||||
<aside className="hidden md:flex border-r flex-col w-full h-screen border-none">
|
||||
<ContextPanel
|
||||
chats={[]}
|
||||
activeChatId={null}
|
||||
isAuthenticated={false}
|
||||
profile={null}
|
||||
/>
|
||||
</aside>
|
||||
<main className="flex flex-col h-screen bg-[#07080f] pt-14 md:pt-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto py-8 px-4 sm:px-6">
|
||||
{messages.length === 0 && !isStreaming ? (
|
||||
<EmptyChatState />
|
||||
) : (
|
||||
<>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ChatInput
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isStreaming}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
remainingRequests={remainingRequests}
|
||||
limitReached={limitReached}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated chat component - uses Electric SQL
|
||||
function AuthenticatedChat({ user }: { user: { name?: string | null; email: string; image?: string | null } }) {
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState("")
|
||||
const [activeThreadId, setActiveThreadId] = useState<number | null>(null)
|
||||
const [pendingMessages, setPendingMessages] = useState<Message[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>(AVAILABLE_MODELS[0].id)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut()
|
||||
window.location.href = "/"
|
||||
}
|
||||
|
||||
const chatThreadsCollection = getChatThreadsCollection()
|
||||
const chatMessagesCollection = getChatMessagesCollection()
|
||||
|
||||
const { data: threads = [] } = useLiveQuery((q) =>
|
||||
q
|
||||
.from({ chatThreads: chatThreadsCollection })
|
||||
.orderBy(({ chatThreads }) => chatThreads.created_at),
|
||||
)
|
||||
|
||||
const sortedThreads = useMemo(
|
||||
() => [...threads].sort((a, b) => b.id - a.id),
|
||||
[threads],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeThreadId === null && sortedThreads.length > 0) {
|
||||
setActiveThreadId(sortedThreads[0].id)
|
||||
}
|
||||
}, [sortedThreads, activeThreadId])
|
||||
|
||||
const { data: dbMessages = [] } = useLiveQuery((q) => {
|
||||
const base = q
|
||||
.from({ chatMessages: chatMessagesCollection })
|
||||
.orderBy(({ chatMessages }) => chatMessages.created_at)
|
||||
if (activeThreadId === null) {
|
||||
return base.where(({ chatMessages }) => eq(chatMessages.thread_id, -1))
|
||||
}
|
||||
return base.where(({ chatMessages }) =>
|
||||
eq(chatMessages.thread_id, activeThreadId),
|
||||
)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingMessages.length === 0) return
|
||||
|
||||
const stillPending = pendingMessages.filter((pending) => {
|
||||
const isSynced = dbMessages.some(
|
||||
(m: DBMessage) =>
|
||||
m.role === pending.role &&
|
||||
m.content === pending.content,
|
||||
)
|
||||
return !isSynced
|
||||
})
|
||||
|
||||
if (stillPending.length !== pendingMessages.length) {
|
||||
setPendingMessages(stillPending)
|
||||
}
|
||||
}, [dbMessages, pendingMessages])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel(getStoredModel())
|
||||
}, [])
|
||||
|
||||
const messages: Message[] = useMemo(() => {
|
||||
const baseMessages: Message[] = dbMessages.map((m: DBMessage) => ({
|
||||
id: m.id,
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
createdAt: m.created_at,
|
||||
}))
|
||||
|
||||
const msgs = [...baseMessages]
|
||||
for (const pending of pendingMessages) {
|
||||
const alreadyExists = msgs.some(
|
||||
(m) => m.role === pending.role && m.content === pending.content,
|
||||
)
|
||||
if (!alreadyExists) {
|
||||
msgs.push(pending)
|
||||
}
|
||||
}
|
||||
|
||||
return msgs
|
||||
}, [dbMessages, pendingMessages])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages, streamingContent])
|
||||
|
||||
const handleModelChange = (model: ModelId) => {
|
||||
setSelectedModel(model)
|
||||
setStoredModel(model)
|
||||
}
|
||||
|
||||
const handleSubmit = async (userContent: string) => {
|
||||
if (!userContent.trim() || isStreaming) return
|
||||
|
||||
setIsStreaming(true)
|
||||
setStreamingContent("")
|
||||
|
||||
try {
|
||||
let threadId = activeThreadId
|
||||
if (!threadId) {
|
||||
const thread = await createThread(userContent.slice(0, 40) || "New chat")
|
||||
threadId = thread.id
|
||||
setActiveThreadId(thread.id)
|
||||
}
|
||||
|
||||
const pendingUserMsg: Message = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: userContent,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
setPendingMessages((prev) => [...prev, pendingUserMsg])
|
||||
|
||||
await addMessage({ threadId, role: "user", content: userContent })
|
||||
|
||||
const threadMessages = dbMessages.filter((m: DBMessage) => m.thread_id === threadId)
|
||||
const apiMessages = [
|
||||
...threadMessages.map((m: DBMessage) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
})),
|
||||
{ role: "user" as const, content: userContent },
|
||||
]
|
||||
|
||||
const res = await fetch("/api/chat/ai", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ threadId, messages: apiMessages, model: selectedModel }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`AI request failed: ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error("No response body")
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ""
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
}
|
||||
|
||||
if (accumulated) {
|
||||
const pendingAssistantMsg: Message = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: accumulated,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
setPendingMessages((prev) => [...prev, pendingAssistantMsg])
|
||||
}
|
||||
setStreamingContent("")
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error)
|
||||
setStreamingContent("")
|
||||
} finally {
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header - only visible on small screens */}
|
||||
<header className="md:hidden fixed top-0 left-0 right-0 z-40 flex items-center justify-between px-4 py-3 bg-[#07080f]/95 backdrop-blur-sm border-b border-white/5">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="p-2 -ml-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center gap-2 p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-cyan-600 flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-white">
|
||||
{user.email?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile slide-out menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<aside className="absolute top-0 left-0 h-full w-72 bg-[#0a0b10] border-r border-white/5 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<span className="text-white font-medium">Menu</span>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="p-2 -mr-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ContextPanel
|
||||
chats={sortedThreads}
|
||||
activeChatId={activeThreadId ? activeThreadId.toString() : null}
|
||||
isAuthenticated={true}
|
||||
profile={user}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 border-t border-white/5">
|
||||
<div className="flex items-center gap-3 mb-3 px-1">
|
||||
<div className="w-8 h-8 rounded-full bg-cyan-600 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user.email?.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-white/70 text-sm truncate">{user.email}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false)
|
||||
handleSignOut()
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 text-sm font-medium text-red-400 hover:text-red-300 hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-screen max-w-[1700px] mx-auto md:grid md:grid-cols-[280px_1fr] bg-inherit">
|
||||
<aside className="hidden md:flex border-r flex-col w-full h-screen border-none">
|
||||
<ContextPanel
|
||||
chats={sortedThreads}
|
||||
activeChatId={activeThreadId ? activeThreadId.toString() : null}
|
||||
isAuthenticated={true}
|
||||
profile={user}
|
||||
/>
|
||||
</aside>
|
||||
<main className="flex flex-col h-screen bg-[#07080f] pt-14 md:pt-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto py-8 px-4 sm:px-6">
|
||||
{messages.length === 0 && !isStreaming ? (
|
||||
<EmptyChatState />
|
||||
) : (
|
||||
<>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ChatInput
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isStreaming}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const isAuthenticated = !!session?.user
|
||||
|
||||
if (isPending) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Render different components based on auth state
|
||||
// This prevents Electric SQL collections from being initialized for guests
|
||||
return isAuthenticated ? <AuthenticatedChat user={session.user} /> : <GuestChat />
|
||||
}
|
||||
215
packages/web/src/components/chat/MessageBubble.tsx
Normal file
215
packages/web/src/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
export type Message = {
|
||||
id: string | number
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: { message: Message }) {
|
||||
const isUser = message.role === "user"
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="w-fit max-w-2xl rounded-xl px-4 py-2 bg-[#16171f] inner-shadow-xl outline-1 outline-neutral-100/12 text-white">
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Assistant message with Markdown rendering
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="prose prose-sm prose-invert max-w-none text-white">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ ...props }) => (
|
||||
<h1
|
||||
className="text-2xl font-bold mt-6 mb-4 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2
|
||||
className="text-xl font-bold mt-5 mb-3 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3
|
||||
className="text-lg font-semibold mt-4 mb-2 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="mb-4 leading-relaxed text-neutral-200" {...props} />
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="list-disc list-inside mb-4 space-y-1 text-neutral-200"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="list-decimal list-inside mb-4 space-y-1 text-neutral-200"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ ...props }) => (
|
||||
<li className="ml-2 text-neutral-200" {...props} />
|
||||
),
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code
|
||||
className="bg-[#1e1f28] px-1.5 py-0.5 rounded text-sm font-mono text-teal-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code
|
||||
className="block bg-[#1e1f28] p-3 rounded-lg overflow-x-auto text-sm font-mono my-4 text-neutral-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ ...props }) => <pre className="my-4" {...props} />,
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-teal-500/50 pl-4 italic my-4 text-neutral-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: ({ ...props }) => (
|
||||
<a
|
||||
className="text-teal-400 hover:text-teal-300 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ ...props }) => (
|
||||
<strong className="font-semibold text-white" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 py-2">
|
||||
<div className="w-2 h-2 bg-teal-500 rounded-full animate-pulse" />
|
||||
<div
|
||||
className="w-2 h-2 bg-teal-500 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 h-2 bg-teal-500 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.4s" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StreamingMessage({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="prose prose-sm prose-invert max-w-none text-white">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ ...props }) => (
|
||||
<h1
|
||||
className="text-2xl font-bold mt-6 mb-4 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2
|
||||
className="text-xl font-bold mt-5 mb-3 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3
|
||||
className="text-lg font-semibold mt-4 mb-2 text-white"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="mb-4 leading-relaxed text-neutral-200" {...props} />
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="list-disc list-inside mb-4 space-y-1 text-neutral-200"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="list-decimal list-inside mb-4 space-y-1 text-neutral-200"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ ...props }) => (
|
||||
<li className="ml-2 text-neutral-200" {...props} />
|
||||
),
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const isInline = !className
|
||||
return isInline ? (
|
||||
<code
|
||||
className="bg-[#1e1f28] px-1.5 py-0.5 rounded text-sm font-mono text-teal-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code
|
||||
className="block bg-[#1e1f28] p-3 rounded-lg overflow-x-auto text-sm font-mono my-4 text-neutral-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ ...props }) => <pre className="my-4" {...props} />,
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-teal-500/50 pl-4 italic my-4 text-neutral-400"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
a: ({ ...props }) => (
|
||||
<a
|
||||
className="text-teal-400 hover:text-teal-300 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ ...props }) => (
|
||||
<strong className="font-semibold text-white" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
<span className="inline-block w-1.5 h-4 ml-0.5 bg-teal-500/70 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
packages/web/src/components/flowglad/regularPlanButton.tsx
Normal file
29
packages/web/src/components/flowglad/regularPlanButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useBilling } from "@flowglad/react"
|
||||
|
||||
export function RegularPlanButton() {
|
||||
const { createCheckoutSession, loaded, errors } = useBilling()
|
||||
|
||||
if (!loaded || !createCheckoutSession) {
|
||||
return (
|
||||
<button type="button" disabled>
|
||||
Loading checkout…
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (errors) {
|
||||
return <p>Unable to load checkout right now.</p>
|
||||
}
|
||||
|
||||
const handlePurchase = async () => {
|
||||
await createCheckoutSession({
|
||||
priceSlug: "",
|
||||
quantity: 1,
|
||||
successUrl: `${window.location.origin}/billing/success`,
|
||||
cancelUrl: `${window.location.origin}/billing/cancel`,
|
||||
autoRedirect: true,
|
||||
})
|
||||
}
|
||||
|
||||
return <button onClick={handlePurchase}>Buy now</button>
|
||||
}
|
||||
13
packages/web/src/data/demo.punk-songs.ts
Normal file
13
packages/web/src/data/demo.punk-songs.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
|
||||
export const getPunkSongs = createServerFn({
|
||||
method: "GET",
|
||||
}).handler(async () => [
|
||||
{ id: 1, name: "Teenage Dirtbag", artist: "Wheatus" },
|
||||
{ id: 2, name: "Smells Like Teen Spirit", artist: "Nirvana" },
|
||||
{ id: 3, name: "The Middle", artist: "Jimmy Eat World" },
|
||||
{ id: 4, name: "My Own Worst Enemy", artist: "Lit" },
|
||||
{ id: 5, name: "Fat Lip", artist: "Sum 41" },
|
||||
{ id: 6, name: "All the Small Things", artist: "blink-182" },
|
||||
{ id: 7, name: "Beverly Hills", artist: "Weezer" },
|
||||
])
|
||||
75
packages/web/src/db/connection.ts
Normal file
75
packages/web/src/db/connection.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import postgres from "postgres"
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import * as schema from "./schema"
|
||||
|
||||
type Hyperdrive = {
|
||||
connectionString: string
|
||||
}
|
||||
|
||||
type CloudflareEnv = {
|
||||
DATABASE_URL?: string
|
||||
HYPERDRIVE?: Hyperdrive
|
||||
}
|
||||
|
||||
// Note: NO caching - Cloudflare Workers don't allow sharing I/O objects across requests
|
||||
|
||||
// Get the database connection string, preferring DATABASE_URL over Hyperdrive
|
||||
const getConnectionString = (env?: CloudflareEnv): string => {
|
||||
// Prefer DATABASE_URL if set (direct connection, bypasses Hyperdrive)
|
||||
if (env?.DATABASE_URL) {
|
||||
return env.DATABASE_URL
|
||||
}
|
||||
|
||||
// Fall back to Hyperdrive if available
|
||||
if (env?.HYPERDRIVE?.connectionString) {
|
||||
return env.HYPERDRIVE.connectionString
|
||||
}
|
||||
|
||||
// Fall back to process.env (local dev)
|
||||
if (process.env.DATABASE_URL) {
|
||||
return process.env.DATABASE_URL
|
||||
}
|
||||
|
||||
throw new Error("No database connection available. Set DATABASE_URL or configure Hyperdrive.")
|
||||
}
|
||||
|
||||
// Helper to get Cloudflare env from server context
|
||||
const getCloudflareEnv = (): CloudflareEnv | undefined => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: CloudflareEnv } } | null
|
||||
}
|
||||
return getServerContext()?.cloudflare?.env
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function to get db using server context
|
||||
export const db = () => getDb(getConnectionString(getCloudflareEnv()))
|
||||
export const authDb = () => getAuthDb(getConnectionString(getCloudflareEnv()))
|
||||
|
||||
// Main db with snake_case casing for app tables (chat_threads, chat_messages)
|
||||
export const getDb = (databaseUrlOrHyperdrive: string | Hyperdrive) => {
|
||||
const connectionString =
|
||||
typeof databaseUrlOrHyperdrive === "string"
|
||||
? databaseUrlOrHyperdrive
|
||||
: databaseUrlOrHyperdrive.connectionString
|
||||
|
||||
// Create fresh connection per request for Cloudflare Workers compatibility
|
||||
const sql = postgres(connectionString, { prepare: false })
|
||||
return drizzle(sql, { schema, casing: "snake_case" })
|
||||
}
|
||||
|
||||
// Auth db WITHOUT casing transform for better-auth tables (users, sessions, etc.)
|
||||
// better-auth uses camelCase columns and manages its own naming
|
||||
export const getAuthDb = (databaseUrlOrHyperdrive: string | Hyperdrive) => {
|
||||
const connectionString =
|
||||
typeof databaseUrlOrHyperdrive === "string"
|
||||
? databaseUrlOrHyperdrive
|
||||
: databaseUrlOrHyperdrive.connectionString
|
||||
|
||||
// Create fresh connection per request for Cloudflare Workers compatibility
|
||||
const sql = postgres(connectionString, { prepare: false })
|
||||
return drizzle(sql, { schema })
|
||||
}
|
||||
283
packages/web/src/db/schema.ts
Normal file
283
packages/web/src/db/schema.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
boolean,
|
||||
doublePrecision,
|
||||
foreignKey,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { createSchemaFactory } from "drizzle-zod"
|
||||
import { z } from "zod"
|
||||
|
||||
const { createSelectSchema } = createSchemaFactory({ zodInstance: z })
|
||||
|
||||
// Better-auth tables (using camelCase as better-auth expects)
|
||||
export const users = pgTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
username: text("username").unique(), // unique username for stream URLs (linsa.io/username)
|
||||
emailVerified: boolean("emailVerified")
|
||||
.$defaultFn(() => false)
|
||||
.notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("createdAt")
|
||||
.$defaultFn(() => new Date())
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updatedAt")
|
||||
.$defaultFn(() => new Date())
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const sessions = pgTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
updatedAt: timestamp("updatedAt").notNull(),
|
||||
ipAddress: text("ipAddress"),
|
||||
userAgent: text("userAgent"),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
})
|
||||
|
||||
export const accounts = pgTable("accounts", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("accountId").notNull(),
|
||||
providerId: text("providerId").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accessToken: text("accessToken"),
|
||||
refreshToken: text("refreshToken"),
|
||||
idToken: text("idToken"),
|
||||
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
|
||||
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("createdAt").notNull(),
|
||||
updatedAt: timestamp("updatedAt").notNull(),
|
||||
})
|
||||
|
||||
export const verifications = pgTable("verifications", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expiresAt").notNull(),
|
||||
createdAt: timestamp("createdAt").$defaultFn(() => new Date()),
|
||||
updatedAt: timestamp("updatedAt").$defaultFn(() => new Date()),
|
||||
})
|
||||
|
||||
// App tables (using snake_case for Electric sync compatibility)
|
||||
export const chat_threads = pgTable("chat_threads", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
title: text("title").notNull(),
|
||||
user_id: text("user_id"), // nullable for guest users
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const chat_messages = pgTable("chat_messages", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
thread_id: integer("thread_id")
|
||||
.notNull()
|
||||
.references(() => chat_threads.id, { onDelete: "cascade" }),
|
||||
role: varchar("role", { length: 32 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const canvas = pgTable("canvas", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
owner_id: text("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull().default("Untitled Canvas"),
|
||||
width: integer("width").notNull().default(1024),
|
||||
height: integer("height").notNull().default(1024),
|
||||
default_model: text("default_model")
|
||||
.notNull()
|
||||
.default("gemini-2.5-flash-image-preview"),
|
||||
default_style: text("default_style").notNull().default("default"),
|
||||
background_prompt: text("background_prompt"),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const canvas_images = pgTable(
|
||||
"canvas_images",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
canvas_id: uuid("canvas_id")
|
||||
.notNull()
|
||||
.references(() => canvas.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull().default("Untitled Image"),
|
||||
prompt: text("prompt").notNull().default(""),
|
||||
model_id: text("model_id")
|
||||
.notNull()
|
||||
.default("gemini-2.0-flash-exp-image-generation"),
|
||||
model_used: text("model_used"),
|
||||
style_id: text("style_id").notNull().default("default"),
|
||||
width: integer("width").notNull().default(512),
|
||||
height: integer("height").notNull().default(512),
|
||||
position: jsonb("position")
|
||||
.$type<{ x: number; y: number }>()
|
||||
.$defaultFn(() => ({ x: 0, y: 0 }))
|
||||
.notNull(),
|
||||
rotation: doublePrecision("rotation").notNull().default(0),
|
||||
content_base64: text("content_base64"),
|
||||
image_url: text("image_url"),
|
||||
metadata: jsonb("metadata"),
|
||||
branch_parent_id: uuid("branch_parent_id"),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
branchParentFk: foreignKey({
|
||||
columns: [table.branch_parent_id],
|
||||
foreignColumns: [table.id],
|
||||
name: "canvas_images_branch_parent_id_canvas_images_id_fk",
|
||||
}).onDelete("set null"),
|
||||
}),
|
||||
)
|
||||
|
||||
// Context items for website/file content injection into chat
|
||||
export const context_items = pgTable("context_items", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: varchar("type", { length: 32 }).notNull(), // 'url' or 'file'
|
||||
url: text("url"), // URL for web content
|
||||
name: text("name").notNull(), // Display name (domain/path or filename)
|
||||
content: text("content"), // Fetched markdown content
|
||||
refreshing: boolean("refreshing").notNull().default(false),
|
||||
parent_id: integer("parent_id"), // For hierarchical URL structure
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
// Junction table for active context items per thread
|
||||
export const thread_context_items = pgTable("thread_context_items", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
thread_id: integer("thread_id")
|
||||
.notNull()
|
||||
.references(() => chat_threads.id, { onDelete: "cascade" }),
|
||||
context_item_id: integer("context_item_id")
|
||||
.notNull()
|
||||
.references(() => context_items.id, { onDelete: "cascade" }),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const blocks = pgTable("blocks", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: text("name").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
// Browser sessions - for saving browser tabs (Safari, Chrome, etc.)
|
||||
export const browser_sessions = pgTable("browser_sessions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(), // e.g., "2024-01-23-safari-tabs-1"
|
||||
browser: varchar("browser", { length: 32 }).notNull().default("safari"), // safari, chrome, firefox, arc, etc.
|
||||
tab_count: integer("tab_count").notNull().default(0),
|
||||
is_favorite: boolean("is_favorite").notNull().default(false),
|
||||
captured_at: timestamp("captured_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(), // when the session was captured
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const browser_session_tabs = pgTable("browser_session_tabs", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
session_id: uuid("session_id")
|
||||
.notNull()
|
||||
.references(() => browser_sessions.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull().default(""),
|
||||
url: text("url").notNull(),
|
||||
position: integer("position").notNull().default(0), // order within session
|
||||
favicon_url: text("favicon_url"), // optional favicon
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Streams (Twitch-like live streaming)
|
||||
// =============================================================================
|
||||
|
||||
export const streams = pgTable("streams", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull().default("Live Stream"),
|
||||
description: text("description"),
|
||||
is_live: boolean("is_live").notNull().default(false),
|
||||
viewer_count: integer("viewer_count").notNull().default(0),
|
||||
stream_key: text("stream_key").notNull().unique(), // secret key for streaming
|
||||
// Stream endpoints (set by Linux server)
|
||||
hls_url: text("hls_url"), // HLS playback URL
|
||||
thumbnail_url: text("thumbnail_url"),
|
||||
started_at: timestamp("started_at", { withTimezone: true }),
|
||||
ended_at: timestamp("ended_at", { withTimezone: true }),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const selectStreamsSchema = createSelectSchema(streams)
|
||||
export type Stream = z.infer<typeof selectStreamsSchema>
|
||||
|
||||
export const selectUsersSchema = createSelectSchema(users)
|
||||
export const selectChatThreadSchema = createSelectSchema(chat_threads)
|
||||
export const selectChatMessageSchema = createSelectSchema(chat_messages)
|
||||
export const selectCanvasSchema = createSelectSchema(canvas)
|
||||
export const selectCanvasImageSchema = createSelectSchema(canvas_images)
|
||||
export const selectContextItemSchema = createSelectSchema(context_items)
|
||||
export const selectThreadContextItemSchema =
|
||||
createSelectSchema(thread_context_items)
|
||||
export const selectBrowserSessionSchema = createSelectSchema(browser_sessions)
|
||||
export const selectBrowserSessionTabSchema =
|
||||
createSelectSchema(browser_session_tabs)
|
||||
export type User = z.infer<typeof selectUsersSchema>
|
||||
export type ChatThread = z.infer<typeof selectChatThreadSchema>
|
||||
export type ChatMessage = z.infer<typeof selectChatMessageSchema>
|
||||
export type CanvasRecord = z.infer<typeof selectCanvasSchema>
|
||||
export type CanvasImage = z.infer<typeof selectCanvasImageSchema>
|
||||
export type ContextItem = z.infer<typeof selectContextItemSchema>
|
||||
export type ThreadContextItem = z.infer<typeof selectThreadContextItemSchema>
|
||||
export type BrowserSession = z.infer<typeof selectBrowserSessionSchema>
|
||||
export type BrowserSessionTab = z.infer<typeof selectBrowserSessionTabSchema>
|
||||
29
packages/web/src/env.d.ts
vendored
Normal file
29
packages/web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
declare namespace Cloudflare {
|
||||
interface Env {
|
||||
DATABASE_URL: string
|
||||
HYPERDRIVE: Hyperdrive
|
||||
ELECTRIC_URL: string
|
||||
ELECTRIC_SOURCE_ID?: string
|
||||
ELECTRIC_SOURCE_SECRET?: string
|
||||
BETTER_AUTH_SECRET: string
|
||||
APP_BASE_URL?: string
|
||||
RESEND_API_KEY?: string
|
||||
RESEND_FROM_EMAIL?: string
|
||||
OPENROUTER_API_KEY?: string
|
||||
OPENROUTER_MODEL?: string
|
||||
GEMINI_API_KEY?: string
|
||||
FLOWGLAD_SECRET_KEY?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Hyperdrive {
|
||||
connectionString: string
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_FLOWGLAD_ENABLED?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
612
packages/web/src/features/canvas/BladeCanvasExperience.tsx
Normal file
612
packages/web/src/features/canvas/BladeCanvasExperience.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import Canvas from "./components/Canvas"
|
||||
import Overlay from "./components/Overlay"
|
||||
import Onboarding from "./components/Onboarding"
|
||||
import {
|
||||
CanvasProvider,
|
||||
useCanvasStore,
|
||||
type CanvasBox,
|
||||
} from "./store/canvasStore"
|
||||
import type {
|
||||
SerializedCanvasImage,
|
||||
SerializedCanvasRecord,
|
||||
} from "@/lib/canvas/types"
|
||||
import {
|
||||
createCanvasBox,
|
||||
deleteCanvasBox,
|
||||
generateCanvasBoxImage,
|
||||
updateCanvasBox,
|
||||
} from "@/lib/canvas/client"
|
||||
import type { CanvasRect } from "./config"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
const TOKEN_COST = 1
|
||||
const DEFAULT_TOKEN_BALANCE = { tokens: 999, premiumTokens: 999 }
|
||||
|
||||
type BladeCanvasExperienceProps = {
|
||||
initialCanvas: SerializedCanvasRecord
|
||||
initialImages: SerializedCanvasImage[]
|
||||
}
|
||||
|
||||
const getImageDataUrl = (image: SerializedCanvasImage) => {
|
||||
if (image.imageUrl) {
|
||||
return image.imageUrl
|
||||
}
|
||||
if (image.imageData) {
|
||||
const mime =
|
||||
typeof image.metadata?.mimeType === "string" ? image.metadata.mimeType : "image/png"
|
||||
return `data:${mime};base64,${image.imageData}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const uiModelFromProvider = (
|
||||
modelId: string | null | undefined,
|
||||
): CanvasBox["model"] => {
|
||||
if (!modelId) {
|
||||
return "gemini"
|
||||
}
|
||||
if (modelId.includes("gpt-image") || modelId.includes("dall")) {
|
||||
return "dall-e-3"
|
||||
}
|
||||
if (modelId.includes("nano-banana")) {
|
||||
return "nano-banana"
|
||||
}
|
||||
return "gemini"
|
||||
}
|
||||
|
||||
const GEMINI_MODEL = "gemini-2.5-flash-image-preview"
|
||||
|
||||
const providerModelFromUi = (model: CanvasBox["model"]) => {
|
||||
switch (model) {
|
||||
case "dall-e-3":
|
||||
return "gpt-image-1"
|
||||
case "nano-banana":
|
||||
return "nano-banana"
|
||||
default:
|
||||
return GEMINI_MODEL
|
||||
}
|
||||
}
|
||||
|
||||
const mapImageToBoxInput = (image: SerializedCanvasImage): CanvasBox => ({
|
||||
id: image.id,
|
||||
name: image.name,
|
||||
prompt: image.prompt ?? "",
|
||||
rect: {
|
||||
x: image.position?.x ?? 0,
|
||||
y: image.position?.y ?? 0,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
},
|
||||
imageUrl: getImageDataUrl(image),
|
||||
description:
|
||||
typeof image.metadata?.description === "string" ? image.metadata.description : undefined,
|
||||
model: uiModelFromProvider(image.modelId),
|
||||
styleId: image.styleId ?? "default",
|
||||
branchParentId: image.branchParentId ?? null,
|
||||
})
|
||||
|
||||
const rectToPosition = (rect: CanvasRect) => ({ x: rect.x, y: rect.y })
|
||||
const rectToSize = (rect: CanvasRect) => ({ width: rect.width, height: rect.height })
|
||||
|
||||
export function BladeCanvasExperience({
|
||||
initialCanvas,
|
||||
initialImages,
|
||||
}: BladeCanvasExperienceProps) {
|
||||
return (
|
||||
<CanvasProvider>
|
||||
<BladeCanvasExperienceContent
|
||||
initialCanvas={initialCanvas}
|
||||
initialImages={initialImages}
|
||||
/>
|
||||
</CanvasProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function BladeCanvasExperienceContent({
|
||||
initialCanvas,
|
||||
initialImages,
|
||||
}: BladeCanvasExperienceProps) {
|
||||
const canvasId = initialCanvas.id
|
||||
const {
|
||||
boxes,
|
||||
addBox,
|
||||
updateBoxData,
|
||||
deleteBox,
|
||||
setSelectedBoxId,
|
||||
selectedBoxId,
|
||||
reset,
|
||||
startOnboarding,
|
||||
} = useCanvasStore()
|
||||
|
||||
const [promptValue, setPromptValue] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [generatingBoxIds, setGeneratingBoxIds] = useState<string[]>([])
|
||||
const [promptContextLabel, setPromptContextLabel] = useState<string | null>(null)
|
||||
const [editingBoxId, setEditingBoxId] = useState<string | null>(null)
|
||||
const promptSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
const activeBox = useMemo(
|
||||
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
|
||||
[boxes, selectedBoxId],
|
||||
)
|
||||
|
||||
const editingBox = useMemo(
|
||||
() => (editingBoxId ? boxes.find((box) => box.id === editingBoxId) ?? null : null),
|
||||
[boxes, editingBoxId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) {
|
||||
return
|
||||
}
|
||||
reset(initialImages.map(mapImageToBoxInput))
|
||||
initializedRef.current = true
|
||||
}, [initialImages, reset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBox) {
|
||||
setPromptValue("")
|
||||
return
|
||||
}
|
||||
setPromptValue((prev) => (prev === activeBox.prompt ? prev : activeBox.prompt))
|
||||
}, [activeBox?.id, activeBox?.prompt])
|
||||
|
||||
useEffect(() => {
|
||||
if (boxes.length === 0) {
|
||||
startOnboarding()
|
||||
}
|
||||
}, [boxes.length, startOnboarding])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBox) {
|
||||
setPromptContextLabel(null)
|
||||
return
|
||||
}
|
||||
if (activeBox.branchParentId) {
|
||||
const boxIndex = boxes.findIndex((box) => box.id === activeBox.id) + 1
|
||||
const parentIndex =
|
||||
boxes.findIndex((box) => box.id === activeBox.branchParentId) + 1
|
||||
setPromptContextLabel(
|
||||
`Box ${boxIndex} Branch of Box ${parentIndex > 0 ? parentIndex : "?"}`,
|
||||
)
|
||||
} else if (activeBox.name) {
|
||||
setPromptContextLabel(activeBox.name)
|
||||
} else {
|
||||
const boxIndex = boxes.findIndex((box) => box.id === activeBox.id) + 1
|
||||
setPromptContextLabel(boxIndex ? `Box ${boxIndex}` : null)
|
||||
}
|
||||
}, [activeBox, boxes])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (promptSaveRef.current) {
|
||||
clearTimeout(promptSaveRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const schedulePromptSave = useCallback((boxId: string, prompt: string) => {
|
||||
if (promptSaveRef.current) {
|
||||
clearTimeout(promptSaveRef.current)
|
||||
}
|
||||
promptSaveRef.current = setTimeout(() => {
|
||||
updateCanvasBox(boxId, { prompt }).catch((err) => {
|
||||
console.error("[canvas] failed to persist prompt", err)
|
||||
setError("Failed to save prompt")
|
||||
})
|
||||
}, 600)
|
||||
}, [])
|
||||
|
||||
const syncBoxWithImage = useCallback(
|
||||
(localId: string, image: SerializedCanvasImage) => {
|
||||
const mapped = mapImageToBoxInput(image)
|
||||
updateBoxData(localId, () => mapped as CanvasBox)
|
||||
if (localId !== mapped.id) {
|
||||
setSelectedBoxId((prev) => (prev === localId ? mapped.id : prev))
|
||||
setGeneratingBoxIds((prev) => prev.map((id) => (id === localId ? mapped.id : id)))
|
||||
}
|
||||
return mapped.id
|
||||
},
|
||||
[setSelectedBoxId, updateBoxData],
|
||||
)
|
||||
|
||||
const persistNewBox = useCallback(
|
||||
async (box: CanvasBox) => {
|
||||
const image = await createCanvasBox({
|
||||
canvasId,
|
||||
name: box.name,
|
||||
prompt: box.prompt,
|
||||
position: rectToPosition(box.rect),
|
||||
size: rectToSize(box.rect),
|
||||
modelId: providerModelFromUi(box.model),
|
||||
styleId: box.styleId ?? "default",
|
||||
branchParentId: box.branchParentId ?? null,
|
||||
})
|
||||
return syncBoxWithImage(box.id, image)
|
||||
},
|
||||
[canvasId, syncBoxWithImage],
|
||||
)
|
||||
|
||||
const handlePromptValueChange = useCallback(
|
||||
(value: string) => {
|
||||
setPromptValue(value)
|
||||
if (!selectedBoxId) {
|
||||
return
|
||||
}
|
||||
updateBoxData(selectedBoxId, (box) => ({
|
||||
...box,
|
||||
prompt: value,
|
||||
}))
|
||||
schedulePromptSave(selectedBoxId, value)
|
||||
},
|
||||
[schedulePromptSave, selectedBoxId, updateBoxData],
|
||||
)
|
||||
|
||||
const handleSelectStyle = useCallback(
|
||||
async (styleId: string) => {
|
||||
if (!selectedBoxId) return
|
||||
updateBoxData(selectedBoxId, (box) => ({ ...box, styleId }))
|
||||
try {
|
||||
await updateCanvasBox(selectedBoxId, { styleId })
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to update style", err)
|
||||
setError("Failed to update style")
|
||||
}
|
||||
},
|
||||
[selectedBoxId, updateBoxData],
|
||||
)
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
async (modelId: CanvasBox["model"], boxId?: string) => {
|
||||
const targetId = boxId ?? selectedBoxId
|
||||
if (!targetId) return
|
||||
updateBoxData(targetId, (box) => ({ ...box, model: modelId }))
|
||||
try {
|
||||
await updateCanvasBox(targetId, {
|
||||
modelId: providerModelFromUi(modelId),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to update model", err)
|
||||
setError("Failed to update model")
|
||||
}
|
||||
},
|
||||
[selectedBoxId, updateBoxData],
|
||||
)
|
||||
|
||||
const handleRectCommit = useCallback(
|
||||
(boxId: string, rect: CanvasRect) => {
|
||||
updateCanvasBox(boxId, {
|
||||
position: rectToPosition(rect),
|
||||
size: rectToSize(rect),
|
||||
}).catch((err) => {
|
||||
console.error("[canvas] failed to persist rect", err)
|
||||
setError("Failed to save box position")
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleEditPromptChange = useCallback(
|
||||
(boxId: string, value: string) => {
|
||||
updateBoxData(boxId, (box) => ({
|
||||
...box,
|
||||
prompt: value,
|
||||
}))
|
||||
schedulePromptSave(boxId, value)
|
||||
},
|
||||
[schedulePromptSave, updateBoxData],
|
||||
)
|
||||
|
||||
const handleEditSizeChange = useCallback(
|
||||
(boxId: string, dimension: "width" | "height", value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return
|
||||
}
|
||||
const target = boxes.find((box) => box.id === boxId)
|
||||
const width = dimension === "width" ? value : target?.rect.width ?? value
|
||||
const height = dimension === "height" ? value : target?.rect.height ?? value
|
||||
updateBoxData(boxId, (box) => ({
|
||||
...box,
|
||||
rect: {
|
||||
...box.rect,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
}))
|
||||
updateCanvasBox(boxId, {
|
||||
size: { width, height },
|
||||
}).catch((err) => {
|
||||
console.error("[canvas] failed to update size", err)
|
||||
setError("Failed to update size")
|
||||
})
|
||||
},
|
||||
[boxes, updateBoxData],
|
||||
)
|
||||
|
||||
const handleAddBox = useCallback(async () => {
|
||||
const created = addBox()
|
||||
if (!created) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const newId = await persistNewBox(created)
|
||||
setSelectedBoxId(newId)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to create box", err)
|
||||
deleteBox(created.id)
|
||||
setError("Failed to add box")
|
||||
}
|
||||
}, [addBox, deleteBox, persistNewBox, setSelectedBoxId])
|
||||
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
if (!selectedBoxId) {
|
||||
return
|
||||
}
|
||||
if (boxes.length <= 1) {
|
||||
setError("Keep at least one box on the canvas.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteCanvasBox(selectedBoxId)
|
||||
deleteBox(selectedBoxId)
|
||||
setGeneratingBoxIds((prev) => prev.filter((id) => id !== selectedBoxId))
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to delete box", err)
|
||||
setError("Failed to delete box")
|
||||
}
|
||||
}, [boxes.length, deleteBox, selectedBoxId])
|
||||
|
||||
const handleBranchFrom = useCallback(
|
||||
async (box: CanvasBox) => {
|
||||
const parentIndex = boxes.findIndex((candidate) => candidate.id === box.id) + 1
|
||||
const branchName = `Box ${boxes.length + 1} Branch of Box ${parentIndex || "?"}`
|
||||
const created = addBox(
|
||||
{
|
||||
name: branchName,
|
||||
prompt: box.prompt,
|
||||
model: box.model,
|
||||
styleId: box.styleId,
|
||||
branchParentId: box.id,
|
||||
},
|
||||
{ select: true },
|
||||
)
|
||||
if (!created) {
|
||||
return
|
||||
}
|
||||
setPromptValue(box.prompt)
|
||||
try {
|
||||
const newId = await persistNewBox(created)
|
||||
setSelectedBoxId(newId)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to branch box", err)
|
||||
deleteBox(created.id)
|
||||
setError("Unable to create a branch box")
|
||||
}
|
||||
},
|
||||
[addBox, boxes, deleteBox, persistNewBox, setSelectedBoxId],
|
||||
)
|
||||
|
||||
const handleSubmitPrompt = useCallback(
|
||||
async (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
|
||||
let targetBoxId = selectedBoxId ?? null
|
||||
let targetBox = targetBoxId
|
||||
? boxes.find((candidate) => candidate.id === targetBoxId) ?? null
|
||||
: null
|
||||
|
||||
if (!targetBox || !targetBoxId) {
|
||||
const created = addBox({ prompt: trimmed }, { select: true })
|
||||
if (!created) {
|
||||
setError("Unable to create a box for this prompt")
|
||||
return false
|
||||
}
|
||||
try {
|
||||
targetBoxId = await persistNewBox(created)
|
||||
targetBox = { ...created, id: targetBoxId }
|
||||
setSelectedBoxId(targetBoxId)
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to create prompt box", err)
|
||||
deleteBox(created.id)
|
||||
setError("Unable to create a box for this prompt")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const boxId = targetBoxId
|
||||
let effectivePrompt = trimmed
|
||||
if (targetBox?.branchParentId) {
|
||||
const parent = boxes.find((candidate) => candidate.id === targetBox.branchParentId)
|
||||
if (parent) {
|
||||
const parentPrompt = parent.prompt.trim()
|
||||
if (parentPrompt && !effectivePrompt.startsWith(parentPrompt)) {
|
||||
effectivePrompt = [parentPrompt, effectivePrompt]
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setGeneratingBoxIds((prev) => (prev.includes(boxId) ? prev : [...prev, boxId]))
|
||||
setError(null)
|
||||
let currentId = boxId
|
||||
try {
|
||||
const image = await generateCanvasBoxImage({
|
||||
imageId: boxId,
|
||||
prompt: effectivePrompt,
|
||||
modelId: providerModelFromUi(targetBox!.model),
|
||||
})
|
||||
currentId = syncBoxWithImage(boxId, image)
|
||||
setPromptValue(effectivePrompt)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error("[canvas] generation failed", err)
|
||||
const message = err instanceof Error ? err.message : "Unable to generate image"
|
||||
setError(message)
|
||||
return false
|
||||
} finally {
|
||||
setGeneratingBoxIds((prev) => prev.filter((id) => id !== currentId))
|
||||
}
|
||||
},
|
||||
[addBox, boxes, deleteBox, persistNewBox, selectedBoxId, setSelectedBoxId, syncBoxWithImage],
|
||||
)
|
||||
|
||||
const currentBoxName = activeBox?.name ?? null
|
||||
const isGenerating = selectedBoxId ? generatingBoxIds.includes(selectedBoxId) : false
|
||||
|
||||
const handleOpenEdit = useCallback(
|
||||
(box: CanvasBox) => {
|
||||
setEditingBoxId(box.id)
|
||||
setSelectedBoxId(box.id)
|
||||
},
|
||||
[setSelectedBoxId],
|
||||
)
|
||||
|
||||
const handleCloseEdit = useCallback(() => {
|
||||
setEditingBoxId(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingBoxId && !boxes.find((box) => box.id === editingBoxId)) {
|
||||
setEditingBoxId(null)
|
||||
}
|
||||
}, [boxes, editingBoxId])
|
||||
|
||||
if (editingBox) {
|
||||
const imageUrl = editingBox.imageUrl
|
||||
return (
|
||||
<div className="flex h-screen w-full divide-x divide-white/10 overflow-hidden bg-neutral-950 text-white">
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
{imageUrl ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 scale-110 transform bg-cover bg-center blur-3xl opacity-50"
|
||||
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||
/>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center p-8">
|
||||
<motion.img
|
||||
layoutId={`box-image-${editingBox.id}`}
|
||||
src={imageUrl}
|
||||
alt={editingBox.name}
|
||||
className="max-h-full max-w-full rounded-3xl shadow-[0_30px_60px_rgba(0,0,0,0.5)]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white/60">
|
||||
No image generated yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full max-w-md flex-col gap-6 bg-neutral-900/80 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.4em] text-white/60">Editing</p>
|
||||
<h2 className="text-2xl font-semibold">{editingBox.name}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseEdit}
|
||||
className="rounded-full border border-white/20 px-4 py-1 text-sm text-white transition hover:border-white/50"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-white/60">Prompt</span>
|
||||
<textarea
|
||||
className="min-h-[150px] rounded-2xl border border-white/10 bg-white/5 p-3 text-sm text-white outline-none focus:border-white/40"
|
||||
value={editingBox.prompt}
|
||||
onChange={(event) => handleEditPromptChange(editingBox.id, event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-white/60">Width</span>
|
||||
<input
|
||||
type="number"
|
||||
min={64}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-white outline-none focus:border-white/40"
|
||||
value={Math.round(editingBox.rect.width)}
|
||||
onChange={(event) => handleEditSizeChange(editingBox.id, "width", Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-white/60">Height</span>
|
||||
<input
|
||||
type="number"
|
||||
min={64}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-white outline-none focus:border-white/40"
|
||||
value={Math.round(editingBox.rect.height)}
|
||||
onChange={(event) => handleEditSizeChange(editingBox.id, "height", Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-white/60">Model</span>
|
||||
<select
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-white outline-none focus:border-white/40"
|
||||
value={editingBox.model}
|
||||
onChange={(event) => handleModelChange(event.target.value as CanvasBox["model"], editingBox.id)}
|
||||
>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="dall-e-3">DALL·E 3</option>
|
||||
<option value="nano-banana" disabled>
|
||||
Nano Banana (Coming soon)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/80">
|
||||
<span>Style</span>
|
||||
<span className="font-semibold">{editingBox.styleId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-full overflow-hidden rounded-xl border border-slate-200 bg-white text-slate-900 transition-colors duration-300 dark:border-neutral-800 dark:bg-neutral-950 dark:text-slate-100">
|
||||
<Canvas
|
||||
generatingBoxIds={generatingBoxIds}
|
||||
error={error}
|
||||
onBranchFrom={handleBranchFrom}
|
||||
onRectCommit={handleRectCommit}
|
||||
onEditBox={handleOpenEdit}
|
||||
editingBoxId={editingBoxId}
|
||||
/>
|
||||
<Overlay
|
||||
value={promptValue}
|
||||
onValueChange={handlePromptValueChange}
|
||||
onSubmit={handleSubmitPrompt}
|
||||
isGenerating={isGenerating}
|
||||
error={error}
|
||||
contextLabel={promptContextLabel}
|
||||
onAddBox={handleAddBox}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
onSelectStyle={handleSelectStyle}
|
||||
onSelectModel={handleModelChange}
|
||||
tokenBalance={DEFAULT_TOKEN_BALANCE}
|
||||
tokenCost={TOKEN_COST}
|
||||
/>
|
||||
{currentBoxName ? (
|
||||
<div className="absolute left-1/2 top-4 -translate-x-1/2 text-xs uppercase tracking-wide text-white/50">
|
||||
Selected: {currentBoxName}
|
||||
</div>
|
||||
) : null}
|
||||
{/* <Onboarding /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
893
packages/web/src/features/canvas/components/Canvas.tsx
Normal file
893
packages/web/src/features/canvas/components/Canvas.tsx
Normal file
@@ -0,0 +1,893 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
|
||||
import { CANVAS_CONFIG, type CanvasRect } from "../config"
|
||||
import { useCanvasStore, type CanvasBox } from "../store/canvasStore"
|
||||
import { GitBranch, Pencil, Trash2, Type } from "lucide-react"
|
||||
|
||||
const normaliseRect = (rect: CanvasRect): CanvasRect => ({
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
})
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max)
|
||||
|
||||
const MIN_VIEWPORT_SCALE = 0.4
|
||||
const MAX_VIEWPORT_SCALE = 3
|
||||
const ZOOM_SENSITIVITY = 0.0012
|
||||
|
||||
export type CanvasControls = {
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
reset: () => void
|
||||
addBox: () => void
|
||||
deleteSelected: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
hasSelection: boolean
|
||||
}
|
||||
|
||||
type CanvasProps = {
|
||||
generatingBoxIds?: string[]
|
||||
error?: string | null
|
||||
onControlsChange?: (controls: CanvasControls) => void
|
||||
onBranchFrom?: (box: CanvasBox) => void
|
||||
onRectCommit?: (boxId: string, rect: CanvasRect) => void
|
||||
onEditBox?: (box: CanvasBox) => void
|
||||
editingBoxId?: string | null
|
||||
}
|
||||
|
||||
export default function Canvas({
|
||||
generatingBoxIds = [],
|
||||
error,
|
||||
onControlsChange,
|
||||
onBranchFrom,
|
||||
onRectCommit,
|
||||
onEditBox,
|
||||
editingBoxId = null,
|
||||
}: CanvasProps) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const panStateRef = useRef<{
|
||||
pointerId: number
|
||||
startPointer: { x: number; y: number }
|
||||
startOffset: { x: number; y: number }
|
||||
} | null>(null)
|
||||
|
||||
const {
|
||||
boxes,
|
||||
addBox,
|
||||
updateBoxRect,
|
||||
updateBoxData,
|
||||
deleteBox,
|
||||
selectedBoxId,
|
||||
setSelectedBoxId,
|
||||
} = useCanvasStore()
|
||||
|
||||
const [viewport, setViewport] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
scale: number
|
||||
}>(() => ({ x: 0, y: 0, scale: 1 }))
|
||||
const [contextMenuBoxId, setContextMenuBoxId] = useState<string | null>(null)
|
||||
|
||||
const statusMessage = error
|
||||
? error
|
||||
: "Enter a prompt below to create an image."
|
||||
|
||||
const centerOnBox = useCallback((box: CanvasBox) => {
|
||||
const element = canvasRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
const rect = element.getBoundingClientRect()
|
||||
setViewport((prev) => {
|
||||
const scale = prev.scale
|
||||
const boxCenterX = box.rect.x + box.rect.width / 2
|
||||
const boxCenterY = box.rect.y + box.rect.height / 2
|
||||
return {
|
||||
...prev,
|
||||
x: rect.width / 2 - boxCenterX * scale,
|
||||
y: rect.height / 2 - boxCenterY * scale,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const previousBoxesLengthRef = useRef<number>(boxes.length)
|
||||
|
||||
useEffect(() => {
|
||||
const previousLength = previousBoxesLengthRef.current
|
||||
if (boxes.length === 0) {
|
||||
previousBoxesLengthRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (previousLength === 0) {
|
||||
centerOnBox(boxes[0])
|
||||
} else if (boxes.length > previousLength) {
|
||||
const last = boxes[boxes.length - 1]
|
||||
centerOnBox(last)
|
||||
}
|
||||
|
||||
previousBoxesLengthRef.current = boxes.length
|
||||
}, [boxes, centerOnBox])
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setContextMenuBoxId(null)
|
||||
addBox()
|
||||
}, [addBox])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(targetId?: string) => {
|
||||
const id = targetId ?? selectedBoxId
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
deleteBox(id)
|
||||
setContextMenuBoxId((prev) => (prev === id ? null : prev))
|
||||
},
|
||||
[deleteBox, selectedBoxId]
|
||||
)
|
||||
|
||||
const handleContextMenuOpen = useCallback(
|
||||
(boxId: string) => {
|
||||
setContextMenuBoxId(boxId)
|
||||
setSelectedBoxId(boxId)
|
||||
},
|
||||
[setSelectedBoxId]
|
||||
)
|
||||
|
||||
const handleContextMenuClose = useCallback(() => {
|
||||
setContextMenuBoxId(null)
|
||||
}, [])
|
||||
|
||||
const handleCanvasPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('[data-canvas-box="true"]')) {
|
||||
setContextMenuBoxId(null)
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const element = event.currentTarget
|
||||
panStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startPointer: { x: event.clientX, y: event.clientY },
|
||||
startOffset: { x: viewport.x, y: viewport.y },
|
||||
}
|
||||
setSelectedBoxId(null)
|
||||
setContextMenuBoxId(null)
|
||||
|
||||
if (element.setPointerCapture) {
|
||||
try {
|
||||
element.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// ignore capture errors
|
||||
}
|
||||
}
|
||||
},
|
||||
[setSelectedBoxId, viewport.x, viewport.y]
|
||||
)
|
||||
|
||||
const handleCanvasPointerMove = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const state = panStateRef.current
|
||||
if (!state || event.pointerId !== state.pointerId) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const dx = event.clientX - state.startPointer.x
|
||||
const dy = event.clientY - state.startPointer.y
|
||||
setViewport((prev) => ({
|
||||
...prev,
|
||||
x: state.startOffset.x + dx,
|
||||
y: state.startOffset.y + dy,
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCanvasPointerEnd = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const state = panStateRef.current
|
||||
if (!state || event.pointerId !== state.pointerId) {
|
||||
return
|
||||
}
|
||||
panStateRef.current = null
|
||||
const element = event.currentTarget
|
||||
if (element.releasePointerCapture) {
|
||||
try {
|
||||
element.releasePointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleWheel = useCallback((event: WheelEvent) => {
|
||||
const element = canvasRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
const point = {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
}
|
||||
|
||||
setViewport((prev) => {
|
||||
const scaleMultiplier = Math.exp(-event.deltaY * ZOOM_SENSITIVITY)
|
||||
const nextScale = clamp(
|
||||
prev.scale * scaleMultiplier,
|
||||
MIN_VIEWPORT_SCALE,
|
||||
MAX_VIEWPORT_SCALE
|
||||
)
|
||||
|
||||
if (nextScale === prev.scale) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const worldX = (point.x - prev.x) / prev.scale
|
||||
const worldY = (point.y - prev.y) / prev.scale
|
||||
const nextX = point.x - worldX * nextScale
|
||||
const nextY = point.y - worldY * nextScale
|
||||
|
||||
return {
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
scale: nextScale,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const element = canvasRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
const handle = (event: WheelEvent) => {
|
||||
handleWheel(event)
|
||||
}
|
||||
|
||||
element.addEventListener("wheel", handle, { passive: false })
|
||||
return () => {
|
||||
element.removeEventListener("wheel", handle)
|
||||
}
|
||||
}, [handleWheel])
|
||||
|
||||
useEffect(() => {
|
||||
onControlsChange?.({
|
||||
undo: () => undefined,
|
||||
redo: () => undefined,
|
||||
reset: () => {
|
||||
const target =
|
||||
boxes.find((box) => box.id === selectedBoxId) ?? boxes[0] ?? null
|
||||
if (target) {
|
||||
centerOnBox(target)
|
||||
} else {
|
||||
setViewport((prev) => ({ ...prev, x: 0, y: 0 }))
|
||||
}
|
||||
},
|
||||
addBox: handleAdd,
|
||||
deleteSelected: handleDelete,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
hasSelection: Boolean(selectedBoxId),
|
||||
})
|
||||
}, [
|
||||
boxes,
|
||||
centerOnBox,
|
||||
handleAdd,
|
||||
handleDelete,
|
||||
onControlsChange,
|
||||
selectedBoxId,
|
||||
])
|
||||
|
||||
const handleDrag = useCallback(
|
||||
(id: string, start: CanvasRect, dx: number, dy: number) => {
|
||||
const nextRect = normaliseRect({
|
||||
...start,
|
||||
x: start.x + dx / viewport.scale,
|
||||
y: start.y + dy / viewport.scale,
|
||||
})
|
||||
updateBoxRect(id, () => nextRect)
|
||||
return nextRect
|
||||
},
|
||||
[updateBoxRect, viewport.scale]
|
||||
)
|
||||
|
||||
const handleResize = useCallback(
|
||||
(
|
||||
id: string,
|
||||
start: CanvasRect,
|
||||
handle: ResizeHandle,
|
||||
dx: number,
|
||||
dy: number
|
||||
) => {
|
||||
const nextRect = calculateResizedRect(
|
||||
handle,
|
||||
start,
|
||||
dx / viewport.scale,
|
||||
dy / viewport.scale,
|
||||
{
|
||||
minWidth: CANVAS_CONFIG.MIN_WIDTH,
|
||||
minHeight: CANVAS_CONFIG.MIN_HEIGHT,
|
||||
maxWidth: CANVAS_CONFIG.MAX_PIXEL_WIDTH,
|
||||
maxHeight: CANVAS_CONFIG.MAX_PIXEL_HEIGHT,
|
||||
}
|
||||
)
|
||||
updateBoxRect(id, () => nextRect)
|
||||
return nextRect
|
||||
},
|
||||
[updateBoxRect, viewport.scale]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative h-full w-full overflow-hidden bg-white transition-colors duration-300 dark:bg-neutral-950"
|
||||
onPointerDown={handleCanvasPointerDown}
|
||||
onPointerMove={handleCanvasPointerMove}
|
||||
onPointerUp={handleCanvasPointerEnd}
|
||||
onPointerLeave={handleCanvasPointerEnd}
|
||||
onPointerCancel={handleCanvasPointerEnd}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0"
|
||||
style={{
|
||||
transform: `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.scale})`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
>
|
||||
{boxes.map((box, index) => (
|
||||
<CanvasBox
|
||||
key={box.id}
|
||||
box={box}
|
||||
index={index}
|
||||
isSelected={box.id === selectedBoxId}
|
||||
defaultStatusMessage={statusMessage}
|
||||
isGenerating={generatingBoxIds.includes(box.id)}
|
||||
onSelect={() => setSelectedBoxId(box.id)}
|
||||
onDrag={handleDrag}
|
||||
onResize={handleResize}
|
||||
onInteractionStart={() => {
|
||||
const element = canvasRef.current
|
||||
const panState = panStateRef.current
|
||||
if (panState && element?.releasePointerCapture) {
|
||||
try {
|
||||
element.releasePointerCapture(panState.pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
panStateRef.current = null
|
||||
}}
|
||||
onInteractionEnd={(_, rect) => {
|
||||
if (onRectCommit) {
|
||||
onRectCommit(box.id, rect)
|
||||
}
|
||||
}}
|
||||
contextMenuOpen={contextMenuBoxId === box.id}
|
||||
onOpenContextMenu={handleContextMenuOpen}
|
||||
onCloseContextMenu={handleContextMenuClose}
|
||||
onDeleteBox={() => handleDelete(box.id)}
|
||||
onRenameBox={(newName) => {
|
||||
updateBoxData(box.id, (current) => ({
|
||||
...current,
|
||||
name: newName,
|
||||
}))
|
||||
}}
|
||||
onBranchFrom={() => {
|
||||
if (onBranchFrom) {
|
||||
onBranchFrom(box)
|
||||
}
|
||||
}}
|
||||
onEditBox={() => onEditBox?.(box)}
|
||||
layoutActive={editingBoxId === box.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{boxes.length === 0 ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm text-white/60">
|
||||
No boxes yet. Use the toolbar or prompt to add one.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CanvasBoxProps = {
|
||||
box: CanvasBox
|
||||
index: number
|
||||
isSelected: boolean
|
||||
defaultStatusMessage: string
|
||||
isGenerating: boolean
|
||||
onSelect: () => void
|
||||
onDrag: (id: string, start: CanvasRect, dx: number, dy: number) => CanvasRect
|
||||
onResize: (
|
||||
id: string,
|
||||
start: CanvasRect,
|
||||
handle: ResizeHandle,
|
||||
dx: number,
|
||||
dy: number
|
||||
) => CanvasRect
|
||||
onInteractionStart: () => void
|
||||
onInteractionEnd?: (type: "move" | "resize", rect: CanvasRect) => void
|
||||
contextMenuOpen: boolean
|
||||
onOpenContextMenu: (boxId: string) => void
|
||||
onCloseContextMenu: () => void
|
||||
onDeleteBox: () => void
|
||||
onRenameBox: (name: string) => void
|
||||
onBranchFrom: () => void
|
||||
onEditBox?: () => void
|
||||
layoutActive?: boolean
|
||||
}
|
||||
|
||||
function CanvasBox({
|
||||
box,
|
||||
index,
|
||||
isSelected,
|
||||
defaultStatusMessage,
|
||||
isGenerating,
|
||||
onSelect,
|
||||
onDrag,
|
||||
onResize,
|
||||
onInteractionStart,
|
||||
onInteractionEnd,
|
||||
contextMenuOpen,
|
||||
onOpenContextMenu,
|
||||
onCloseContextMenu,
|
||||
onDeleteBox,
|
||||
onRenameBox,
|
||||
onBranchFrom,
|
||||
onEditBox,
|
||||
layoutActive = false,
|
||||
}: CanvasBoxProps) {
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const pointerStateRef = useRef<{
|
||||
type: "move" | "resize"
|
||||
startPointer: { x: number; y: number }
|
||||
startRect: CanvasRect
|
||||
handle?: ResizeHandle
|
||||
latestRect?: CanvasRect
|
||||
} | null>(null)
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
onSelect()
|
||||
onInteractionStart()
|
||||
onCloseContextMenu()
|
||||
pointerStateRef.current = {
|
||||
type: "move",
|
||||
startPointer: { x: event.clientX, y: event.clientY },
|
||||
startRect: { ...box.rect },
|
||||
latestRect: { ...box.rect },
|
||||
}
|
||||
const pointerId = event.pointerId
|
||||
const target = event.currentTarget
|
||||
if (target.setPointerCapture) {
|
||||
try {
|
||||
target.setPointerCapture(pointerId)
|
||||
} catch {
|
||||
// ignore capture errors
|
||||
}
|
||||
}
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const ctx = pointerStateRef.current
|
||||
if (!ctx || ctx.type !== "move") {
|
||||
return
|
||||
}
|
||||
const nextRect = onDrag(
|
||||
box.id,
|
||||
ctx.startRect,
|
||||
ev.clientX - ctx.startPointer.x,
|
||||
ev.clientY - ctx.startPointer.y
|
||||
)
|
||||
ctx.latestRect = nextRect
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
const ctx = pointerStateRef.current
|
||||
pointerStateRef.current = null
|
||||
window.removeEventListener("pointermove", onMove)
|
||||
window.removeEventListener("pointerup", finish)
|
||||
window.removeEventListener("pointercancel", finish)
|
||||
if (target.releasePointerCapture) {
|
||||
try {
|
||||
target.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (ctx && ctx.type === "move") {
|
||||
onInteractionEnd?.("move", ctx.latestRect ?? ctx.startRect)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onMove)
|
||||
window.addEventListener("pointerup", finish, { once: true })
|
||||
window.addEventListener("pointercancel", finish, { once: true })
|
||||
},
|
||||
[box.id, box.rect, onCloseContextMenu, onDrag, onInteractionEnd, onInteractionStart, onSelect]
|
||||
)
|
||||
|
||||
const startResize = useCallback(
|
||||
(handle: ResizeHandle, event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
event.stopPropagation()
|
||||
onSelect()
|
||||
onInteractionStart()
|
||||
onCloseContextMenu()
|
||||
pointerStateRef.current = {
|
||||
type: "resize",
|
||||
handle,
|
||||
startPointer: { x: event.clientX, y: event.clientY },
|
||||
startRect: { ...box.rect },
|
||||
latestRect: { ...box.rect },
|
||||
}
|
||||
const pointerId = event.pointerId
|
||||
const target = event.currentTarget
|
||||
if (target.setPointerCapture) {
|
||||
try {
|
||||
target.setPointerCapture(pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const ctx = pointerStateRef.current
|
||||
if (!ctx || ctx.type !== "resize" || !ctx.handle) {
|
||||
return
|
||||
}
|
||||
const nextRect = onResize(
|
||||
box.id,
|
||||
ctx.startRect,
|
||||
ctx.handle,
|
||||
ev.clientX - ctx.startPointer.x,
|
||||
ev.clientY - ctx.startPointer.y
|
||||
)
|
||||
ctx.latestRect = nextRect
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
const ctx = pointerStateRef.current
|
||||
pointerStateRef.current = null
|
||||
window.removeEventListener("pointermove", onMove)
|
||||
window.removeEventListener("pointerup", finish)
|
||||
window.removeEventListener("pointercancel", finish)
|
||||
if (target.releasePointerCapture) {
|
||||
try {
|
||||
target.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (ctx && ctx.type === "resize") {
|
||||
onInteractionEnd?.("resize", ctx.latestRect ?? ctx.startRect)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onMove)
|
||||
window.addEventListener("pointerup", finish, { once: true })
|
||||
window.addEventListener("pointercancel", finish, { once: true })
|
||||
},
|
||||
[box.rect, box.id, onCloseContextMenu, onInteractionEnd, onInteractionStart, onResize, onSelect]
|
||||
)
|
||||
|
||||
const showOutline = isSelected || isHovering
|
||||
const statusText = box.description ?? box.prompt ?? defaultStatusMessage
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-canvas-box="true"
|
||||
className="absolute"
|
||||
style={{
|
||||
width: box.rect.width,
|
||||
height: box.rect.height,
|
||||
left: box.rect.x,
|
||||
top: box.rect.y,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
onOpenContextMenu(box.id)
|
||||
}}
|
||||
onDoubleClick={() => onEditBox?.()}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 22 }}
|
||||
>
|
||||
<div
|
||||
className={`relative w-full transition-all duration-300 h-full border canvas-box ${
|
||||
showOutline
|
||||
? "border-indigo-400 shadow-[0_0_0_1px_rgba(99,102,241,0.3)]"
|
||||
: "border-slate-200 dark:border-neutral-800"
|
||||
} bg-white text-slate-900 dark:bg-neutral-900/70 dark:text-white`}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{contextMenuOpen ? (
|
||||
<motion.div
|
||||
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20 }}
|
||||
className="absolute -top-12 left-1/2 z-30 flex -translate-x-1/2 flex-col gap-1 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-slate-900 shadow-lg dark:border-white/10 dark:bg-neutral-950/95 dark:text-white"
|
||||
onPointerDown={(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
console.log("branching from", box)
|
||||
onBranchFrom()
|
||||
onCloseContextMenu()
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-slate-900 transition hover:bg-slate-100 dark:text-white/80 dark:hover:bg-white/10"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
Branch From
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-px bg-white/10" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onEditBox?.()
|
||||
onCloseContextMenu()
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-slate-900 transition hover:bg-slate-100 dark:text-white/80 dark:hover:bg-white/10"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextName = window.prompt("Rename box", box.name)
|
||||
const trimmed = nextName?.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
onRenameBox(trimmed)
|
||||
onCloseContextMenu()
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-slate-900 transition hover:bg-slate-100 dark:text-white/80 dark:hover:bg-white/10"
|
||||
>
|
||||
<Type className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDeleteBox()
|
||||
onCloseContextMenu()
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-red-500 transition hover:bg-red-50 dark:text-red-200 dark:hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
className={`absolute left-3 top-3 flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium uppercase tracking-wide transition-all duration-300 ${
|
||||
isSelected
|
||||
? "bg-indigo-500/90 text-white"
|
||||
: "bg-black/50 text-white/70"
|
||||
}`}
|
||||
>
|
||||
{box.branchParentId ? <GitBranch className="h-3 w-3" /> : null}
|
||||
<span>{box.name || `Box ${index + 1}`}</span>
|
||||
</div>
|
||||
{box.imageUrl ? (
|
||||
layoutActive ? (
|
||||
<motion.img
|
||||
layoutId={`box-image-${box.id}`}
|
||||
src={box.imageUrl}
|
||||
alt={box.name}
|
||||
className="w-full h-full object-cover pointer-events-none"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={box.imageUrl}
|
||||
alt={box.name}
|
||||
className="w-full h-full object-cover pointer-events-none"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-2 text-center px-4 text-white/70 text-sm">
|
||||
{statusText}
|
||||
{isGenerating ? (
|
||||
<div className="h-8 w-8 rounded-full border-2 border-white/20 border-t-white animate-spin" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOutline ? (
|
||||
<div className="absolute bottom-2 right-3 text-[11px] text-white/80">
|
||||
{Math.round(box.rect.width)}×{Math.round(box.rect.height)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showOutline ? (
|
||||
<>
|
||||
<EdgeHandle
|
||||
position="top"
|
||||
onPointerDown={(event) => startResize("n", event)}
|
||||
/>
|
||||
<EdgeHandle
|
||||
position="bottom"
|
||||
onPointerDown={(event) => startResize("s", event)}
|
||||
/>
|
||||
<EdgeHandle
|
||||
position="left"
|
||||
onPointerDown={(event) => startResize("w", event)}
|
||||
/>
|
||||
<EdgeHandle
|
||||
position="right"
|
||||
onPointerDown={(event) => startResize("e", event)}
|
||||
/>
|
||||
<CornerHandle
|
||||
position="top-left"
|
||||
onPointerDown={(event) => startResize("nw", event)}
|
||||
/>
|
||||
<CornerHandle
|
||||
position="top-right"
|
||||
onPointerDown={(event) => startResize("ne", event)}
|
||||
/>
|
||||
<CornerHandle
|
||||
position="bottom-left"
|
||||
onPointerDown={(event) => startResize("sw", event)}
|
||||
/>
|
||||
<CornerHandle
|
||||
position="bottom-right"
|
||||
onPointerDown={(event) => startResize("se", event)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{isGenerating ? <div className="absolute inset-0 bg-black/30" /> : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
type ResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se"
|
||||
|
||||
type EdgeHandleProps = {
|
||||
position: "top" | "bottom" | "left" | "right"
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
function EdgeHandle({ position, onPointerDown }: EdgeHandleProps) {
|
||||
const isVertical = position === "top" || position === "bottom"
|
||||
const cursor = isVertical ? "ns-resize" : "ew-resize"
|
||||
const translateClass =
|
||||
position === "top"
|
||||
? "-translate-y-1/2"
|
||||
: position === "bottom"
|
||||
? "translate-y-1/2"
|
||||
: position === "left"
|
||||
? "-translate-x-1/2"
|
||||
: "translate-x-1/2"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
onPointerDown={onPointerDown}
|
||||
className={`absolute ${translateClass} ${
|
||||
isVertical ? "left-0 right-0 h-3" : "top-0 bottom-0 w-3"
|
||||
} bg-transparent`}
|
||||
style={{ cursor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type CornerHandleProps = {
|
||||
position: "top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
function CornerHandle({ position, onPointerDown }: CornerHandleProps) {
|
||||
const cursor =
|
||||
position === "top-left" || position === "bottom-right"
|
||||
? "nwse-resize"
|
||||
: "nesw-resize"
|
||||
|
||||
const className =
|
||||
position === "top-left"
|
||||
? "-top-1 -left-1"
|
||||
: position === "top-right"
|
||||
? "-top-1 -right-1"
|
||||
: position === "bottom-left"
|
||||
? "-bottom-1 -left-1"
|
||||
: "-bottom-1 -right-1"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
onPointerDown={onPointerDown}
|
||||
className={`absolute h-[10px] w-[10px] bg-indigo-400 ${className}`}
|
||||
style={{ cursor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function calculateResizedRect(
|
||||
handle: ResizeHandle,
|
||||
startRect: CanvasRect,
|
||||
dx: number,
|
||||
dy: number,
|
||||
limits: {
|
||||
minWidth: number
|
||||
minHeight: number
|
||||
maxWidth: number
|
||||
maxHeight: number
|
||||
}
|
||||
): CanvasRect {
|
||||
let x = startRect.x
|
||||
let y = startRect.y
|
||||
let width = startRect.width
|
||||
let height = startRect.height
|
||||
|
||||
if (handle.includes("e")) {
|
||||
width = clamp(width + dx, limits.minWidth, limits.maxWidth)
|
||||
}
|
||||
if (handle.includes("w")) {
|
||||
const updatedWidth = clamp(width - dx, limits.minWidth, limits.maxWidth)
|
||||
const delta = width - updatedWidth
|
||||
width = updatedWidth
|
||||
x += delta
|
||||
}
|
||||
if (handle.includes("s")) {
|
||||
height = clamp(height + dy, limits.minHeight, limits.maxHeight)
|
||||
}
|
||||
if (handle.includes("n")) {
|
||||
const updatedHeight = clamp(height - dy, limits.minHeight, limits.maxHeight)
|
||||
const delta = height - updatedHeight
|
||||
height = updatedHeight
|
||||
y += delta
|
||||
}
|
||||
|
||||
return normaliseRect({ x, y, width, height })
|
||||
}
|
||||
118
packages/web/src/features/canvas/components/Models.tsx
Normal file
118
packages/web/src/features/canvas/components/Models.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { useCanvasStore } from "../store/canvasStore"
|
||||
|
||||
export type ModelId = "gemini" | "dall-e-3" | "nano-banana"
|
||||
|
||||
export const MODEL_OPTIONS: Array<{
|
||||
id: ModelId
|
||||
label: string
|
||||
description: string
|
||||
disabled?: boolean
|
||||
badge?: string
|
||||
}> = [
|
||||
{
|
||||
id: "gemini",
|
||||
label: "Gemini 2.5 Flash Image Preview",
|
||||
description:
|
||||
"Google's multimodal model for high-quality image + text generations.",
|
||||
},
|
||||
{
|
||||
id: "dall-e-3",
|
||||
label: "DALL·E 3",
|
||||
description:
|
||||
"OpenAI's flagship model for photorealistic, stylistic image generation.",
|
||||
},
|
||||
{
|
||||
id: "nano-banana",
|
||||
label: "Nano Banana",
|
||||
description:
|
||||
"Fast experimental model for playful concepts and draft visuals.",
|
||||
disabled: true,
|
||||
badge: "Coming soon",
|
||||
},
|
||||
]
|
||||
|
||||
export default function Models({
|
||||
onClose,
|
||||
onSelectModel,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSelectModel?: (modelId: ModelId) => void
|
||||
}) {
|
||||
const { boxes, selectedBoxId, updateBoxData } = useCanvasStore()
|
||||
const active = useMemo(
|
||||
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
|
||||
[boxes, selectedBoxId]
|
||||
)
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<div className="text-sm text-white/60">
|
||||
Select a box to choose its generation model.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-white">Choose a model</div>
|
||||
<div className="space-y-2">
|
||||
{MODEL_OPTIONS.map((option) => {
|
||||
const isActive = option.id === active.model
|
||||
const disabled = option.disabled
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
if (option.id !== active.model) {
|
||||
if (onSelectModel) {
|
||||
onSelectModel(option.id)
|
||||
} else {
|
||||
updateBoxData(active.id, (box) => ({
|
||||
...box,
|
||||
model: option.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-white/10 bg-white/5 text-white/40"
|
||||
: isActive
|
||||
? "border-indigo-500 bg-indigo-500/20 text-white"
|
||||
: "border-white/10 bg-white/5 text-white/80 hover:border-white/20 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
<div className="text-xs text-white/60">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.badge ? (
|
||||
<span className="rounded bg-white/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white/70">
|
||||
{option.badge}
|
||||
</span>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<CheckIcon className="h-4 w-4 text-white" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
packages/web/src/features/canvas/components/Onboarding.tsx
Normal file
257
packages/web/src/features/canvas/components/Onboarding.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
ArrowRight,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
} from "lucide-react"
|
||||
|
||||
import { useCanvasStore } from "../store/canvasStore"
|
||||
|
||||
type OnboardingStep =
|
||||
| "welcome"
|
||||
| "add-box"
|
||||
| "select-box"
|
||||
| "enter-prompt"
|
||||
| "generate-image"
|
||||
| "resize-box"
|
||||
| "complete"
|
||||
|
||||
export default function Onboarding() {
|
||||
const { onboardingStep, setOnboardingStep, completeOnboarding } =
|
||||
useCanvasStore()
|
||||
|
||||
const steps: OnboardingStep[] = useMemo(
|
||||
() => [
|
||||
"welcome",
|
||||
"add-box",
|
||||
"select-box",
|
||||
"enter-prompt",
|
||||
"generate-image",
|
||||
"resize-box",
|
||||
"complete",
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const getStepConfig = (step: OnboardingStep) => {
|
||||
switch (step) {
|
||||
case "welcome":
|
||||
return {
|
||||
title: "Welcome to Image Generation!",
|
||||
description:
|
||||
"Let's walk through how to create amazing images with AI. This will only take a few minutes.",
|
||||
position: { x: 50, y: 50 },
|
||||
showArrow: false,
|
||||
}
|
||||
|
||||
case "add-box":
|
||||
return {
|
||||
title: "Step 1: Add a Canvas Box",
|
||||
description:
|
||||
"First, let's add a canvas box where your image will be generated. Click the '+' button in the toolbar.",
|
||||
position: { x: 50, y: 200 },
|
||||
showArrow: true,
|
||||
arrowDirection: "down" as const,
|
||||
highlightElement: "[data-toolbar-add]",
|
||||
}
|
||||
|
||||
case "select-box":
|
||||
return {
|
||||
title: "Step 2: Select the Box",
|
||||
description:
|
||||
"Click on the box you just created to select it. You'll see it highlighted with a blue border.",
|
||||
position: { x: 300, y: 200 },
|
||||
showArrow: true,
|
||||
arrowDirection: "right" as const,
|
||||
highlightElement: ".canvas-box",
|
||||
}
|
||||
|
||||
case "enter-prompt":
|
||||
return {
|
||||
title: "Step 3: Enter Your Prompt",
|
||||
description:
|
||||
"Type your image description in the prompt box at the bottom. Be creative and descriptive!",
|
||||
position: { x: 50, y: 400 },
|
||||
showArrow: true,
|
||||
arrowDirection: "down" as const,
|
||||
highlightElement: "[data-prompt-input]",
|
||||
}
|
||||
|
||||
case "generate-image":
|
||||
return {
|
||||
title: "Step 4: Generate Your Image",
|
||||
description:
|
||||
"Click the generate button to create your AI image. This may take a few moments.",
|
||||
position: { x: 200, y: 400 },
|
||||
showArrow: true,
|
||||
arrowDirection: "right" as const,
|
||||
highlightElement: "[data-generate-button]",
|
||||
}
|
||||
|
||||
case "resize-box":
|
||||
return {
|
||||
title: "Step 5: Resize Your Box",
|
||||
description:
|
||||
"Drag the corner handles to resize your box. You can also move it around the canvas.",
|
||||
position: { x: 400, y: 200 },
|
||||
showArrow: true,
|
||||
arrowDirection: "left" as const,
|
||||
highlightElement: ".canvas-box",
|
||||
}
|
||||
|
||||
case "complete":
|
||||
return {
|
||||
title: "You're All Set!",
|
||||
description:
|
||||
"You've learned the basics! You can now create, customize, and generate images. Have fun creating!",
|
||||
position: { x: 50, y: 50 },
|
||||
showArrow: false,
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (!onboardingStep) return
|
||||
|
||||
const currentIndex = steps.indexOf(onboardingStep)
|
||||
const nextIndex = currentIndex + 1
|
||||
|
||||
if (nextIndex >= steps.length) {
|
||||
completeOnboarding()
|
||||
} else {
|
||||
setOnboardingStep(steps[nextIndex])
|
||||
}
|
||||
}, [onboardingStep, steps, setOnboardingStep, completeOnboarding])
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (!onboardingStep) return
|
||||
|
||||
const currentIndex = steps.indexOf(onboardingStep)
|
||||
const prevIndex = currentIndex - 1
|
||||
|
||||
if (prevIndex >= 0) {
|
||||
setOnboardingStep(steps[prevIndex])
|
||||
}
|
||||
}, [onboardingStep, steps, setOnboardingStep])
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
completeOnboarding()
|
||||
}, [completeOnboarding])
|
||||
|
||||
const stepConfig = onboardingStep ? getStepConfig(onboardingStep) : null
|
||||
const canGoBack = onboardingStep !== "welcome"
|
||||
const isLastStep = onboardingStep === "complete"
|
||||
|
||||
if (!onboardingStep || !stepConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0 w-full h-full z-[110] pointer-events-auto">
|
||||
{/* Backdrop overlay */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
{/* Highlight overlay for specific elements */}
|
||||
{stepConfig.highlightElement && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onboarding content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
layoutId="onboarding-content"
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="absolute bg-white/95 backdrop-blur-sm rounded-xl shadow-2xl border border-white/20 p-6 max-w-sm"
|
||||
style={{
|
||||
left: stepConfig.position.x,
|
||||
top: stepConfig.position.y,
|
||||
}}
|
||||
>
|
||||
{/* Arrow pointing to element */}
|
||||
{stepConfig.showArrow && stepConfig.arrowDirection && (
|
||||
<div className="absolute text-indigo-500">
|
||||
{(stepConfig.arrowDirection as string) === "up" && (
|
||||
<ArrowUp className="w-6 h-6 -top-8 left-1/2 -translate-x-1/2" />
|
||||
)}
|
||||
{stepConfig.arrowDirection === "down" && (
|
||||
<ArrowDown className="w-6 h-6 -bottom-8 left-1/2 -translate-x-1/2" />
|
||||
)}
|
||||
{stepConfig.arrowDirection === "left" && (
|
||||
<ArrowLeft className="w-6 h-6 -left-8 top-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
{stepConfig.arrowDirection === "right" && (
|
||||
<ArrowRight className="w-6 h-6 -right-8 top-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{stepConfig.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{stepConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="ml-4 p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex space-x-1">
|
||||
{steps.map((step, _index) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`h-1 flex-1 rounded ${
|
||||
onboardingStep === step ? "bg-indigo-500" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex space-x-2">
|
||||
{canGoBack && (
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
className="flex items-center space-x-1 px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="flex items-center space-x-1 px-4 py-2 bg-indigo-500 text-white text-sm font-medium rounded-lg hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
<span>{isLastStep ? "Finish" : "Next"}</span>
|
||||
{!isLastStep && <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
packages/web/src/features/canvas/components/Overlay.tsx
Normal file
157
packages/web/src/features/canvas/components/Overlay.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { ImageIcon, PlusIcon, Trash2Icon } from "lucide-react"
|
||||
import Prompt, { type PromptProps } from "./Prompt"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
import { useCanvasStore } from "../store/canvasStore"
|
||||
import type { ModelId } from "./Models"
|
||||
|
||||
type TokenBalance = {
|
||||
tokens: number
|
||||
premiumTokens: number
|
||||
}
|
||||
|
||||
type OverlayProps = PromptProps & {
|
||||
onAddBox?: () => void
|
||||
onDeleteSelected?: () => void
|
||||
onSetBackground?: () => void
|
||||
onSelectStyle?: (styleId: string) => void
|
||||
onSelectModel?: (modelId: ModelId) => void
|
||||
contextLabel?: string | null
|
||||
tokenBalance: TokenBalance
|
||||
tokenCost: number
|
||||
}
|
||||
|
||||
export default function Overlay({
|
||||
onAddBox,
|
||||
onDeleteSelected,
|
||||
onSetBackground,
|
||||
onSelectStyle,
|
||||
onSelectModel,
|
||||
contextLabel,
|
||||
tokenBalance,
|
||||
tokenCost,
|
||||
...promptProps
|
||||
}: OverlayProps) {
|
||||
const [hoveredTool, setHoveredTool] = useState<string | null>(null)
|
||||
const { boxes, selectedBoxId, onboardingStep } = useCanvasStore()
|
||||
|
||||
const activeBox = useMemo(
|
||||
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
|
||||
[boxes, selectedBoxId]
|
||||
)
|
||||
const activeBoxName = contextLabel ?? activeBox?.name ?? null
|
||||
const activeStyleId = activeBox?.styleId ?? "default"
|
||||
|
||||
const tools = [
|
||||
{
|
||||
icon: <PlusIcon size={18} strokeWidth={2.9} />,
|
||||
label: "Add",
|
||||
description: "Add a new content box",
|
||||
onClick: () => onAddBox?.(),
|
||||
},
|
||||
{
|
||||
icon: <Trash2Icon size={18} strokeWidth={2} />,
|
||||
label: "Delete",
|
||||
description: "Delete the selected box",
|
||||
onClick: () => onDeleteSelected?.(),
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon size={18} strokeWidth={2} />,
|
||||
label: "Background",
|
||||
description: "Set the canvas background",
|
||||
onClick: () => onSetBackground?.(),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-full z-[110] pointer-events-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-3">
|
||||
<TokenBadge label="Tokens" value={tokenBalance.tokens} />
|
||||
<TokenBadge label="Premium" value={tokenBalance.premiumTokens} variant="premium" />
|
||||
</div>
|
||||
<Prompt
|
||||
{...promptProps}
|
||||
activeBoxName={activeBoxName}
|
||||
activeModelId={activeBox?.model ?? "gemini"}
|
||||
activeStyleId={activeStyleId}
|
||||
onSelectStyle={onSelectStyle}
|
||||
onSelectModel={onSelectModel}
|
||||
tokenCost={tokenCost}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -100, scale: 0 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: -100, scale: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
className={`absolute top-[50%] left-0 translate-y-[-50%] p-[10px] ${
|
||||
onboardingStep === "add-box" ? "z-[120]" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-[6px] rounded-2xl border border-slate-200 bg-white p-[6px] shadow-sm dark:border-white/10 dark:bg-neutral-900">
|
||||
{tools.map((tool) => (
|
||||
<div
|
||||
key={tool.label}
|
||||
data-toolbar-add={tool.label === "Add" ? "true" : undefined}
|
||||
className="flex relative cursor-pointer items-center justify-center rounded-xl p-[10px] hover:bg-slate-100 dark:hover:bg-neutral-800"
|
||||
onClick={tool.onClick}
|
||||
onMouseEnter={() => setHoveredTool(tool.label)}
|
||||
onMouseLeave={() => setHoveredTool(null)}
|
||||
>
|
||||
{tool.icon}
|
||||
{/* Tooltip with Framer Motion */}
|
||||
<AnimatePresence>
|
||||
{hoveredTool === tool.label && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10, scale: 0.8 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: -10, scale: 0.8 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
duration: 0.2,
|
||||
}}
|
||||
className="absolute left-full top-1/2 z-50 ml-3 -translate-y-1/2 whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] text-white shadow-lg dark:bg-neutral-900"
|
||||
>
|
||||
{tool.description}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TokenBadge({
|
||||
label,
|
||||
value,
|
||||
variant = "default",
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
variant?: "default" | "premium"
|
||||
}) {
|
||||
const isPremium = variant === "premium"
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border px-3 py-2 text-left ${
|
||||
isPremium
|
||||
? "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-200/40 dark:bg-amber-400/10 dark:text-amber-50"
|
||||
: "border-slate-200 bg-white text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-500 dark:text-white/70">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-base font-semibold">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
packages/web/src/features/canvas/components/Prompt.tsx
Normal file
251
packages/web/src/features/canvas/components/Prompt.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { motion, useAnimate } from "framer-motion"
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import Models, { MODEL_OPTIONS, type ModelId } from "./Models"
|
||||
import Styles from "./Styles"
|
||||
import { STYLE_PRESETS } from "../styles-presets"
|
||||
import { useCanvasStore } from "../store/canvasStore"
|
||||
|
||||
export type PromptProps = {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
onSubmit: (value: string) => Promise<boolean> | boolean
|
||||
isGenerating: boolean
|
||||
error?: string | null
|
||||
activeBoxName?: string | null
|
||||
activeModelId?: "gemini" | "dall-e-3" | "nano-banana" | null
|
||||
activeStyleId?: string | null
|
||||
onSelectStyle?: (styleId: string) => void
|
||||
onSelectModel?: (modelId: ModelId) => void
|
||||
contextLabel?: string | null
|
||||
tokenCost?: number
|
||||
}
|
||||
|
||||
export default function Prompt({
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
isGenerating,
|
||||
error,
|
||||
activeBoxName,
|
||||
activeModelId = "gemini",
|
||||
activeStyleId = "default",
|
||||
onSelectStyle,
|
||||
onSelectModel,
|
||||
contextLabel,
|
||||
tokenCost = 1,
|
||||
}: PromptProps) {
|
||||
const [currentCase, setCurrentCase] = useState<number>(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { onboardingStep } = useCanvasStore()
|
||||
|
||||
const autoResize = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
|
||||
textarea.style.height = "auto"
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 250)}px`
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
autoResize()
|
||||
}, [autoResize, value])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (isGenerating) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
await onSubmit(trimmed)
|
||||
}, [isGenerating, onSubmit, onValueChange, value])
|
||||
|
||||
const activeModelLabel = MODEL_OPTIONS.find(
|
||||
(option) => option.id === activeModelId
|
||||
)?.label
|
||||
const activeStyle = STYLE_PRESETS.find(
|
||||
(preset) => preset.id === activeStyleId
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-0 left-[50%] translate-x-[-50%] flex justify-center items-center p-[10px] w-full max-w-[520px] ${
|
||||
onboardingStep === "enter-prompt" || onboardingStep === "generate-image"
|
||||
? "z-[120]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<PromptWrapper currentCase={currentCase} setCurrentCase={setCurrentCase}>
|
||||
{(() => {
|
||||
switch (currentCase) {
|
||||
case 0:
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{activeBoxName || contextLabel ? (
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-white/60">
|
||||
<span className="uppercase tracking-wide">
|
||||
Prompting {contextLabel ?? activeBoxName}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeStyle && activeStyle.id !== "default" ? (
|
||||
<span className="rounded bg-slate-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-600 dark:bg-white/10 dark:text-white/70">
|
||||
{activeStyle.label}
|
||||
</span>
|
||||
) : null}
|
||||
{activeModelLabel ? (
|
||||
<span className="rounded bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-white/10 dark:text-white">
|
||||
{activeModelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
data-prompt-input="true"
|
||||
data-generate-button="true"
|
||||
className="h-[100px] min-h-[60px] max-h-[250px] w-full resize-none overflow-y-auto bg-transparent text-slate-900 outline-none placeholder:text-slate-400 dark:text-white dark:placeholder:text-white/50"
|
||||
placeholder="Describe the image you want to see"
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
onInput={autoResize}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSubmit()
|
||||
}
|
||||
}}
|
||||
aria-label="Image prompt"
|
||||
aria-busy={isGenerating}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500 dark:text-white/40">
|
||||
<span>
|
||||
Press Enter to generate • Shift+Enter for a new line
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-slate-600 dark:text-white/60">
|
||||
Cost: {tokenCost} premium token
|
||||
{tokenCost !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{isGenerating ? (
|
||||
<span className="text-primary-300">Generating…</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="text-xs text-red-400">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
case 1:
|
||||
return (
|
||||
<Models
|
||||
onClose={() => setCurrentCase(0)}
|
||||
onSelectModel={onSelectModel}
|
||||
/>
|
||||
)
|
||||
case 2:
|
||||
return onSelectStyle ? (
|
||||
<Styles
|
||||
onClose={() => setCurrentCase(0)}
|
||||
onSelectStyle={onSelectStyle}
|
||||
activeStyleId={activeStyleId ?? "default"}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-slate-600 dark:text-white/60">
|
||||
Style selection unavailable.
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return <div className="h-[50px]" />
|
||||
}
|
||||
})()}
|
||||
</PromptWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptWrapper({
|
||||
children,
|
||||
currentCase,
|
||||
setCurrentCase,
|
||||
}: {
|
||||
children: React.ReactNode | (() => React.ReactNode)
|
||||
currentCase: number
|
||||
setCurrentCase: (index: number) => void
|
||||
}) {
|
||||
const constraintsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const tools = ["Prompt", "Models", "Styles"]
|
||||
|
||||
const [scope, animate] = useAnimate()
|
||||
|
||||
useEffect(() => {
|
||||
void animate(
|
||||
`#case-${currentCase}`,
|
||||
{ opacity: [0, 1] },
|
||||
{ duration: 0.5, delay: 0.5 }
|
||||
)
|
||||
}, [animate, currentCase])
|
||||
|
||||
return (
|
||||
<div ref={scope} className="w-full">
|
||||
<div
|
||||
ref={constraintsRef}
|
||||
className=" flex justify-center w-full relative items-center"
|
||||
>
|
||||
{/* <div className="absolute z-10 left-[50%] translate-x-[-50%] top-[-30px] w-[90%] h-full ">
|
||||
<div className="glass-card w-full h-full rounded-2xl">
|
||||
<div className="w-full h-[24px] group flex items-center justify-center ">
|
||||
<div className="w-[40px] h-[5px] bg-neutral-700/40 group-hover:bg-neutral-700/70 transition-all duration-300 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
<motion.div
|
||||
layoutId="container"
|
||||
initial={{
|
||||
scale: 0.6,
|
||||
y: 100,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
y: 0,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
className="relative z-20 w-full rounded-2xl bg-white p-0 text-slate-900 shadow-xl dark:bg-neutral-900 dark:text-white"
|
||||
>
|
||||
<div
|
||||
id={`case-${currentCase}`}
|
||||
className="relative flex flex-col justify-between p-4 "
|
||||
>
|
||||
{typeof children === "function"
|
||||
? (children as () => React.ReactNode)()
|
||||
: children}
|
||||
</div>
|
||||
<div className="flex absolute top-[-26px] left-0 z-[-10] scrollbar-hide text-slate-600 gap-0.5 text-xs dark:text-white/80">
|
||||
{tools.map((tool, index) => (
|
||||
<Fragment key={tool}>
|
||||
<div
|
||||
onClick={() => setCurrentCase(index)}
|
||||
className={`cursor-pointer rounded-t-xl px-4 py-1 ${
|
||||
index === currentCase
|
||||
? "h-[50px] bg-white text-slate-900 shadow transition-all duration-300 dark:bg-neutral-900 dark:text-white"
|
||||
: "h-[40px] bg-slate-100 text-slate-500 opacity-70 dark:bg-neutral-900/70 dark:text-white/70"
|
||||
}`}
|
||||
>
|
||||
{tool}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
packages/web/src/features/canvas/components/Styles.tsx
Normal file
69
packages/web/src/features/canvas/components/Styles.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { useCanvasStore } from "../store/canvasStore"
|
||||
import { STYLE_PRESETS } from "../styles-presets"
|
||||
|
||||
export default function Styles({
|
||||
onClose,
|
||||
onSelectStyle,
|
||||
activeStyleId,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSelectStyle: (styleId: string) => void
|
||||
activeStyleId: string
|
||||
}) {
|
||||
const { boxes, selectedBoxId } = useCanvasStore()
|
||||
const active = useMemo(
|
||||
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
|
||||
[boxes, selectedBoxId]
|
||||
)
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<div className="text-sm text-white/60">
|
||||
Select a box to choose a style.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-white">Choose a style</div>
|
||||
<div className="space-y-2">
|
||||
{STYLE_PRESETS.map((preset) => {
|
||||
const currentId = activeStyleId ?? active.styleId ?? "default"
|
||||
const isActive = currentId === preset.id
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectStyle(preset.id)
|
||||
onClose()
|
||||
}}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||
isActive
|
||||
? "border-indigo-500 bg-indigo-500/20 text-white"
|
||||
: "border-white/10 bg-white/5 text-white/80 hover:border-white/20 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{preset.label}</div>
|
||||
<div className="text-xs text-white/60">
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
{isActive ? <CheckIcon className="h-4 w-4 text-white" /> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-white/50">
|
||||
{preset.prompt}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Undo2, Redo2 } from "lucide-react"
|
||||
|
||||
interface CanvasToolbarProps {
|
||||
onUndo?: () => void
|
||||
onRedo?: () => void
|
||||
onReset?: () => void
|
||||
canUndo?: boolean
|
||||
canRedo?: boolean
|
||||
}
|
||||
|
||||
export default function UndoRedoToolbar({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onReset,
|
||||
canUndo = false,
|
||||
canRedo = false,
|
||||
}: CanvasToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg">
|
||||
<button
|
||||
onClick={onUndo}
|
||||
color=""
|
||||
disabled={!canUndo}
|
||||
className="text-white p-2 hover:bg-neutral-700 h-[40px] flex items-center justify-center min-w-[40px] bg-neutral-800/50 rounded-lg"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
className="text-white p-2 hover:bg-neutral-700 h-[40px] flex items-center justify-center min-w-[40px] bg-neutral-800/50 rounded-lg"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-white p-2 hover:bg-neutral-700 h-[40px] flex items-center justify-center min-w-[40px] bg-neutral-800/50 rounded-lg"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
packages/web/src/features/canvas/config.ts
Normal file
39
packages/web/src/features/canvas/config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Canvas configuration constants
|
||||
export const CANVAS_CONFIG = {
|
||||
// Initial sizing
|
||||
INITIAL_SIZE: 254,
|
||||
MIN_WIDTH: 254,
|
||||
MIN_HEIGHT: 254,
|
||||
MAX_PIXEL_WIDTH: 1000,
|
||||
MAX_PIXEL_HEIGHT: 1000,
|
||||
|
||||
// Visual styling
|
||||
HANDLE_COLOR: "#6366F1", // indigo-500
|
||||
OUTLINE_COLOR: "rgba(99,102,241,0.7)", // indigo-500 with opacity
|
||||
|
||||
// Handle sizing
|
||||
HANDLE_SIZE: 8,
|
||||
EDGE_HANDLE_THICKNESS: 2,
|
||||
|
||||
// Animation settings
|
||||
ANIMATION_DURATION: 0.2,
|
||||
SPRING_STIFFNESS: 200,
|
||||
SPRING_DAMPING: 22,
|
||||
|
||||
// History settings
|
||||
MAX_HISTORY_SIZE: 50,
|
||||
} as const
|
||||
|
||||
export type CanvasRect = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type CanvasItem = {
|
||||
id: string
|
||||
rect: CanvasRect
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
}
|
||||
367
packages/web/src/features/canvas/store/canvasStore.tsx
Normal file
367
packages/web/src/features/canvas/store/canvasStore.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from "react"
|
||||
|
||||
import type { CanvasRect } from "../config"
|
||||
|
||||
type CanvasBox = {
|
||||
id: string
|
||||
name: string
|
||||
prompt: string
|
||||
rect: CanvasRect
|
||||
imageUrl?: string
|
||||
description?: string
|
||||
model: "gemini" | "dall-e-3" | "nano-banana"
|
||||
styleId?: string
|
||||
branchParentId?: string | null
|
||||
}
|
||||
|
||||
type OnboardingStep =
|
||||
| "welcome"
|
||||
| "add-box"
|
||||
| "select-box"
|
||||
| "enter-prompt"
|
||||
| "generate-image"
|
||||
| "resize-box"
|
||||
| "complete"
|
||||
|
||||
type CanvasStoreValue = {
|
||||
boxes: CanvasBox[]
|
||||
selectedBoxId: string | null
|
||||
setBoxes: (next: CanvasBox[] | ((prev: CanvasBox[]) => CanvasBox[])) => void
|
||||
addBox: (
|
||||
box?: Partial<Omit<CanvasBox, "rect">> & {
|
||||
rect?: Partial<CanvasRect>
|
||||
},
|
||||
options?: {
|
||||
select?: boolean
|
||||
}
|
||||
) => CanvasBox | null
|
||||
updateBoxRect: (id: string, updater: (rect: CanvasRect) => CanvasRect) => void
|
||||
updateBoxData: (id: string, updater: (box: CanvasBox) => CanvasBox) => void
|
||||
deleteBox: (id: string) => void
|
||||
setSelectedBoxId: (id: string | null) => void
|
||||
reset: (
|
||||
boxes: Array<
|
||||
Partial<Omit<CanvasBox, "rect">> & {
|
||||
name?: string
|
||||
rect?: Partial<CanvasRect>
|
||||
imageUrl?: string
|
||||
description?: string
|
||||
model?: "gemini" | "dall-e-3" | "nano-banana"
|
||||
styleId?: string
|
||||
}
|
||||
>
|
||||
) => void
|
||||
// Onboarding state
|
||||
onboardingStep: OnboardingStep | null
|
||||
setOnboardingStep: (step: OnboardingStep | null) => void
|
||||
startOnboarding: () => void
|
||||
completeOnboarding: () => void
|
||||
}
|
||||
|
||||
const CanvasStoreContext = createContext<CanvasStoreValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const defaultRect: CanvasRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 256,
|
||||
height: 256,
|
||||
}
|
||||
|
||||
const BOX_GAP = 24
|
||||
const BRANCH_VERTICAL_GAP = 32
|
||||
|
||||
let idCounter = 0
|
||||
const newId = () => `canvas-box-${++idCounter}`
|
||||
|
||||
const normaliseRect = (rect: CanvasRect): CanvasRect => ({
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
})
|
||||
|
||||
type CreateBoxInput = {
|
||||
id?: string
|
||||
name: string
|
||||
prompt?: string
|
||||
rect?: CanvasRect
|
||||
imageUrl?: string
|
||||
description?: string
|
||||
model?: "gemini" | "dall-e-3" | "nano-banana"
|
||||
styleId?: string
|
||||
branchParentId?: string | null
|
||||
}
|
||||
|
||||
const createBox = ({
|
||||
id,
|
||||
name,
|
||||
prompt = "",
|
||||
rect = defaultRect,
|
||||
imageUrl,
|
||||
description,
|
||||
model = "gemini",
|
||||
styleId = "default",
|
||||
branchParentId,
|
||||
}: CreateBoxInput): CanvasBox => ({
|
||||
id: id ?? newId(),
|
||||
name,
|
||||
prompt,
|
||||
rect: normaliseRect(rect),
|
||||
imageUrl,
|
||||
description,
|
||||
model,
|
||||
styleId,
|
||||
branchParentId: branchParentId ?? null,
|
||||
})
|
||||
|
||||
export function CanvasProvider({ children }: PropsWithChildren) {
|
||||
const [boxes, setBoxesState] = useState<CanvasBox[]>([])
|
||||
const [selectedBoxId, setSelectedBoxId] = useState<string | null>(null)
|
||||
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const setBoxes = useCallback(
|
||||
(next: CanvasBox[] | ((prev: CanvasBox[]) => CanvasBox[])) => {
|
||||
setBoxesState((prev) => {
|
||||
const resolved =
|
||||
typeof next === "function"
|
||||
? (next as (p: CanvasBox[]) => CanvasBox[])(prev)
|
||||
: next
|
||||
return resolved.map((box) => ({
|
||||
...box,
|
||||
rect: normaliseRect(box.rect),
|
||||
model: box.model ?? "gemini",
|
||||
styleId: box.styleId ?? "default",
|
||||
branchParentId: box.branchParentId ?? null,
|
||||
}))
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const addBox = useCallback(
|
||||
(
|
||||
overrides?: Partial<Omit<CanvasBox, "id" | "name" | "rect">> & {
|
||||
rect?: Partial<CanvasRect>
|
||||
},
|
||||
options?: {
|
||||
select?: boolean
|
||||
}
|
||||
) => {
|
||||
let created: CanvasBox | null = null
|
||||
setBoxesState((prev) => {
|
||||
const name = overrides?.name ?? `Box ${prev.length + 1}`
|
||||
const last = prev[prev.length - 1] ?? null
|
||||
const branchParent = overrides?.branchParentId
|
||||
? prev.find((box) => box.id === overrides.branchParentId)
|
||||
: null
|
||||
const branchSiblingCount = branchParent
|
||||
? prev.filter((box) => box.branchParentId === branchParent.id).length
|
||||
: 0
|
||||
const rect: CanvasRect = {
|
||||
x:
|
||||
overrides?.rect?.x ??
|
||||
(branchParent
|
||||
? branchParent.rect.x
|
||||
: last
|
||||
? last.rect.x + last.rect.width + BOX_GAP
|
||||
: 0),
|
||||
y:
|
||||
overrides?.rect?.y ??
|
||||
(branchParent
|
||||
? branchParent.rect.y +
|
||||
branchParent.rect.height +
|
||||
BRANCH_VERTICAL_GAP +
|
||||
branchSiblingCount *
|
||||
(branchParent.rect.height + BRANCH_VERTICAL_GAP)
|
||||
: last
|
||||
? last.rect.y
|
||||
: 0),
|
||||
width:
|
||||
overrides?.rect?.width ??
|
||||
(branchParent ? branchParent.rect.width : defaultRect.width),
|
||||
height:
|
||||
overrides?.rect?.height ??
|
||||
(branchParent ? branchParent.rect.height : defaultRect.height),
|
||||
}
|
||||
created = createBox({
|
||||
id: overrides?.id,
|
||||
name,
|
||||
prompt: overrides?.prompt ?? "",
|
||||
rect,
|
||||
imageUrl: overrides?.imageUrl,
|
||||
description: overrides?.description,
|
||||
model: overrides?.model ?? "gemini",
|
||||
styleId: overrides?.styleId ?? "default",
|
||||
branchParentId: overrides?.branchParentId ?? null,
|
||||
})
|
||||
return [...prev, created]
|
||||
})
|
||||
const shouldSelect = options?.select ?? true
|
||||
if (created && shouldSelect) {
|
||||
setSelectedBoxId(created.id)
|
||||
}
|
||||
return created
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateBoxRect = useCallback(
|
||||
(id: string, updater: (rect: CanvasRect) => CanvasRect) => {
|
||||
setBoxesState((prev) =>
|
||||
prev.map((box) =>
|
||||
box.id === id
|
||||
? {
|
||||
...box,
|
||||
rect: normaliseRect(updater(box.rect)),
|
||||
}
|
||||
: box
|
||||
)
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateBoxData = useCallback(
|
||||
(id: string, updater: (box: CanvasBox) => CanvasBox) => {
|
||||
setBoxesState((prev) =>
|
||||
prev.map((box) => {
|
||||
if (box.id !== id) {
|
||||
return box
|
||||
}
|
||||
const updated = updater(box)
|
||||
return {
|
||||
...updated,
|
||||
model: updated.model ?? "gemini",
|
||||
rect: normaliseRect(updated.rect),
|
||||
styleId: updated.styleId ?? box.styleId ?? "default",
|
||||
branchParentId:
|
||||
updated.branchParentId ?? box.branchParentId ?? null,
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const deleteBox = useCallback((id: string) => {
|
||||
setBoxesState((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
return prev
|
||||
}
|
||||
const next = prev.filter((box) => box.id !== id)
|
||||
if (next.length > 0) {
|
||||
setSelectedBoxId(next[next.length - 1]?.id ?? null)
|
||||
} else {
|
||||
setSelectedBoxId(null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(
|
||||
(
|
||||
initial: Array<
|
||||
Partial<Omit<CanvasBox, "id" | "name" | "rect">> & {
|
||||
name?: string
|
||||
rect?: Partial<CanvasRect>
|
||||
imageUrl?: string
|
||||
}
|
||||
>
|
||||
) => {
|
||||
let counter = 0
|
||||
let cursorX = 0
|
||||
const next = initial.map((item) => {
|
||||
counter += 1
|
||||
const rect: CanvasRect = {
|
||||
x: item.rect?.x ?? cursorX,
|
||||
y: item.rect?.y ?? 0,
|
||||
width: item.rect?.width ?? defaultRect.width,
|
||||
height: item.rect?.height ?? defaultRect.height,
|
||||
}
|
||||
const box = createBox({
|
||||
id: item.id,
|
||||
name: item.name ?? `Box ${counter}`,
|
||||
prompt: item.prompt ?? "",
|
||||
rect,
|
||||
imageUrl: item.imageUrl,
|
||||
description: item.description,
|
||||
model: item.model ?? "gemini",
|
||||
styleId: item.styleId ?? "default",
|
||||
branchParentId: item.branchParentId ?? null,
|
||||
})
|
||||
cursorX = box.rect.x + box.rect.width + BOX_GAP
|
||||
return box
|
||||
})
|
||||
setBoxes(next)
|
||||
setSelectedBoxId(next[0]?.id ?? null)
|
||||
},
|
||||
[setBoxes]
|
||||
)
|
||||
|
||||
const startOnboarding = useCallback(() => {
|
||||
setOnboardingStep("welcome")
|
||||
}, [])
|
||||
|
||||
const completeOnboarding = useCallback(() => {
|
||||
setOnboardingStep(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<CanvasStoreValue>(
|
||||
() => ({
|
||||
boxes,
|
||||
selectedBoxId,
|
||||
setBoxes,
|
||||
addBox,
|
||||
updateBoxRect,
|
||||
updateBoxData,
|
||||
deleteBox,
|
||||
setSelectedBoxId,
|
||||
reset,
|
||||
onboardingStep,
|
||||
setOnboardingStep,
|
||||
startOnboarding,
|
||||
completeOnboarding,
|
||||
}),
|
||||
[
|
||||
boxes,
|
||||
selectedBoxId,
|
||||
setBoxes,
|
||||
addBox,
|
||||
updateBoxRect,
|
||||
updateBoxData,
|
||||
deleteBox,
|
||||
reset,
|
||||
onboardingStep,
|
||||
setOnboardingStep,
|
||||
startOnboarding,
|
||||
completeOnboarding,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<CanvasStoreContext.Provider value={value}>
|
||||
{children}
|
||||
</CanvasStoreContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useCanvasStore(): CanvasStoreValue {
|
||||
const ctx = useContext(CanvasStoreContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useCanvasStore must be used within a CanvasProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export type { CanvasBox }
|
||||
39
packages/web/src/features/canvas/styles-presets.ts
Normal file
39
packages/web/src/features/canvas/styles-presets.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type StylePreset = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export const STYLE_PRESETS: StylePreset[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Natural",
|
||||
description: "Balanced, unstyled rendering",
|
||||
prompt: "Render the scene with natural lighting and realistic tones.",
|
||||
},
|
||||
{
|
||||
id: "cinematic",
|
||||
label: "Cinematic",
|
||||
description: "High-contrast, filmic look",
|
||||
prompt: "Cinematic lighting, dramatic contrast, 35mm film aesthetic, rich color grading.",
|
||||
},
|
||||
{
|
||||
id: "watercolor",
|
||||
label: "Watercolor",
|
||||
description: "Soft painterly textures",
|
||||
prompt: "Watercolor illustration, soft brush strokes, flowing pigment, textured paper background.",
|
||||
},
|
||||
{
|
||||
id: "anime",
|
||||
label: "Anime",
|
||||
description: "Vibrant anime style",
|
||||
prompt: "Anime illustration, clean line art, vibrant cel shading, dynamic background, Studio Ghibli inspired.",
|
||||
},
|
||||
{
|
||||
id: "noir",
|
||||
label: "Noir",
|
||||
description: "Moody black-and-white",
|
||||
prompt: "Film noir photography, high contrast black and white, dramatic shadows, moody atmosphere.",
|
||||
},
|
||||
]
|
||||
73
packages/web/src/hooks/useGuestUsage.ts
Normal file
73
packages/web/src/hooks/useGuestUsage.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
|
||||
const GUEST_USAGE_KEY = "gen_guest_usage"
|
||||
const GUEST_FREE_LIMIT = 5
|
||||
|
||||
type GuestUsage = {
|
||||
count: number
|
||||
lastReset: string // ISO date string
|
||||
}
|
||||
|
||||
function getStoredUsage(): GuestUsage {
|
||||
if (typeof window === "undefined") {
|
||||
return { count: 0, lastReset: new Date().toISOString() }
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(GUEST_USAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as GuestUsage
|
||||
}
|
||||
} catch {
|
||||
// Invalid data, reset
|
||||
}
|
||||
|
||||
return { count: 0, lastReset: new Date().toISOString() }
|
||||
}
|
||||
|
||||
function setStoredUsage(usage: GuestUsage): void {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
localStorage.setItem(GUEST_USAGE_KEY, JSON.stringify(usage))
|
||||
} catch {
|
||||
// localStorage might be full or disabled
|
||||
}
|
||||
}
|
||||
|
||||
export function useGuestUsage() {
|
||||
const [usage, setUsage] = useState<GuestUsage>(getStoredUsage)
|
||||
|
||||
useEffect(() => {
|
||||
setUsage(getStoredUsage())
|
||||
}, [])
|
||||
|
||||
const remaining = Math.max(0, GUEST_FREE_LIMIT - usage.count)
|
||||
const canUse = remaining > 0
|
||||
|
||||
const incrementUsage = useCallback(() => {
|
||||
setUsage((prev) => {
|
||||
const newUsage = {
|
||||
count: prev.count + 1,
|
||||
lastReset: prev.lastReset,
|
||||
}
|
||||
setStoredUsage(newUsage)
|
||||
return newUsage
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetUsage = useCallback(() => {
|
||||
const newUsage = { count: 0, lastReset: new Date().toISOString() }
|
||||
setStoredUsage(newUsage)
|
||||
setUsage(newUsage)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
used: usage.count,
|
||||
remaining,
|
||||
limit: GUEST_FREE_LIMIT,
|
||||
canUse,
|
||||
incrementUsage,
|
||||
resetUsage,
|
||||
}
|
||||
}
|
||||
104
packages/web/src/lib/ai/gemini-image.ts
Normal file
104
packages/web/src/lib/ai/gemini-image.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
||||
export const DEFAULT_GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image-preview"
|
||||
|
||||
type GeminiEnv = {
|
||||
GEMINI_API_KEY?: string
|
||||
GOOGLE_API_KEY?: string
|
||||
}
|
||||
|
||||
const getEnv = (): GeminiEnv => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: GeminiEnv } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env) {
|
||||
const env = ctx.cloudflare.env
|
||||
if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) {
|
||||
return {
|
||||
GEMINI_API_KEY: env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY,
|
||||
GOOGLE_API_KEY: env.GOOGLE_API_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore, running outside Cloudflare
|
||||
}
|
||||
|
||||
const key = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY
|
||||
return { GEMINI_API_KEY: key, GOOGLE_API_KEY: process.env.GOOGLE_API_KEY }
|
||||
}
|
||||
|
||||
export type GeminiImageRequest = {
|
||||
prompt: string
|
||||
model?: string
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
export type GeminiImageResponse = {
|
||||
base64Image: string
|
||||
mimeType: string
|
||||
rawResponse: unknown
|
||||
}
|
||||
|
||||
export async function generateGeminiImage(
|
||||
params: GeminiImageRequest,
|
||||
): Promise<GeminiImageResponse> {
|
||||
const { GEMINI_API_KEY } = getEnv()
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
throw new Error(
|
||||
"Set GEMINI_API_KEY or GOOGLE_API_KEY to enable Gemini image generation.",
|
||||
)
|
||||
}
|
||||
|
||||
const model = params.model ?? DEFAULT_GEMINI_IMAGE_MODEL
|
||||
|
||||
const body = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: params.prompt }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: params.temperature ?? 0.9,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch(`${GEMINI_API_BASE}/models/${model}:generateContent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-goog-api-key": GEMINI_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const json = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof json?.error?.message === "string"
|
||||
? json.error.message
|
||||
: "Gemini image generation failed"
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const candidates = Array.isArray(json?.candidates) ? json.candidates : []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parts = candidate?.content?.parts ?? []
|
||||
for (const part of parts) {
|
||||
if (part?.inlineData?.data) {
|
||||
return {
|
||||
base64Image: part.inlineData.data,
|
||||
mimeType: part.inlineData.mimeType ?? "image/png",
|
||||
rawResponse: json,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Gemini did not return inline image data.")
|
||||
}
|
||||
77
packages/web/src/lib/ai/openai-image.ts
Normal file
77
packages/web/src/lib/ai/openai-image.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
const OPENAI_API_URL = "https://api.openai.com/v1/images/generations"
|
||||
const DEFAULT_OPENAI_MODEL = "gpt-image-1"
|
||||
|
||||
type OpenAIEnv = {
|
||||
OPENAI_API_KEY?: string
|
||||
}
|
||||
|
||||
const getEnv = (): OpenAIEnv => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: OpenAIEnv } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env?.OPENAI_API_KEY) {
|
||||
return { OPENAI_API_KEY: ctx.cloudflare.env.OPENAI_API_KEY }
|
||||
}
|
||||
} catch {
|
||||
// ignore — not running in server context
|
||||
}
|
||||
return { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
|
||||
}
|
||||
|
||||
export type OpenAIImageRequest = {
|
||||
prompt: string
|
||||
model?: string
|
||||
size?: "1024x1024" | "1024x1792" | "1792x1024"
|
||||
}
|
||||
|
||||
export type OpenAIImageResponse = {
|
||||
base64Image: string
|
||||
mimeType: string
|
||||
revisedPrompt?: string
|
||||
}
|
||||
|
||||
export async function generateOpenAIImage(
|
||||
params: OpenAIImageRequest,
|
||||
): Promise<OpenAIImageResponse> {
|
||||
const { OPENAI_API_KEY } = getEnv()
|
||||
if (!OPENAI_API_KEY) {
|
||||
throw new Error("Set OPENAI_API_KEY to enable DALL·E image generation.")
|
||||
}
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model ?? DEFAULT_OPENAI_MODEL,
|
||||
prompt: params.prompt,
|
||||
size: params.size ?? "1024x1024",
|
||||
response_format: "b64_json",
|
||||
}),
|
||||
})
|
||||
|
||||
const json: any = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof json?.error?.message === "string"
|
||||
? json.error.message
|
||||
: "OpenAI image generation failed"
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const payload = Array.isArray(json?.data) ? json.data[0] : undefined
|
||||
const base64 = typeof payload?.b64_json === "string" ? payload.b64_json : null
|
||||
if (!base64) {
|
||||
throw new Error("OpenAI returned no image data")
|
||||
}
|
||||
|
||||
return {
|
||||
base64Image: base64,
|
||||
mimeType: "image/png",
|
||||
revisedPrompt: typeof payload?.revised_prompt === "string" ? payload.revised_prompt : undefined,
|
||||
}
|
||||
}
|
||||
39
packages/web/src/lib/ai/provider.ts
Normal file
39
packages/web/src/lib/ai/provider.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
|
||||
// Get API key from Cloudflare env or process.env
|
||||
const getApiKey = (): string | undefined => {
|
||||
// Try Cloudflare Workers context first
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server")
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env?.OPENROUTER_API_KEY) {
|
||||
return ctx.cloudflare.env.OPENROUTER_API_KEY as string
|
||||
}
|
||||
} catch {
|
||||
// Not in Cloudflare context
|
||||
}
|
||||
return process.env.OPENROUTER_API_KEY
|
||||
}
|
||||
|
||||
const getModel = (): string => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server")
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env?.OPENROUTER_MODEL) {
|
||||
return ctx.cloudflare.env.OPENROUTER_MODEL as string
|
||||
}
|
||||
} catch {
|
||||
// Not in Cloudflare context
|
||||
}
|
||||
return process.env.OPENROUTER_MODEL ?? "google/gemini-2.0-flash-001"
|
||||
}
|
||||
|
||||
export const getOpenRouter = () => {
|
||||
const apiKey = getApiKey()
|
||||
if (!apiKey) {
|
||||
return null
|
||||
}
|
||||
return createOpenRouter({ apiKey })
|
||||
}
|
||||
|
||||
export const getDefaultModel = () => getModel()
|
||||
7
packages/web/src/lib/auth-client.ts
Normal file
7
packages/web/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react"
|
||||
import { emailOTPClient } from "better-auth/client/plugins"
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: typeof window !== "undefined" ? window.location.origin : undefined,
|
||||
plugins: [emailOTPClient()],
|
||||
})
|
||||
145
packages/web/src/lib/auth.ts
Normal file
145
packages/web/src/lib/auth.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { betterAuth } from "better-auth"
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start"
|
||||
import { emailOTP } from "better-auth/plugins"
|
||||
import { Resend } from "resend"
|
||||
import { authDb } from "@/db/connection"
|
||||
import * as schema from "@/db/schema"
|
||||
|
||||
type AuthEnv = {
|
||||
BETTER_AUTH_SECRET: string
|
||||
APP_BASE_URL?: string
|
||||
RESEND_API_KEY?: string
|
||||
RESEND_FROM_EMAIL?: string
|
||||
}
|
||||
|
||||
// Helper to get Cloudflare env from server context
|
||||
const getCloudflareEnv = (): Partial<AuthEnv> | undefined => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Partial<AuthEnv> } } | null
|
||||
}
|
||||
return getServerContext()?.cloudflare?.env
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Get env from Cloudflare context or process.env
|
||||
const getEnv = (): AuthEnv => {
|
||||
let BETTER_AUTH_SECRET: string | undefined
|
||||
let APP_BASE_URL: string | undefined
|
||||
let RESEND_API_KEY: string | undefined
|
||||
let RESEND_FROM_EMAIL: string | undefined
|
||||
|
||||
// Try Cloudflare Workers context first (production)
|
||||
const cfEnv = getCloudflareEnv()
|
||||
if (cfEnv) {
|
||||
BETTER_AUTH_SECRET = cfEnv.BETTER_AUTH_SECRET
|
||||
APP_BASE_URL = cfEnv.APP_BASE_URL
|
||||
RESEND_API_KEY = cfEnv.RESEND_API_KEY
|
||||
RESEND_FROM_EMAIL = cfEnv.RESEND_FROM_EMAIL
|
||||
}
|
||||
|
||||
// Fall back to process.env (local dev)
|
||||
BETTER_AUTH_SECRET = BETTER_AUTH_SECRET ?? process.env.BETTER_AUTH_SECRET
|
||||
APP_BASE_URL = APP_BASE_URL ?? process.env.APP_BASE_URL
|
||||
RESEND_API_KEY = RESEND_API_KEY ?? process.env.RESEND_API_KEY
|
||||
RESEND_FROM_EMAIL = RESEND_FROM_EMAIL ?? process.env.RESEND_FROM_EMAIL
|
||||
|
||||
if (!BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET is not configured")
|
||||
}
|
||||
|
||||
return {
|
||||
BETTER_AUTH_SECRET,
|
||||
APP_BASE_URL,
|
||||
RESEND_API_KEY,
|
||||
RESEND_FROM_EMAIL,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAuth = () => {
|
||||
// Note: We create a fresh auth instance per request because Cloudflare Workers
|
||||
// doesn't allow sharing I/O objects (like DB connections) across requests
|
||||
const env = getEnv()
|
||||
const database = authDb()
|
||||
|
||||
// Detect production: if APP_BASE_URL is set and not localhost, we're in production
|
||||
const isProduction =
|
||||
env.APP_BASE_URL && !env.APP_BASE_URL.includes("localhost")
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
const fromEmail = env.RESEND_FROM_EMAIL ?? "noreply@example.com"
|
||||
|
||||
console.log("[auth] Config:", {
|
||||
isProduction,
|
||||
hasResendKey: !!env.RESEND_API_KEY,
|
||||
fromEmail,
|
||||
appBaseUrl: env.APP_BASE_URL,
|
||||
})
|
||||
|
||||
return betterAuth({
|
||||
database: drizzleAdapter(database, {
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
schema,
|
||||
}),
|
||||
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:3000"],
|
||||
plugins: [
|
||||
tanstackStartCookies(),
|
||||
emailOTP({
|
||||
async sendVerificationOTP({ email, otp }) {
|
||||
console.log("[auth] sendVerificationOTP called:", {
|
||||
email,
|
||||
isProduction,
|
||||
hasResend: !!resend,
|
||||
})
|
||||
|
||||
if (!isProduction || !resend) {
|
||||
// In dev mode or if Resend not configured, log OTP to terminal
|
||||
console.log("\n" + "=".repeat(50))
|
||||
console.log(`🔐 OTP CODE for ${email}`)
|
||||
console.log(` Code: ${otp}`)
|
||||
console.log("=".repeat(50) + "\n")
|
||||
return
|
||||
}
|
||||
|
||||
// Send email via Resend in production
|
||||
console.log("[auth] Sending email via Resend to:", email)
|
||||
const { error, data } = await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
subject: "Your Linsa verification code",
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 400px; margin: 0 auto; padding: 20px; background-color: #050505; color: #ffffff;">
|
||||
<h2 style="color: #ffffff; margin-bottom: 16px; font-weight: 600;">Your verification code</h2>
|
||||
<p style="color: #a1a1aa; margin-bottom: 24px;">Enter this code to sign in to Linsa:</p>
|
||||
<div style="background-color: #18181b; border: 1px solid #27272a; border-radius: 12px; padding: 24px; text-align: center;">
|
||||
<span style="font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #ffffff; font-family: monospace;">${otp}</span>
|
||||
</div>
|
||||
<p style="color: #71717a; font-size: 14px; margin-top: 24px;">This code expires in 5 minutes.</p>
|
||||
<p style="color: #52525b; font-size: 12px; margin-top: 16px;">If you didn't request this code, you can safely ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error("[auth] Failed to send OTP email:", error)
|
||||
throw new Error("Failed to send verification email")
|
||||
}
|
||||
|
||||
console.log("[auth] Email sent successfully:", data)
|
||||
},
|
||||
otpLength: 6,
|
||||
expiresIn: 300, // 5 minutes
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Lazy proxy that calls getAuth() on each access
|
||||
export const auth = new Proxy({} as ReturnType<typeof betterAuth>, {
|
||||
get(_target, prop) {
|
||||
return getAuth()[prop as keyof ReturnType<typeof betterAuth>]
|
||||
},
|
||||
})
|
||||
109
packages/web/src/lib/billing-helpers.ts
Normal file
109
packages/web/src/lib/billing-helpers.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { BillingWithChecks, Price, UsageMeter, Product } from "@flowglad/server"
|
||||
|
||||
/**
|
||||
* Computes the total usage credits for a given usage meter slug from the current subscription's feature items.
|
||||
*/
|
||||
export function computeUsageTotal(
|
||||
usageMeterSlug: string,
|
||||
currentSubscription:
|
||||
| NonNullable<NonNullable<BillingWithChecks["currentSubscriptions"]>[number]>
|
||||
| undefined,
|
||||
pricingModel: BillingWithChecks["pricingModel"] | undefined,
|
||||
): number {
|
||||
try {
|
||||
if (!currentSubscription || !pricingModel?.usageMeters) return 0
|
||||
|
||||
const experimental = currentSubscription.experimental as
|
||||
| { featureItems?: Array<{ type: string; usageMeterId: string; amount: number }> }
|
||||
| undefined
|
||||
const featureItems = experimental?.featureItems ?? []
|
||||
|
||||
if (featureItems.length === 0) return 0
|
||||
|
||||
// Build lookup map: usageMeterId -> slug
|
||||
const usageMeterById: Record<string, string> = {}
|
||||
for (const meter of pricingModel.usageMeters) {
|
||||
usageMeterById[String(meter.id)] = String(meter.slug)
|
||||
}
|
||||
|
||||
// Sum up usage credits for matching meter
|
||||
let total = 0
|
||||
for (const item of featureItems) {
|
||||
if (item.type !== "usage_credit_grant") continue
|
||||
const meterSlug = usageMeterById[item.usageMeterId]
|
||||
if (meterSlug === usageMeterSlug) {
|
||||
total += item.amount
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a usage meter by its slug from the pricing model.
|
||||
*/
|
||||
export function findUsageMeterBySlug(
|
||||
usageMeterSlug: string,
|
||||
pricingModel: BillingWithChecks["pricingModel"] | undefined,
|
||||
): { id: string; slug: string } | null {
|
||||
if (!pricingModel?.usageMeters) return null
|
||||
|
||||
const usageMeter = pricingModel.usageMeters.find(
|
||||
(meter: UsageMeter) => meter.slug === usageMeterSlug,
|
||||
)
|
||||
|
||||
if (!usageMeter) return null
|
||||
|
||||
return {
|
||||
id: String(usageMeter.id),
|
||||
slug: String(usageMeter.slug),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a usage price by its associated usage meter slug from the pricing model.
|
||||
*/
|
||||
export function findUsagePriceByMeterSlug(
|
||||
usageMeterSlug: string,
|
||||
pricingModel: BillingWithChecks["pricingModel"] | undefined,
|
||||
): Price | null {
|
||||
if (!pricingModel?.products || !pricingModel?.usageMeters) return null
|
||||
|
||||
// Build lookup map: slug -> id
|
||||
const meterIdBySlug = new Map(
|
||||
pricingModel.usageMeters.map((meter: UsageMeter) => [meter.slug, meter.id]),
|
||||
)
|
||||
|
||||
const usageMeterId = meterIdBySlug.get(usageMeterSlug)
|
||||
if (!usageMeterId) return null
|
||||
|
||||
// Find price by meter ID
|
||||
const usagePrice = pricingModel.products
|
||||
.flatMap((product: Product) => product.prices ?? [])
|
||||
.find(
|
||||
(price: Price) => price.type === "usage" && price.usageMeterId === usageMeterId,
|
||||
)
|
||||
|
||||
return usagePrice ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a plan is a default (free) plan by looking up the price by slug.
|
||||
*/
|
||||
export function isDefaultPlanBySlug(
|
||||
pricingModel: BillingWithChecks["pricingModel"] | null | undefined,
|
||||
priceSlug: string | undefined,
|
||||
): boolean {
|
||||
if (!pricingModel?.products || !priceSlug) return false
|
||||
|
||||
for (const product of pricingModel.products) {
|
||||
const price = product.prices?.find((p: Price) => p.slug === priceSlug)
|
||||
if (price) {
|
||||
return product.default === true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
236
packages/web/src/lib/billing.ts
Normal file
236
packages/web/src/lib/billing.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { getFlowgladServer } from "./flowglad"
|
||||
import { getAuth } from "./auth"
|
||||
|
||||
// Usage limits
|
||||
const GUEST_FREE_REQUESTS = 5
|
||||
const AUTH_FREE_REQUESTS_DAILY = 20
|
||||
const PAID_PLAN_REQUESTS = 1000
|
||||
|
||||
// Usage meter slug (configure in Flowglad dashboard)
|
||||
export const AI_REQUESTS_METER = "ai_requests"
|
||||
|
||||
// Price slug for the pro plan (configure in Flowglad dashboard)
|
||||
export const PRO_PLAN_PRICE_SLUG = "pro_monthly"
|
||||
|
||||
type UsageCheckResult = {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
limit: number
|
||||
reason?: "guest_limit" | "daily_limit" | "subscription_limit" | "no_subscription"
|
||||
isGuest: boolean
|
||||
isPaid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can make an AI request based on their billing status.
|
||||
*
|
||||
* Tiers:
|
||||
* - Guest (no auth): 5 free requests total (stored in cookie/localStorage)
|
||||
* - Authenticated free: 20 free requests per day
|
||||
* - Pro plan ($7.99/mo): 1000 requests per billing period
|
||||
*/
|
||||
export async function checkUsageAllowed(request: Request): Promise<UsageCheckResult> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
// Guest user - check local/cookie based limit
|
||||
if (!session?.user) {
|
||||
// For guests, we'll track on client side via localStorage
|
||||
// Server just knows they're a guest with limited access
|
||||
return {
|
||||
allowed: true, // Client will enforce limit
|
||||
remaining: GUEST_FREE_REQUESTS,
|
||||
limit: GUEST_FREE_REQUESTS,
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated user - check Flowglad billing
|
||||
const flowglad = getFlowgladServer(request)
|
||||
|
||||
if (!flowglad) {
|
||||
// Flowglad not configured, fall back to daily free limit
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: AUTH_FREE_REQUESTS_DAILY,
|
||||
limit: AUTH_FREE_REQUESTS_DAILY,
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const billing = await flowglad.getBilling()
|
||||
|
||||
// Check if user has an active subscription
|
||||
const hasActiveSubscription = billing.currentSubscriptions &&
|
||||
billing.currentSubscriptions.length > 0
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
// Check usage balance for paid plan
|
||||
const usage = billing.checkUsageBalance(AI_REQUESTS_METER)
|
||||
|
||||
if (usage) {
|
||||
const remaining = usage.availableBalance
|
||||
return {
|
||||
allowed: remaining > 0,
|
||||
remaining,
|
||||
limit: PAID_PLAN_REQUESTS,
|
||||
reason: remaining <= 0 ? "subscription_limit" : undefined,
|
||||
isGuest: false,
|
||||
isPaid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Has subscription but no usage meter configured yet
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: PAID_PLAN_REQUESTS,
|
||||
limit: PAID_PLAN_REQUESTS,
|
||||
isGuest: false,
|
||||
isPaid: true,
|
||||
}
|
||||
}
|
||||
|
||||
// No subscription - use daily free limit
|
||||
// For now we allow without tracking (TODO: implement daily limit tracking)
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: AUTH_FREE_REQUESTS_DAILY,
|
||||
limit: AUTH_FREE_REQUESTS_DAILY,
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error checking usage:", error)
|
||||
// On error, allow with daily limit
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: AUTH_FREE_REQUESTS_DAILY,
|
||||
limit: AUTH_FREE_REQUESTS_DAILY,
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a usage event after AI request completes.
|
||||
* Only records for paid users with active subscriptions.
|
||||
*/
|
||||
export async function recordUsage(
|
||||
request: Request,
|
||||
amount: number = 1,
|
||||
transactionId?: string
|
||||
): Promise<void> {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
// Guest users don't record to Flowglad
|
||||
return
|
||||
}
|
||||
|
||||
const flowglad = getFlowgladServer(request)
|
||||
if (!flowglad) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const billing = await flowglad.getBilling()
|
||||
|
||||
const hasActiveSubscription = billing.currentSubscriptions &&
|
||||
billing.currentSubscriptions.length > 0
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
// Only record usage for paid subscriptions
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = billing.currentSubscriptions![0]
|
||||
|
||||
// Find the usage price for the AI requests meter
|
||||
const usagePrice = billing.pricingModel?.products
|
||||
?.flatMap(p => p.prices || [])
|
||||
?.find((p: { type?: string; usageMeterSlug?: string }) =>
|
||||
p.type === "usage" && p.usageMeterSlug === AI_REQUESTS_METER
|
||||
) as { id: string } | undefined
|
||||
|
||||
if (!usagePrice) {
|
||||
console.warn("[billing] No usage price found for meter:", AI_REQUESTS_METER)
|
||||
return
|
||||
}
|
||||
|
||||
await flowglad.createUsageEvent({
|
||||
subscriptionId: subscription.id,
|
||||
priceId: usagePrice.id,
|
||||
amount,
|
||||
transactionId: transactionId ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[billing] Error recording usage:", error)
|
||||
// Don't throw - usage recording should not block the request
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get billing summary for display in UI.
|
||||
*/
|
||||
export async function getBillingSummary(request: Request) {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
return {
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
freeLimit: GUEST_FREE_REQUESTS,
|
||||
planName: "Guest",
|
||||
}
|
||||
}
|
||||
|
||||
const flowglad = getFlowgladServer(request)
|
||||
if (!flowglad) {
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
freeLimit: AUTH_FREE_REQUESTS_DAILY,
|
||||
planName: "Free",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const billing = await flowglad.getBilling()
|
||||
|
||||
const hasActiveSubscription = billing.currentSubscriptions &&
|
||||
billing.currentSubscriptions.length > 0
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
const usage = billing.checkUsageBalance(AI_REQUESTS_METER)
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: true,
|
||||
remaining: usage?.availableBalance ?? PAID_PLAN_REQUESTS,
|
||||
limit: PAID_PLAN_REQUESTS,
|
||||
planName: "Pro",
|
||||
billingPortalUrl: billing.billingPortalUrl ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
freeLimit: AUTH_FREE_REQUESTS_DAILY,
|
||||
planName: "Free",
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Error getting summary:", error)
|
||||
return {
|
||||
isGuest: false,
|
||||
isPaid: false,
|
||||
freeLimit: AUTH_FREE_REQUESTS_DAILY,
|
||||
planName: "Free",
|
||||
}
|
||||
}
|
||||
}
|
||||
115
packages/web/src/lib/canvas/client.ts
Normal file
115
packages/web/src/lib/canvas/client.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
SerializedCanvas,
|
||||
SerializedCanvasImage,
|
||||
SerializedCanvasSummary,
|
||||
} from "./types"
|
||||
|
||||
const jsonHeaders = { "content-type": "application/json" }
|
||||
|
||||
const handleJson = async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(message || "Canvas request failed")
|
||||
}
|
||||
return (await response.json()) as any
|
||||
}
|
||||
|
||||
export const fetchCanvasSnapshot = async (
|
||||
canvasId: string,
|
||||
): Promise<SerializedCanvas> => {
|
||||
const res = await fetch(`/api/canvas/${canvasId}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
const data = await handleJson(res)
|
||||
return data as SerializedCanvas
|
||||
}
|
||||
|
||||
export const fetchCanvasList = async (): Promise<SerializedCanvasSummary[]> => {
|
||||
const res = await fetch("/api/canvas", { credentials: "include" })
|
||||
const data = await handleJson(res)
|
||||
return data.canvases as SerializedCanvasSummary[]
|
||||
}
|
||||
|
||||
export const createCanvasProject = async (params: {
|
||||
name?: string
|
||||
} = {}): Promise<SerializedCanvas> => {
|
||||
const res = await fetch("/api/canvas", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name: params.name }),
|
||||
})
|
||||
const data = await handleJson(res)
|
||||
return data as SerializedCanvas
|
||||
}
|
||||
|
||||
export const createCanvasBox = async (params: {
|
||||
canvasId: string
|
||||
name?: string
|
||||
prompt?: string
|
||||
position?: { x: number; y: number }
|
||||
size?: { width: number; height: number }
|
||||
modelId?: string
|
||||
styleId?: string
|
||||
branchParentId?: string | null
|
||||
}): Promise<SerializedCanvasImage> => {
|
||||
const res = await fetch("/api/canvas/images", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
const data = await handleJson(res)
|
||||
return data.image as SerializedCanvasImage
|
||||
}
|
||||
|
||||
export const updateCanvasBox = async (
|
||||
imageId: string,
|
||||
data: Partial<{
|
||||
name: string
|
||||
prompt: string
|
||||
modelId: string
|
||||
styleId: string
|
||||
position: { x: number; y: number }
|
||||
size: { width: number; height: number }
|
||||
rotation: number
|
||||
}>,
|
||||
): Promise<SerializedCanvasImage> => {
|
||||
const res = await fetch(`/api/canvas/images/${imageId}`, {
|
||||
method: "PATCH",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const json = await handleJson(res)
|
||||
return json.image as SerializedCanvasImage
|
||||
}
|
||||
|
||||
export const deleteCanvasBox = async (imageId: string) => {
|
||||
const res = await fetch(`/api/canvas/images/${imageId}`, {
|
||||
method: "DELETE",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
})
|
||||
await handleJson(res)
|
||||
}
|
||||
|
||||
export const generateCanvasBoxImage = async (params: {
|
||||
imageId: string
|
||||
prompt?: string
|
||||
modelId?: string
|
||||
temperature?: number
|
||||
}): Promise<SerializedCanvasImage> => {
|
||||
const res = await fetch(`/api/canvas/images/${params.imageId}/generate`, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
prompt: params.prompt,
|
||||
modelId: params.modelId,
|
||||
temperature: params.temperature,
|
||||
}),
|
||||
})
|
||||
const json = await handleJson(res)
|
||||
return json.image as SerializedCanvasImage
|
||||
}
|
||||
364
packages/web/src/lib/canvas/db.ts
Normal file
364
packages/web/src/lib/canvas/db.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { asc, desc, eq, inArray } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { canvas, canvas_images } from "@/db/schema"
|
||||
import type {
|
||||
CanvasPoint,
|
||||
CanvasSize,
|
||||
SerializedCanvas,
|
||||
SerializedCanvasImage,
|
||||
SerializedCanvasRecord,
|
||||
SerializedCanvasSummary,
|
||||
} from "./types"
|
||||
|
||||
const DEFAULT_POSITION: CanvasPoint = { x: 0, y: 0 }
|
||||
const DEFAULT_IMAGE_SIZE: CanvasSize = { width: 512, height: 512 }
|
||||
const DEFAULT_IMAGE_NAME = "Box 1"
|
||||
const DEFAULT_MODEL = "gemini-2.5-flash-image-preview"
|
||||
|
||||
const resolveDatabaseUrl = () => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
const url = ctx?.cloudflare?.env?.DATABASE_URL
|
||||
if (url) {
|
||||
return url
|
||||
}
|
||||
} catch {
|
||||
// probably not running inside server context
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
return process.env.DATABASE_URL
|
||||
}
|
||||
|
||||
throw new Error("DATABASE_URL is not configured")
|
||||
}
|
||||
|
||||
const db = () => getDb(resolveDatabaseUrl())
|
||||
|
||||
type DatabaseClient = ReturnType<typeof db>
|
||||
|
||||
const parsePoint = (value: unknown): CanvasPoint => {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"x" in value &&
|
||||
"y" in value &&
|
||||
typeof (value as any).x === "number" &&
|
||||
typeof (value as any).y === "number"
|
||||
) {
|
||||
return { x: (value as any).x, y: (value as any).y }
|
||||
}
|
||||
return DEFAULT_POSITION
|
||||
}
|
||||
|
||||
const serializeCanvasRecord = (record: typeof canvas.$inferSelect): SerializedCanvasRecord => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
ownerId: record.owner_id,
|
||||
defaultModel: record.default_model,
|
||||
defaultStyle: record.default_style,
|
||||
backgroundPrompt: record.background_prompt,
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
createdAt: record.created_at.toISOString(),
|
||||
updatedAt: record.updated_at.toISOString(),
|
||||
})
|
||||
|
||||
const serializeImage = (image: typeof canvas_images.$inferSelect): SerializedCanvasImage => ({
|
||||
id: image.id,
|
||||
canvasId: image.canvas_id,
|
||||
name: image.name,
|
||||
prompt: image.prompt,
|
||||
modelId: image.model_id,
|
||||
modelUsed: image.model_used,
|
||||
styleId: image.style_id,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
rotation: image.rotation,
|
||||
position: parsePoint(image.position),
|
||||
branchParentId: image.branch_parent_id,
|
||||
metadata: (image.metadata as Record<string, unknown> | null) ?? null,
|
||||
imageUrl: image.image_url,
|
||||
imageData: image.content_base64 ?? null,
|
||||
createdAt: image.created_at.toISOString(),
|
||||
updatedAt: image.updated_at.toISOString(),
|
||||
})
|
||||
|
||||
const createCanvasWithDefaults = async (
|
||||
params: {
|
||||
ownerId: string
|
||||
name?: string
|
||||
database?: DatabaseClient
|
||||
},
|
||||
): Promise<SerializedCanvas> => {
|
||||
const database = params.database ?? db()
|
||||
const [createdCanvas] = await database
|
||||
.insert(canvas)
|
||||
.values({
|
||||
owner_id: params.ownerId,
|
||||
name: params.name ?? "Untitled Canvas",
|
||||
})
|
||||
.returning()
|
||||
|
||||
const [createdImage] = await database
|
||||
.insert(canvas_images)
|
||||
.values({
|
||||
canvas_id: createdCanvas.id,
|
||||
name: DEFAULT_IMAGE_NAME,
|
||||
prompt: "",
|
||||
position: DEFAULT_POSITION,
|
||||
width: DEFAULT_IMAGE_SIZE.width,
|
||||
height: DEFAULT_IMAGE_SIZE.height,
|
||||
model_id: DEFAULT_MODEL,
|
||||
style_id: "default",
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
canvas: serializeCanvasRecord(createdCanvas),
|
||||
images: [serializeImage(createdImage)],
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateCanvasForUser(userId: string): Promise<SerializedCanvas> {
|
||||
const database = db()
|
||||
const existing = await database
|
||||
.select()
|
||||
.from(canvas)
|
||||
.where(eq(canvas.owner_id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
const images = await database
|
||||
.select()
|
||||
.from(canvas_images)
|
||||
.where(eq(canvas_images.canvas_id, existing[0].id))
|
||||
.orderBy(asc(canvas_images.created_at))
|
||||
|
||||
return {
|
||||
canvas: serializeCanvasRecord(existing[0]),
|
||||
images: images.map(serializeImage),
|
||||
}
|
||||
}
|
||||
|
||||
return createCanvasWithDefaults({ ownerId: userId, database })
|
||||
}
|
||||
|
||||
export async function getCanvasSnapshotById(canvasId: string): Promise<SerializedCanvas | null> {
|
||||
const database = db()
|
||||
const records = await database.select().from(canvas).where(eq(canvas.id, canvasId)).limit(1)
|
||||
if (records.length === 0) {
|
||||
return null
|
||||
}
|
||||
const images = await database
|
||||
.select()
|
||||
.from(canvas_images)
|
||||
.where(eq(canvas_images.canvas_id, canvasId))
|
||||
.orderBy(asc(canvas_images.created_at))
|
||||
|
||||
return {
|
||||
canvas: serializeCanvasRecord(records[0]),
|
||||
images: images.map(serializeImage),
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCanvasForUser(params: {
|
||||
userId: string
|
||||
name?: string
|
||||
}): Promise<SerializedCanvas> {
|
||||
return createCanvasWithDefaults({ ownerId: params.userId, name: params.name })
|
||||
}
|
||||
|
||||
export async function listCanvasesForUser(userId: string): Promise<SerializedCanvasSummary[]> {
|
||||
const database = db()
|
||||
const records = await database
|
||||
.select()
|
||||
.from(canvas)
|
||||
.where(eq(canvas.owner_id, userId))
|
||||
.orderBy(desc(canvas.updated_at))
|
||||
|
||||
if (records.length === 0) {
|
||||
const created = await createCanvasWithDefaults({ ownerId: userId, database })
|
||||
return [
|
||||
{
|
||||
canvas: created.canvas,
|
||||
previewImage: created.images[0] ?? null,
|
||||
imageCount: created.images.length,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const canvasIds = records.map((record) => record.id)
|
||||
const previewMap = new Map<string, SerializedCanvasImage>()
|
||||
const countMap = new Map<string, number>()
|
||||
|
||||
if (canvasIds.length > 0) {
|
||||
const images = await database
|
||||
.select()
|
||||
.from(canvas_images)
|
||||
.where(inArray(canvas_images.canvas_id, canvasIds))
|
||||
.orderBy(desc(canvas_images.updated_at))
|
||||
|
||||
for (const image of images) {
|
||||
const serialized = serializeImage(image)
|
||||
const parentCanvasId = serialized.canvasId
|
||||
countMap.set(parentCanvasId, (countMap.get(parentCanvasId) ?? 0) + 1)
|
||||
if (!previewMap.has(parentCanvasId)) {
|
||||
previewMap.set(parentCanvasId, serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records.map((record) => ({
|
||||
canvas: serializeCanvasRecord(record),
|
||||
previewImage: previewMap.get(record.id) ?? null,
|
||||
imageCount: countMap.get(record.id) ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createCanvasImage(params: {
|
||||
canvasId: string
|
||||
name?: string
|
||||
prompt?: string
|
||||
position?: CanvasPoint
|
||||
size?: CanvasSize
|
||||
modelId?: string
|
||||
styleId?: string
|
||||
branchParentId?: string | null
|
||||
}): Promise<SerializedCanvasImage> {
|
||||
const database = db()
|
||||
const [image] = await database
|
||||
.insert(canvas_images)
|
||||
.values({
|
||||
canvas_id: params.canvasId,
|
||||
name: params.name ?? DEFAULT_IMAGE_NAME,
|
||||
prompt: params.prompt ?? "",
|
||||
position: params.position ?? DEFAULT_POSITION,
|
||||
width: params.size?.width ?? DEFAULT_IMAGE_SIZE.width,
|
||||
height: params.size?.height ?? DEFAULT_IMAGE_SIZE.height,
|
||||
model_id: params.modelId ?? DEFAULT_MODEL,
|
||||
style_id: params.styleId ?? "default",
|
||||
branch_parent_id: params.branchParentId ?? null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return serializeImage(image)
|
||||
}
|
||||
|
||||
export async function updateCanvasImage(params: {
|
||||
imageId: string
|
||||
data: {
|
||||
name?: string
|
||||
prompt?: string
|
||||
modelId?: string
|
||||
modelUsed?: string | null
|
||||
styleId?: string
|
||||
position?: CanvasPoint
|
||||
size?: CanvasSize
|
||||
rotation?: number
|
||||
metadata?: Record<string, unknown> | null
|
||||
branchParentId?: string | null
|
||||
imageDataBase64?: string | null
|
||||
imageUrl?: string | null
|
||||
}
|
||||
}): Promise<SerializedCanvasImage> {
|
||||
const database = db()
|
||||
const values: Partial<typeof canvas_images.$inferInsert> = {
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
if (params.data.name !== undefined) values.name = params.data.name
|
||||
if (params.data.prompt !== undefined) values.prompt = params.data.prompt
|
||||
if (params.data.modelId !== undefined) values.model_id = params.data.modelId
|
||||
if (params.data.modelUsed !== undefined) values.model_used = params.data.modelUsed
|
||||
if (params.data.styleId !== undefined) values.style_id = params.data.styleId
|
||||
if (params.data.position) values.position = params.data.position
|
||||
if (params.data.size) {
|
||||
values.width = params.data.size.width
|
||||
values.height = params.data.size.height
|
||||
}
|
||||
if (typeof params.data.rotation === "number") {
|
||||
values.rotation = params.data.rotation
|
||||
}
|
||||
if (params.data.metadata !== undefined) {
|
||||
values.metadata = params.data.metadata ?? null
|
||||
}
|
||||
if (params.data.branchParentId !== undefined) {
|
||||
values.branch_parent_id = params.data.branchParentId
|
||||
}
|
||||
if (params.data.imageDataBase64 !== undefined) {
|
||||
values.content_base64 = params.data.imageDataBase64 ?? null
|
||||
}
|
||||
if (params.data.imageUrl !== undefined) {
|
||||
values.image_url = params.data.imageUrl
|
||||
}
|
||||
|
||||
const [updated] = await database
|
||||
.update(canvas_images)
|
||||
.set(values)
|
||||
.where(eq(canvas_images.id, params.imageId))
|
||||
.returning()
|
||||
|
||||
return serializeImage(updated)
|
||||
}
|
||||
|
||||
export async function deleteCanvasImage(imageId: string) {
|
||||
const database = db()
|
||||
await database.delete(canvas_images).where(eq(canvas_images.id, imageId))
|
||||
}
|
||||
|
||||
export async function updateCanvasRecord(params: {
|
||||
canvasId: string
|
||||
data: {
|
||||
name?: string
|
||||
width?: number
|
||||
height?: number
|
||||
defaultModel?: string
|
||||
defaultStyle?: string
|
||||
backgroundPrompt?: string | null
|
||||
}
|
||||
}): Promise<SerializedCanvasRecord> {
|
||||
const database = db()
|
||||
const values: Partial<typeof canvas.$inferInsert> = {
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
if (params.data.name !== undefined) values.name = params.data.name
|
||||
if (params.data.width !== undefined) values.width = params.data.width
|
||||
if (params.data.height !== undefined) values.height = params.data.height
|
||||
if (params.data.defaultModel !== undefined) values.default_model = params.data.defaultModel
|
||||
if (params.data.defaultStyle !== undefined) values.default_style = params.data.defaultStyle
|
||||
if (params.data.backgroundPrompt !== undefined)
|
||||
values.background_prompt = params.data.backgroundPrompt
|
||||
|
||||
const [record] = await database
|
||||
.update(canvas)
|
||||
.set(values)
|
||||
.where(eq(canvas.id, params.canvasId))
|
||||
.returning()
|
||||
|
||||
return serializeCanvasRecord(record)
|
||||
}
|
||||
|
||||
export async function getCanvasOwner(canvasId: string) {
|
||||
const database = db()
|
||||
const [record] = await database
|
||||
.select({ ownerId: canvas.owner_id })
|
||||
.from(canvas)
|
||||
.where(eq(canvas.id, canvasId))
|
||||
.limit(1)
|
||||
return record ?? null
|
||||
}
|
||||
|
||||
export async function getCanvasImageRecord(imageId: string) {
|
||||
const database = db()
|
||||
const [record] = await database
|
||||
.select()
|
||||
.from(canvas_images)
|
||||
.where(eq(canvas_images.id, imageId))
|
||||
.limit(1)
|
||||
return record ?? null
|
||||
}
|
||||
53
packages/web/src/lib/canvas/types.ts
Normal file
53
packages/web/src/lib/canvas/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type CanvasPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type CanvasSize = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type SerializedCanvasRecord = {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
defaultModel: string
|
||||
defaultStyle: string
|
||||
backgroundPrompt: string | null
|
||||
width: number
|
||||
height: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type SerializedCanvasImage = {
|
||||
id: string
|
||||
canvasId: string
|
||||
name: string
|
||||
prompt: string
|
||||
modelId: string
|
||||
modelUsed: string | null
|
||||
styleId: string
|
||||
width: number
|
||||
height: number
|
||||
rotation: number
|
||||
position: CanvasPoint
|
||||
branchParentId: string | null
|
||||
metadata: Record<string, unknown> | null
|
||||
imageUrl: string | null
|
||||
imageData: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type SerializedCanvas = {
|
||||
canvas: SerializedCanvasRecord
|
||||
images: SerializedCanvasImage[]
|
||||
}
|
||||
|
||||
export type SerializedCanvasSummary = {
|
||||
canvas: SerializedCanvasRecord
|
||||
previewImage: SerializedCanvasImage | null
|
||||
imageCount: number
|
||||
}
|
||||
85
packages/web/src/lib/canvas/user-session.ts
Normal file
85
packages/web/src/lib/canvas/user-session.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { getAuthDb } from "@/db/connection"
|
||||
import { users } from "@/db/schema"
|
||||
|
||||
const COOKIE_NAME = "canvas_guest_id"
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
|
||||
|
||||
const parseCookies = (header: string | null) => {
|
||||
if (!header) return {}
|
||||
return header.split(";").reduce<Record<string, string>>((acc, part) => {
|
||||
const [key, ...rest] = part.trim().split("=")
|
||||
if (!key) return acc
|
||||
acc[key] = rest.join("=")
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const buildCookie = (id: string) =>
|
||||
`${COOKIE_NAME}=${id}; Path=/; Max-Age=${COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax`
|
||||
|
||||
const resolveDatabaseUrl = () => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
const url = ctx?.cloudflare?.env?.DATABASE_URL
|
||||
if (url) {
|
||||
return url
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
return process.env.DATABASE_URL
|
||||
}
|
||||
|
||||
throw new Error("DATABASE_URL is not configured")
|
||||
}
|
||||
|
||||
const getAuthDatabase = () => {
|
||||
const url = resolveDatabaseUrl()
|
||||
return getAuthDb(url)
|
||||
}
|
||||
|
||||
async function ensureGuestUser(existingId?: string) {
|
||||
const db = getAuthDatabase()
|
||||
|
||||
if (existingId) {
|
||||
const existing = await db.query.users.findFirst({
|
||||
where(fields, { eq }) {
|
||||
return eq(fields.id, existingId)
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return { userId: existingId, setCookie: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
const newId = crypto.randomUUID()
|
||||
const email = `canvas-guest-${newId}@example.local`
|
||||
|
||||
await db.insert(users).values({
|
||||
id: newId,
|
||||
name: "Canvas Guest",
|
||||
email,
|
||||
})
|
||||
|
||||
return { userId: newId, setCookie: buildCookie(newId) }
|
||||
}
|
||||
|
||||
export async function resolveCanvasUser(request: Request) {
|
||||
const session = await getAuth().api.getSession({ headers: request.headers })
|
||||
|
||||
if (session?.user?.id) {
|
||||
return { userId: session.user.id, setCookie: undefined }
|
||||
}
|
||||
|
||||
const cookies = parseCookies(request.headers.get("cookie"))
|
||||
const guestId = cookies[COOKIE_NAME]
|
||||
return ensureGuestUser(guestId)
|
||||
}
|
||||
107
packages/web/src/lib/collections.ts
Normal file
107
packages/web/src/lib/collections.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { createCollection } from "@tanstack/react-db"
|
||||
import { electricCollectionOptions } from "@tanstack/electric-db-collection"
|
||||
import {
|
||||
selectUsersSchema,
|
||||
selectChatThreadSchema,
|
||||
selectChatMessageSchema,
|
||||
} from "@/db/schema"
|
||||
|
||||
export const usersCollection = createCollection(
|
||||
electricCollectionOptions({
|
||||
id: "users",
|
||||
shapeOptions: {
|
||||
url: new URL(
|
||||
"/api/users",
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000",
|
||||
).toString(),
|
||||
parser: {
|
||||
timestamptz: (date: string) => new Date(date),
|
||||
},
|
||||
},
|
||||
schema: selectUsersSchema,
|
||||
getKey: (item) => item.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000"
|
||||
|
||||
// Create collections lazily to avoid fetching before authentication
|
||||
// Using a factory pattern so each call gets the same collection instance
|
||||
|
||||
const createChatThreadsCollection = () =>
|
||||
createCollection(
|
||||
electricCollectionOptions({
|
||||
id: "chat_threads",
|
||||
shapeOptions: {
|
||||
url: new URL("/api/chat-threads", baseUrl).toString(),
|
||||
parser: {
|
||||
timestamptz: (date: string) => new Date(date),
|
||||
},
|
||||
fetchClient: (input, init) =>
|
||||
fetch(input, { ...init, credentials: "include" }),
|
||||
onError: () => {
|
||||
// Silently ignore auth errors for guest users
|
||||
},
|
||||
},
|
||||
schema: selectChatThreadSchema,
|
||||
getKey: (item) => item.id,
|
||||
}),
|
||||
)
|
||||
|
||||
const createChatMessagesCollection = () =>
|
||||
createCollection(
|
||||
electricCollectionOptions({
|
||||
id: "chat_messages",
|
||||
shapeOptions: {
|
||||
url: new URL("/api/chat-messages", baseUrl).toString(),
|
||||
parser: {
|
||||
timestamptz: (date: string) => new Date(date),
|
||||
},
|
||||
fetchClient: (input, init) =>
|
||||
fetch(input, { ...init, credentials: "include" }),
|
||||
onError: () => {
|
||||
// Silently ignore auth errors for guest users
|
||||
},
|
||||
},
|
||||
schema: selectChatMessageSchema,
|
||||
getKey: (item) => item.id,
|
||||
}),
|
||||
)
|
||||
|
||||
type ChatThreadsCollection = ReturnType<typeof createChatThreadsCollection>
|
||||
type ChatMessagesCollection = ReturnType<typeof createChatMessagesCollection>
|
||||
|
||||
let _chatThreadsCollection: ChatThreadsCollection | null = null
|
||||
let _chatMessagesCollection: ChatMessagesCollection | null = null
|
||||
|
||||
export function getChatThreadsCollection(): ChatThreadsCollection {
|
||||
if (!_chatThreadsCollection) {
|
||||
_chatThreadsCollection = createChatThreadsCollection()
|
||||
}
|
||||
return _chatThreadsCollection
|
||||
}
|
||||
|
||||
export function getChatMessagesCollection(): ChatMessagesCollection {
|
||||
if (!_chatMessagesCollection) {
|
||||
_chatMessagesCollection = createChatMessagesCollection()
|
||||
}
|
||||
return _chatMessagesCollection
|
||||
}
|
||||
|
||||
// Keep exports for backward compatibility but as getters
|
||||
export const chatThreadsCollection = {
|
||||
get collection() {
|
||||
return getChatThreadsCollection()
|
||||
},
|
||||
}
|
||||
|
||||
export const chatMessagesCollection = {
|
||||
get collection() {
|
||||
return getChatMessagesCollection()
|
||||
},
|
||||
}
|
||||
103
packages/web/src/lib/electric-proxy.ts
Normal file
103
packages/web/src/lib/electric-proxy.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"
|
||||
|
||||
type ElectricEnv = {
|
||||
ELECTRIC_URL?: string
|
||||
ELECTRIC_SOURCE_ID?: string
|
||||
ELECTRIC_SOURCE_SECRET?: string
|
||||
}
|
||||
const DEFAULT_ALLOW_HEADERS =
|
||||
"content-type,authorization,x-requested-with,x-electric-client-id"
|
||||
|
||||
// Get env from Cloudflare context or process.env
|
||||
const getElectricEnv = (): ElectricEnv => {
|
||||
let ELECTRIC_URL: string | undefined
|
||||
let ELECTRIC_SOURCE_ID: string | undefined
|
||||
let ELECTRIC_SOURCE_SECRET: string | undefined
|
||||
|
||||
// Try Cloudflare Workers context first (production)
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server")
|
||||
const ctx = getServerContext()
|
||||
if (ctx?.cloudflare?.env) {
|
||||
const cfEnv = ctx.cloudflare.env as Partial<ElectricEnv>
|
||||
ELECTRIC_URL = cfEnv.ELECTRIC_URL
|
||||
ELECTRIC_SOURCE_ID = cfEnv.ELECTRIC_SOURCE_ID
|
||||
ELECTRIC_SOURCE_SECRET = cfEnv.ELECTRIC_SOURCE_SECRET
|
||||
}
|
||||
} catch {
|
||||
// Not in Cloudflare context
|
||||
}
|
||||
|
||||
// Fall back to process.env (local dev)
|
||||
return {
|
||||
ELECTRIC_URL: ELECTRIC_URL ?? process.env.ELECTRIC_URL,
|
||||
ELECTRIC_SOURCE_ID: ELECTRIC_SOURCE_ID ?? process.env.ELECTRIC_SOURCE_ID,
|
||||
ELECTRIC_SOURCE_SECRET:
|
||||
ELECTRIC_SOURCE_SECRET ?? process.env.ELECTRIC_SOURCE_SECRET,
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareElectricUrl(requestUrl: string): URL {
|
||||
const url = new URL(requestUrl)
|
||||
const env = getElectricEnv()
|
||||
const electricUrl = env.ELECTRIC_URL ?? "http://localhost:3100"
|
||||
const originUrl = new URL(`${electricUrl}/v1/shape`)
|
||||
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
|
||||
originUrl.searchParams.set(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
if (env.ELECTRIC_SOURCE_ID && env.ELECTRIC_SOURCE_SECRET) {
|
||||
originUrl.searchParams.set("source_id", env.ELECTRIC_SOURCE_ID)
|
||||
originUrl.searchParams.set("secret", env.ELECTRIC_SOURCE_SECRET)
|
||||
}
|
||||
|
||||
return originUrl
|
||||
}
|
||||
|
||||
const buildCorsHeaders = (request?: Request) => {
|
||||
const headers = new Headers()
|
||||
const origin = request?.headers.get("origin")
|
||||
|
||||
if (origin) {
|
||||
headers.set("access-control-allow-origin", origin)
|
||||
headers.set("access-control-allow-credentials", "true")
|
||||
} else {
|
||||
headers.set("access-control-allow-origin", "*")
|
||||
}
|
||||
|
||||
const requestedHeaders =
|
||||
request?.headers.get("access-control-request-headers") ?? DEFAULT_ALLOW_HEADERS
|
||||
headers.set("access-control-allow-headers", requestedHeaders)
|
||||
headers.set("access-control-allow-methods", "GET,OPTIONS")
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export const optionsResponse = (request?: Request) =>
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: buildCorsHeaders(request),
|
||||
})
|
||||
|
||||
export async function proxyElectricRequest(
|
||||
originUrl: URL,
|
||||
request?: Request,
|
||||
): Promise<Response> {
|
||||
const response = await fetch(originUrl)
|
||||
const headers = new Headers(response.headers)
|
||||
const corsHeaders = buildCorsHeaders(request)
|
||||
|
||||
headers.delete("content-encoding")
|
||||
headers.delete("content-length")
|
||||
headers.set("vary", "cookie")
|
||||
corsHeaders.forEach((value, key) => headers.set(key, value))
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
73
packages/web/src/lib/flowglad.ts
Normal file
73
packages/web/src/lib/flowglad.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FlowgladServer } from "@flowglad/server"
|
||||
import { getAuth } from "./auth"
|
||||
|
||||
type FlowgladEnv = {
|
||||
FLOWGLAD_SECRET_KEY?: string
|
||||
}
|
||||
|
||||
const getEnv = (): FlowgladEnv => {
|
||||
let FLOWGLAD_SECRET_KEY: string | undefined
|
||||
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: FlowgladEnv } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
FLOWGLAD_SECRET_KEY = ctx?.cloudflare?.env?.FLOWGLAD_SECRET_KEY
|
||||
} catch {
|
||||
// Not in server context
|
||||
}
|
||||
|
||||
FLOWGLAD_SECRET_KEY = FLOWGLAD_SECRET_KEY ?? process.env.FLOWGLAD_SECRET_KEY
|
||||
|
||||
return { FLOWGLAD_SECRET_KEY }
|
||||
}
|
||||
|
||||
export const getFlowgladServer = (request?: Request) => {
|
||||
const env = getEnv()
|
||||
|
||||
if (!env.FLOWGLAD_SECRET_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new FlowgladServer({
|
||||
apiKey: env.FLOWGLAD_SECRET_KEY,
|
||||
getRequestingCustomer: async () => {
|
||||
if (!request) {
|
||||
throw new Error("Request required to get customer")
|
||||
}
|
||||
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error("Unauthenticated")
|
||||
}
|
||||
|
||||
return {
|
||||
externalId: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name ?? undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a FlowgladServer instance for a specific user ID.
|
||||
* Use this when you already have the user ID and don't need request-based auth.
|
||||
*/
|
||||
export const flowglad = (userId: string) => {
|
||||
const env = getEnv()
|
||||
|
||||
if (!env.FLOWGLAD_SECRET_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new FlowgladServer({
|
||||
apiKey: env.FLOWGLAD_SECRET_KEY,
|
||||
getRequestingCustomer: async () => ({
|
||||
externalId: userId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
36
packages/web/src/lib/stream/db.ts
Normal file
36
packages/web/src/lib/stream/db.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type StreamPageData = {
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
username: string | null
|
||||
image: string | null
|
||||
}
|
||||
stream: {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
is_live: boolean
|
||||
viewer_count: number
|
||||
hls_url: string | null
|
||||
thumbnail_url: string | null
|
||||
started_at: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export async function getStreamByUsername(
|
||||
username: string,
|
||||
): Promise<StreamPageData | null> {
|
||||
const res = await fetch(`/api/streams/${username}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (res.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch stream data")
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
49
packages/web/src/lib/worker-rpc.ts
Normal file
49
packages/web/src/lib/worker-rpc.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Example utilities for calling the Worker RPC from the web package
|
||||
*
|
||||
* Usage in server functions or loaders:
|
||||
*
|
||||
* import { getServerContext } from '@tanstack/react-start/server';
|
||||
* import { callWorkerRpc } from '@/lib/worker-rpc';
|
||||
*
|
||||
* export const loader = async () => {
|
||||
* const { WORKER_RPC } = getServerContext().cloudflare.env;
|
||||
* const result = await callWorkerRpc(WORKER_RPC);
|
||||
* return result;
|
||||
* };
|
||||
*/
|
||||
|
||||
import type { WorkerRpc } from "../../../worker/src/rpc"
|
||||
|
||||
/**
|
||||
* Example: Call the sayHello RPC method
|
||||
*/
|
||||
export async function sayHelloRpc(workerRpc: WorkerRpc, name: string) {
|
||||
return await workerRpc.sayHello(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Call the calculate RPC method
|
||||
*/
|
||||
export async function calculateRpc(
|
||||
workerRpc: WorkerRpc,
|
||||
operation: "add" | "subtract" | "multiply" | "divide",
|
||||
a: number,
|
||||
b: number,
|
||||
) {
|
||||
return await workerRpc.calculate(operation, a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Call the processBatch RPC method
|
||||
*/
|
||||
export async function processBatchRpc(workerRpc: WorkerRpc, items: string[]) {
|
||||
return await workerRpc.processBatch(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Call the getData RPC method
|
||||
*/
|
||||
export async function getDataRpc(workerRpc: WorkerRpc, key: string) {
|
||||
return await workerRpc.getData(key)
|
||||
}
|
||||
12
packages/web/src/logo.svg
Normal file
12
packages/web/src/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
993
packages/web/src/routeTree.gen.ts
Normal file
993
packages/web/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,993 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UsersRouteImport } from './routes/users'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as SessionsRouteImport } from './routes/sessions'
|
||||
import { Route as MarketplaceRouteImport } from './routes/marketplace'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as ChatRouteImport } from './routes/chat'
|
||||
import { Route as CanvasRouteImport } from './routes/canvas'
|
||||
import { Route as BlocksRouteImport } from './routes/blocks'
|
||||
import { Route as AuthRouteImport } from './routes/auth'
|
||||
import { Route as UsernameRouteImport } from './routes/$username'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as CanvasIndexRouteImport } from './routes/canvas.index'
|
||||
import { Route as I1focusDemoRouteImport } from './routes/i.1focus-demo'
|
||||
import { Route as CanvasCanvasIdRouteImport } from './routes/canvas.$canvasId'
|
||||
import { Route as ApiUsersRouteImport } from './routes/api/users'
|
||||
import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events'
|
||||
import { Route as ApiStreamRouteImport } from './routes/api/stream'
|
||||
import { Route as ApiProfileRouteImport } from './routes/api/profile'
|
||||
import { Route as ApiContextItemsRouteImport } from './routes/api/context-items'
|
||||
import { Route as ApiChatThreadsRouteImport } from './routes/api/chat-threads'
|
||||
import { Route as ApiChatMessagesRouteImport } from './routes/api/chat-messages'
|
||||
import { Route as ApiCanvasRouteImport } from './routes/api/canvas'
|
||||
import { Route as ApiBrowserSessionsRouteImport } from './routes/api/browser-sessions'
|
||||
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs'
|
||||
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request'
|
||||
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names'
|
||||
import { Route as ApiUsageEventsCreateRouteImport } from './routes/api/usage-events.create'
|
||||
import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username'
|
||||
import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$'
|
||||
import { Route as ApiChatMutationsRouteImport } from './routes/api/chat/mutations'
|
||||
import { Route as ApiChatGuestRouteImport } from './routes/api/chat/guest'
|
||||
import { Route as ApiChatAiRouteImport } from './routes/api/chat/ai'
|
||||
import { Route as ApiCanvasImagesRouteImport } from './routes/api/canvas.images'
|
||||
import { Route as ApiCanvasCanvasIdRouteImport } from './routes/api/canvas.$canvasId'
|
||||
import { Route as ApiBrowserSessionsSessionIdRouteImport } from './routes/api/browser-sessions.$sessionId'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index'
|
||||
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode'
|
||||
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr'
|
||||
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
|
||||
import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId'
|
||||
import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate'
|
||||
|
||||
const UsersRoute = UsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SessionsRoute = SessionsRouteImport.update({
|
||||
id: '/sessions',
|
||||
path: '/sessions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const MarketplaceRoute = MarketplaceRouteImport.update({
|
||||
id: '/marketplace',
|
||||
path: '/marketplace',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ChatRoute = ChatRouteImport.update({
|
||||
id: '/chat',
|
||||
path: '/chat',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CanvasRoute = CanvasRouteImport.update({
|
||||
id: '/canvas',
|
||||
path: '/canvas',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BlocksRoute = BlocksRouteImport.update({
|
||||
id: '/blocks',
|
||||
path: '/blocks',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: '/auth',
|
||||
path: '/auth',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const UsernameRoute = UsernameRouteImport.update({
|
||||
id: '/$username',
|
||||
path: '/$username',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CanvasIndexRoute = CanvasIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => CanvasRoute,
|
||||
} as any)
|
||||
const I1focusDemoRoute = I1focusDemoRouteImport.update({
|
||||
id: '/i/1focus-demo',
|
||||
path: '/i/1focus-demo',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CanvasCanvasIdRoute = CanvasCanvasIdRouteImport.update({
|
||||
id: '/$canvasId',
|
||||
path: '/$canvasId',
|
||||
getParentRoute: () => CanvasRoute,
|
||||
} as any)
|
||||
const ApiUsersRoute = ApiUsersRouteImport.update({
|
||||
id: '/api/users',
|
||||
path: '/api/users',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiUsageEventsRoute = ApiUsageEventsRouteImport.update({
|
||||
id: '/api/usage-events',
|
||||
path: '/api/usage-events',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStreamRoute = ApiStreamRouteImport.update({
|
||||
id: '/api/stream',
|
||||
path: '/api/stream',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiProfileRoute = ApiProfileRouteImport.update({
|
||||
id: '/api/profile',
|
||||
path: '/api/profile',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiContextItemsRoute = ApiContextItemsRouteImport.update({
|
||||
id: '/api/context-items',
|
||||
path: '/api/context-items',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiChatThreadsRoute = ApiChatThreadsRouteImport.update({
|
||||
id: '/api/chat-threads',
|
||||
path: '/api/chat-threads',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiChatMessagesRoute = ApiChatMessagesRouteImport.update({
|
||||
id: '/api/chat-messages',
|
||||
path: '/api/chat-messages',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiCanvasRoute = ApiCanvasRouteImport.update({
|
||||
id: '/api/canvas',
|
||||
path: '/api/canvas',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiBrowserSessionsRoute = ApiBrowserSessionsRouteImport.update({
|
||||
id: '/api/browser-sessions',
|
||||
path: '/api/browser-sessions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({
|
||||
id: '/demo/start/server-funcs',
|
||||
path: '/demo/start/server-funcs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoStartApiRequestRoute = DemoStartApiRequestRouteImport.update({
|
||||
id: '/demo/start/api-request',
|
||||
path: '/demo/start/api-request',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoApiNamesRoute = DemoApiNamesRouteImport.update({
|
||||
id: '/demo/api/names',
|
||||
path: '/demo/api/names',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiUsageEventsCreateRoute = ApiUsageEventsCreateRouteImport.update({
|
||||
id: '/create',
|
||||
path: '/create',
|
||||
getParentRoute: () => ApiUsageEventsRoute,
|
||||
} as any)
|
||||
const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({
|
||||
id: '/api/streams/$username',
|
||||
path: '/api/streams/$username',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiFlowgladSplatRoute = ApiFlowgladSplatRouteImport.update({
|
||||
id: '/api/flowglad/$',
|
||||
path: '/api/flowglad/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiChatMutationsRoute = ApiChatMutationsRouteImport.update({
|
||||
id: '/api/chat/mutations',
|
||||
path: '/api/chat/mutations',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiChatGuestRoute = ApiChatGuestRouteImport.update({
|
||||
id: '/api/chat/guest',
|
||||
path: '/api/chat/guest',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiChatAiRoute = ApiChatAiRouteImport.update({
|
||||
id: '/api/chat/ai',
|
||||
path: '/api/chat/ai',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiCanvasImagesRoute = ApiCanvasImagesRouteImport.update({
|
||||
id: '/images',
|
||||
path: '/images',
|
||||
getParentRoute: () => ApiCanvasRoute,
|
||||
} as any)
|
||||
const ApiCanvasCanvasIdRoute = ApiCanvasCanvasIdRouteImport.update({
|
||||
id: '/$canvasId',
|
||||
path: '/$canvasId',
|
||||
getParentRoute: () => ApiCanvasRoute,
|
||||
} as any)
|
||||
const ApiBrowserSessionsSessionIdRoute =
|
||||
ApiBrowserSessionsSessionIdRouteImport.update({
|
||||
id: '/$sessionId',
|
||||
path: '/$sessionId',
|
||||
getParentRoute: () => ApiBrowserSessionsRoute,
|
||||
} as any)
|
||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
id: '/api/auth/$',
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoStartSsrIndexRoute = DemoStartSsrIndexRouteImport.update({
|
||||
id: '/demo/start/ssr/',
|
||||
path: '/demo/start/ssr/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoStartSsrSpaModeRoute = DemoStartSsrSpaModeRouteImport.update({
|
||||
id: '/demo/start/ssr/spa-mode',
|
||||
path: '/demo/start/ssr/spa-mode',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoStartSsrFullSsrRoute = DemoStartSsrFullSsrRouteImport.update({
|
||||
id: '/demo/start/ssr/full-ssr',
|
||||
path: '/demo/start/ssr/full-ssr',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
|
||||
id: '/demo/start/ssr/data-only',
|
||||
path: '/demo/start/ssr/data-only',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiCanvasImagesImageIdRoute = ApiCanvasImagesImageIdRouteImport.update({
|
||||
id: '/$imageId',
|
||||
path: '/$imageId',
|
||||
getParentRoute: () => ApiCanvasImagesRoute,
|
||||
} as any)
|
||||
const ApiCanvasImagesImageIdGenerateRoute =
|
||||
ApiCanvasImagesImageIdGenerateRouteImport.update({
|
||||
id: '/generate',
|
||||
path: '/generate',
|
||||
getParentRoute: () => ApiCanvasImagesImageIdRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/$username': typeof UsernameRoute
|
||||
'/auth': typeof AuthRoute
|
||||
'/blocks': typeof BlocksRoute
|
||||
'/canvas': typeof CanvasRouteWithChildren
|
||||
'/chat': typeof ChatRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/marketplace': typeof MarketplaceRoute
|
||||
'/sessions': typeof SessionsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/users': typeof UsersRoute
|
||||
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
|
||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||
'/api/chat-messages': typeof ApiChatMessagesRoute
|
||||
'/api/chat-threads': typeof ApiChatThreadsRoute
|
||||
'/api/context-items': typeof ApiContextItemsRoute
|
||||
'/api/profile': typeof ApiProfileRoute
|
||||
'/api/stream': typeof ApiStreamRoute
|
||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||
'/api/users': typeof ApiUsersRoute
|
||||
'/canvas/$canvasId': typeof CanvasCanvasIdRoute
|
||||
'/i/1focus-demo': typeof I1focusDemoRoute
|
||||
'/canvas/': typeof CanvasIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute
|
||||
'/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute
|
||||
'/api/canvas/images': typeof ApiCanvasImagesRouteWithChildren
|
||||
'/api/chat/ai': typeof ApiChatAiRoute
|
||||
'/api/chat/guest': typeof ApiChatGuestRoute
|
||||
'/api/chat/mutations': typeof ApiChatMutationsRoute
|
||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
'/demo/api/names': typeof DemoApiNamesRoute
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||
'/demo/start/ssr': typeof DemoStartSsrIndexRoute
|
||||
'/api/canvas/images/$imageId/generate': typeof ApiCanvasImagesImageIdGenerateRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/$username': typeof UsernameRoute
|
||||
'/auth': typeof AuthRoute
|
||||
'/blocks': typeof BlocksRoute
|
||||
'/chat': typeof ChatRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/marketplace': typeof MarketplaceRoute
|
||||
'/sessions': typeof SessionsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/users': typeof UsersRoute
|
||||
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
|
||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||
'/api/chat-messages': typeof ApiChatMessagesRoute
|
||||
'/api/chat-threads': typeof ApiChatThreadsRoute
|
||||
'/api/context-items': typeof ApiContextItemsRoute
|
||||
'/api/profile': typeof ApiProfileRoute
|
||||
'/api/stream': typeof ApiStreamRoute
|
||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||
'/api/users': typeof ApiUsersRoute
|
||||
'/canvas/$canvasId': typeof CanvasCanvasIdRoute
|
||||
'/i/1focus-demo': typeof I1focusDemoRoute
|
||||
'/canvas': typeof CanvasIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute
|
||||
'/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute
|
||||
'/api/canvas/images': typeof ApiCanvasImagesRouteWithChildren
|
||||
'/api/chat/ai': typeof ApiChatAiRoute
|
||||
'/api/chat/guest': typeof ApiChatGuestRoute
|
||||
'/api/chat/mutations': typeof ApiChatMutationsRoute
|
||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
'/demo/api/names': typeof DemoApiNamesRoute
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||
'/demo/start/ssr': typeof DemoStartSsrIndexRoute
|
||||
'/api/canvas/images/$imageId/generate': typeof ApiCanvasImagesImageIdGenerateRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/$username': typeof UsernameRoute
|
||||
'/auth': typeof AuthRoute
|
||||
'/blocks': typeof BlocksRoute
|
||||
'/canvas': typeof CanvasRouteWithChildren
|
||||
'/chat': typeof ChatRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/marketplace': typeof MarketplaceRoute
|
||||
'/sessions': typeof SessionsRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/users': typeof UsersRoute
|
||||
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
|
||||
'/api/canvas': typeof ApiCanvasRouteWithChildren
|
||||
'/api/chat-messages': typeof ApiChatMessagesRoute
|
||||
'/api/chat-threads': typeof ApiChatThreadsRoute
|
||||
'/api/context-items': typeof ApiContextItemsRoute
|
||||
'/api/profile': typeof ApiProfileRoute
|
||||
'/api/stream': typeof ApiStreamRoute
|
||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||
'/api/users': typeof ApiUsersRoute
|
||||
'/canvas/$canvasId': typeof CanvasCanvasIdRoute
|
||||
'/i/1focus-demo': typeof I1focusDemoRoute
|
||||
'/canvas/': typeof CanvasIndexRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute
|
||||
'/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute
|
||||
'/api/canvas/images': typeof ApiCanvasImagesRouteWithChildren
|
||||
'/api/chat/ai': typeof ApiChatAiRoute
|
||||
'/api/chat/guest': typeof ApiChatGuestRoute
|
||||
'/api/chat/mutations': typeof ApiChatMutationsRoute
|
||||
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
'/demo/api/names': typeof DemoApiNamesRoute
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
|
||||
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
|
||||
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
|
||||
'/api/canvas/images/$imageId/generate': typeof ApiCanvasImagesImageIdGenerateRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/$username'
|
||||
| '/auth'
|
||||
| '/blocks'
|
||||
| '/canvas'
|
||||
| '/chat'
|
||||
| '/login'
|
||||
| '/marketplace'
|
||||
| '/sessions'
|
||||
| '/settings'
|
||||
| '/users'
|
||||
| '/api/browser-sessions'
|
||||
| '/api/canvas'
|
||||
| '/api/chat-messages'
|
||||
| '/api/chat-threads'
|
||||
| '/api/context-items'
|
||||
| '/api/profile'
|
||||
| '/api/stream'
|
||||
| '/api/usage-events'
|
||||
| '/api/users'
|
||||
| '/canvas/$canvasId'
|
||||
| '/i/1focus-demo'
|
||||
| '/canvas/'
|
||||
| '/api/auth/$'
|
||||
| '/api/browser-sessions/$sessionId'
|
||||
| '/api/canvas/$canvasId'
|
||||
| '/api/canvas/images'
|
||||
| '/api/chat/ai'
|
||||
| '/api/chat/guest'
|
||||
| '/api/chat/mutations'
|
||||
| '/api/flowglad/$'
|
||||
| '/api/streams/$username'
|
||||
| '/api/usage-events/create'
|
||||
| '/demo/api/names'
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/api/canvas/images/$imageId'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
| '/demo/start/ssr'
|
||||
| '/api/canvas/images/$imageId/generate'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/$username'
|
||||
| '/auth'
|
||||
| '/blocks'
|
||||
| '/chat'
|
||||
| '/login'
|
||||
| '/marketplace'
|
||||
| '/sessions'
|
||||
| '/settings'
|
||||
| '/users'
|
||||
| '/api/browser-sessions'
|
||||
| '/api/canvas'
|
||||
| '/api/chat-messages'
|
||||
| '/api/chat-threads'
|
||||
| '/api/context-items'
|
||||
| '/api/profile'
|
||||
| '/api/stream'
|
||||
| '/api/usage-events'
|
||||
| '/api/users'
|
||||
| '/canvas/$canvasId'
|
||||
| '/i/1focus-demo'
|
||||
| '/canvas'
|
||||
| '/api/auth/$'
|
||||
| '/api/browser-sessions/$sessionId'
|
||||
| '/api/canvas/$canvasId'
|
||||
| '/api/canvas/images'
|
||||
| '/api/chat/ai'
|
||||
| '/api/chat/guest'
|
||||
| '/api/chat/mutations'
|
||||
| '/api/flowglad/$'
|
||||
| '/api/streams/$username'
|
||||
| '/api/usage-events/create'
|
||||
| '/demo/api/names'
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/api/canvas/images/$imageId'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
| '/demo/start/ssr'
|
||||
| '/api/canvas/images/$imageId/generate'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/$username'
|
||||
| '/auth'
|
||||
| '/blocks'
|
||||
| '/canvas'
|
||||
| '/chat'
|
||||
| '/login'
|
||||
| '/marketplace'
|
||||
| '/sessions'
|
||||
| '/settings'
|
||||
| '/users'
|
||||
| '/api/browser-sessions'
|
||||
| '/api/canvas'
|
||||
| '/api/chat-messages'
|
||||
| '/api/chat-threads'
|
||||
| '/api/context-items'
|
||||
| '/api/profile'
|
||||
| '/api/stream'
|
||||
| '/api/usage-events'
|
||||
| '/api/users'
|
||||
| '/canvas/$canvasId'
|
||||
| '/i/1focus-demo'
|
||||
| '/canvas/'
|
||||
| '/api/auth/$'
|
||||
| '/api/browser-sessions/$sessionId'
|
||||
| '/api/canvas/$canvasId'
|
||||
| '/api/canvas/images'
|
||||
| '/api/chat/ai'
|
||||
| '/api/chat/guest'
|
||||
| '/api/chat/mutations'
|
||||
| '/api/flowglad/$'
|
||||
| '/api/streams/$username'
|
||||
| '/api/usage-events/create'
|
||||
| '/demo/api/names'
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/api/canvas/images/$imageId'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
| '/demo/start/ssr/'
|
||||
| '/api/canvas/images/$imageId/generate'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
UsernameRoute: typeof UsernameRoute
|
||||
AuthRoute: typeof AuthRoute
|
||||
BlocksRoute: typeof BlocksRoute
|
||||
CanvasRoute: typeof CanvasRouteWithChildren
|
||||
ChatRoute: typeof ChatRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
MarketplaceRoute: typeof MarketplaceRoute
|
||||
SessionsRoute: typeof SessionsRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
UsersRoute: typeof UsersRoute
|
||||
ApiBrowserSessionsRoute: typeof ApiBrowserSessionsRouteWithChildren
|
||||
ApiCanvasRoute: typeof ApiCanvasRouteWithChildren
|
||||
ApiChatMessagesRoute: typeof ApiChatMessagesRoute
|
||||
ApiChatThreadsRoute: typeof ApiChatThreadsRoute
|
||||
ApiContextItemsRoute: typeof ApiContextItemsRoute
|
||||
ApiProfileRoute: typeof ApiProfileRoute
|
||||
ApiStreamRoute: typeof ApiStreamRoute
|
||||
ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren
|
||||
ApiUsersRoute: typeof ApiUsersRoute
|
||||
I1focusDemoRoute: typeof I1focusDemoRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiChatAiRoute: typeof ApiChatAiRoute
|
||||
ApiChatGuestRoute: typeof ApiChatGuestRoute
|
||||
ApiChatMutationsRoute: typeof ApiChatMutationsRoute
|
||||
ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute
|
||||
ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRoute
|
||||
DemoApiNamesRoute: typeof DemoApiNamesRoute
|
||||
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute
|
||||
DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute
|
||||
DemoStartSsrDataOnlyRoute: typeof DemoStartSsrDataOnlyRoute
|
||||
DemoStartSsrFullSsrRoute: typeof DemoStartSsrFullSsrRoute
|
||||
DemoStartSsrSpaModeRoute: typeof DemoStartSsrSpaModeRoute
|
||||
DemoStartSsrIndexRoute: typeof DemoStartSsrIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/users': {
|
||||
id: '/users'
|
||||
path: '/users'
|
||||
fullPath: '/users'
|
||||
preLoaderRoute: typeof UsersRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/sessions': {
|
||||
id: '/sessions'
|
||||
path: '/sessions'
|
||||
fullPath: '/sessions'
|
||||
preLoaderRoute: typeof SessionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/marketplace': {
|
||||
id: '/marketplace'
|
||||
path: '/marketplace'
|
||||
fullPath: '/marketplace'
|
||||
preLoaderRoute: typeof MarketplaceRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/chat': {
|
||||
id: '/chat'
|
||||
path: '/chat'
|
||||
fullPath: '/chat'
|
||||
preLoaderRoute: typeof ChatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/canvas': {
|
||||
id: '/canvas'
|
||||
path: '/canvas'
|
||||
fullPath: '/canvas'
|
||||
preLoaderRoute: typeof CanvasRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/blocks': {
|
||||
id: '/blocks'
|
||||
path: '/blocks'
|
||||
fullPath: '/blocks'
|
||||
preLoaderRoute: typeof BlocksRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/auth': {
|
||||
id: '/auth'
|
||||
path: '/auth'
|
||||
fullPath: '/auth'
|
||||
preLoaderRoute: typeof AuthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$username': {
|
||||
id: '/$username'
|
||||
path: '/$username'
|
||||
fullPath: '/$username'
|
||||
preLoaderRoute: typeof UsernameRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/canvas/': {
|
||||
id: '/canvas/'
|
||||
path: '/'
|
||||
fullPath: '/canvas/'
|
||||
preLoaderRoute: typeof CanvasIndexRouteImport
|
||||
parentRoute: typeof CanvasRoute
|
||||
}
|
||||
'/i/1focus-demo': {
|
||||
id: '/i/1focus-demo'
|
||||
path: '/i/1focus-demo'
|
||||
fullPath: '/i/1focus-demo'
|
||||
preLoaderRoute: typeof I1focusDemoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/canvas/$canvasId': {
|
||||
id: '/canvas/$canvasId'
|
||||
path: '/$canvasId'
|
||||
fullPath: '/canvas/$canvasId'
|
||||
preLoaderRoute: typeof CanvasCanvasIdRouteImport
|
||||
parentRoute: typeof CanvasRoute
|
||||
}
|
||||
'/api/users': {
|
||||
id: '/api/users'
|
||||
path: '/api/users'
|
||||
fullPath: '/api/users'
|
||||
preLoaderRoute: typeof ApiUsersRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/usage-events': {
|
||||
id: '/api/usage-events'
|
||||
path: '/api/usage-events'
|
||||
fullPath: '/api/usage-events'
|
||||
preLoaderRoute: typeof ApiUsageEventsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/stream': {
|
||||
id: '/api/stream'
|
||||
path: '/api/stream'
|
||||
fullPath: '/api/stream'
|
||||
preLoaderRoute: typeof ApiStreamRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/profile': {
|
||||
id: '/api/profile'
|
||||
path: '/api/profile'
|
||||
fullPath: '/api/profile'
|
||||
preLoaderRoute: typeof ApiProfileRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/context-items': {
|
||||
id: '/api/context-items'
|
||||
path: '/api/context-items'
|
||||
fullPath: '/api/context-items'
|
||||
preLoaderRoute: typeof ApiContextItemsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/chat-threads': {
|
||||
id: '/api/chat-threads'
|
||||
path: '/api/chat-threads'
|
||||
fullPath: '/api/chat-threads'
|
||||
preLoaderRoute: typeof ApiChatThreadsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/chat-messages': {
|
||||
id: '/api/chat-messages'
|
||||
path: '/api/chat-messages'
|
||||
fullPath: '/api/chat-messages'
|
||||
preLoaderRoute: typeof ApiChatMessagesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/canvas': {
|
||||
id: '/api/canvas'
|
||||
path: '/api/canvas'
|
||||
fullPath: '/api/canvas'
|
||||
preLoaderRoute: typeof ApiCanvasRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/browser-sessions': {
|
||||
id: '/api/browser-sessions'
|
||||
path: '/api/browser-sessions'
|
||||
fullPath: '/api/browser-sessions'
|
||||
preLoaderRoute: typeof ApiBrowserSessionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/start/server-funcs': {
|
||||
id: '/demo/start/server-funcs'
|
||||
path: '/demo/start/server-funcs'
|
||||
fullPath: '/demo/start/server-funcs'
|
||||
preLoaderRoute: typeof DemoStartServerFuncsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/start/api-request': {
|
||||
id: '/demo/start/api-request'
|
||||
path: '/demo/start/api-request'
|
||||
fullPath: '/demo/start/api-request'
|
||||
preLoaderRoute: typeof DemoStartApiRequestRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/api/names': {
|
||||
id: '/demo/api/names'
|
||||
path: '/demo/api/names'
|
||||
fullPath: '/demo/api/names'
|
||||
preLoaderRoute: typeof DemoApiNamesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/usage-events/create': {
|
||||
id: '/api/usage-events/create'
|
||||
path: '/create'
|
||||
fullPath: '/api/usage-events/create'
|
||||
preLoaderRoute: typeof ApiUsageEventsCreateRouteImport
|
||||
parentRoute: typeof ApiUsageEventsRoute
|
||||
}
|
||||
'/api/streams/$username': {
|
||||
id: '/api/streams/$username'
|
||||
path: '/api/streams/$username'
|
||||
fullPath: '/api/streams/$username'
|
||||
preLoaderRoute: typeof ApiStreamsUsernameRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/flowglad/$': {
|
||||
id: '/api/flowglad/$'
|
||||
path: '/api/flowglad/$'
|
||||
fullPath: '/api/flowglad/$'
|
||||
preLoaderRoute: typeof ApiFlowgladSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/chat/mutations': {
|
||||
id: '/api/chat/mutations'
|
||||
path: '/api/chat/mutations'
|
||||
fullPath: '/api/chat/mutations'
|
||||
preLoaderRoute: typeof ApiChatMutationsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/chat/guest': {
|
||||
id: '/api/chat/guest'
|
||||
path: '/api/chat/guest'
|
||||
fullPath: '/api/chat/guest'
|
||||
preLoaderRoute: typeof ApiChatGuestRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/chat/ai': {
|
||||
id: '/api/chat/ai'
|
||||
path: '/api/chat/ai'
|
||||
fullPath: '/api/chat/ai'
|
||||
preLoaderRoute: typeof ApiChatAiRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/canvas/images': {
|
||||
id: '/api/canvas/images'
|
||||
path: '/images'
|
||||
fullPath: '/api/canvas/images'
|
||||
preLoaderRoute: typeof ApiCanvasImagesRouteImport
|
||||
parentRoute: typeof ApiCanvasRoute
|
||||
}
|
||||
'/api/canvas/$canvasId': {
|
||||
id: '/api/canvas/$canvasId'
|
||||
path: '/$canvasId'
|
||||
fullPath: '/api/canvas/$canvasId'
|
||||
preLoaderRoute: typeof ApiCanvasCanvasIdRouteImport
|
||||
parentRoute: typeof ApiCanvasRoute
|
||||
}
|
||||
'/api/browser-sessions/$sessionId': {
|
||||
id: '/api/browser-sessions/$sessionId'
|
||||
path: '/$sessionId'
|
||||
fullPath: '/api/browser-sessions/$sessionId'
|
||||
preLoaderRoute: typeof ApiBrowserSessionsSessionIdRouteImport
|
||||
parentRoute: typeof ApiBrowserSessionsRoute
|
||||
}
|
||||
'/api/auth/$': {
|
||||
id: '/api/auth/$'
|
||||
path: '/api/auth/$'
|
||||
fullPath: '/api/auth/$'
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/start/ssr/': {
|
||||
id: '/demo/start/ssr/'
|
||||
path: '/demo/start/ssr'
|
||||
fullPath: '/demo/start/ssr'
|
||||
preLoaderRoute: typeof DemoStartSsrIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/start/ssr/spa-mode': {
|
||||
id: '/demo/start/ssr/spa-mode'
|
||||
path: '/demo/start/ssr/spa-mode'
|
||||
fullPath: '/demo/start/ssr/spa-mode'
|
||||
preLoaderRoute: typeof DemoStartSsrSpaModeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/start/ssr/full-ssr': {
|
||||
id: '/demo/start/ssr/full-ssr'
|
||||
path: '/demo/start/ssr/full-ssr'
|
||||
fullPath: '/demo/start/ssr/full-ssr'
|
||||
preLoaderRoute: typeof DemoStartSsrFullSsrRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/start/ssr/data-only': {
|
||||
id: '/demo/start/ssr/data-only'
|
||||
path: '/demo/start/ssr/data-only'
|
||||
fullPath: '/demo/start/ssr/data-only'
|
||||
preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/canvas/images/$imageId': {
|
||||
id: '/api/canvas/images/$imageId'
|
||||
path: '/$imageId'
|
||||
fullPath: '/api/canvas/images/$imageId'
|
||||
preLoaderRoute: typeof ApiCanvasImagesImageIdRouteImport
|
||||
parentRoute: typeof ApiCanvasImagesRoute
|
||||
}
|
||||
'/api/canvas/images/$imageId/generate': {
|
||||
id: '/api/canvas/images/$imageId/generate'
|
||||
path: '/generate'
|
||||
fullPath: '/api/canvas/images/$imageId/generate'
|
||||
preLoaderRoute: typeof ApiCanvasImagesImageIdGenerateRouteImport
|
||||
parentRoute: typeof ApiCanvasImagesImageIdRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CanvasRouteChildren {
|
||||
CanvasCanvasIdRoute: typeof CanvasCanvasIdRoute
|
||||
CanvasIndexRoute: typeof CanvasIndexRoute
|
||||
}
|
||||
|
||||
const CanvasRouteChildren: CanvasRouteChildren = {
|
||||
CanvasCanvasIdRoute: CanvasCanvasIdRoute,
|
||||
CanvasIndexRoute: CanvasIndexRoute,
|
||||
}
|
||||
|
||||
const CanvasRouteWithChildren =
|
||||
CanvasRoute._addFileChildren(CanvasRouteChildren)
|
||||
|
||||
interface ApiBrowserSessionsRouteChildren {
|
||||
ApiBrowserSessionsSessionIdRoute: typeof ApiBrowserSessionsSessionIdRoute
|
||||
}
|
||||
|
||||
const ApiBrowserSessionsRouteChildren: ApiBrowserSessionsRouteChildren = {
|
||||
ApiBrowserSessionsSessionIdRoute: ApiBrowserSessionsSessionIdRoute,
|
||||
}
|
||||
|
||||
const ApiBrowserSessionsRouteWithChildren =
|
||||
ApiBrowserSessionsRoute._addFileChildren(ApiBrowserSessionsRouteChildren)
|
||||
|
||||
interface ApiCanvasImagesImageIdRouteChildren {
|
||||
ApiCanvasImagesImageIdGenerateRoute: typeof ApiCanvasImagesImageIdGenerateRoute
|
||||
}
|
||||
|
||||
const ApiCanvasImagesImageIdRouteChildren: ApiCanvasImagesImageIdRouteChildren =
|
||||
{
|
||||
ApiCanvasImagesImageIdGenerateRoute: ApiCanvasImagesImageIdGenerateRoute,
|
||||
}
|
||||
|
||||
const ApiCanvasImagesImageIdRouteWithChildren =
|
||||
ApiCanvasImagesImageIdRoute._addFileChildren(
|
||||
ApiCanvasImagesImageIdRouteChildren,
|
||||
)
|
||||
|
||||
interface ApiCanvasImagesRouteChildren {
|
||||
ApiCanvasImagesImageIdRoute: typeof ApiCanvasImagesImageIdRouteWithChildren
|
||||
}
|
||||
|
||||
const ApiCanvasImagesRouteChildren: ApiCanvasImagesRouteChildren = {
|
||||
ApiCanvasImagesImageIdRoute: ApiCanvasImagesImageIdRouteWithChildren,
|
||||
}
|
||||
|
||||
const ApiCanvasImagesRouteWithChildren = ApiCanvasImagesRoute._addFileChildren(
|
||||
ApiCanvasImagesRouteChildren,
|
||||
)
|
||||
|
||||
interface ApiCanvasRouteChildren {
|
||||
ApiCanvasCanvasIdRoute: typeof ApiCanvasCanvasIdRoute
|
||||
ApiCanvasImagesRoute: typeof ApiCanvasImagesRouteWithChildren
|
||||
}
|
||||
|
||||
const ApiCanvasRouteChildren: ApiCanvasRouteChildren = {
|
||||
ApiCanvasCanvasIdRoute: ApiCanvasCanvasIdRoute,
|
||||
ApiCanvasImagesRoute: ApiCanvasImagesRouteWithChildren,
|
||||
}
|
||||
|
||||
const ApiCanvasRouteWithChildren = ApiCanvasRoute._addFileChildren(
|
||||
ApiCanvasRouteChildren,
|
||||
)
|
||||
|
||||
interface ApiUsageEventsRouteChildren {
|
||||
ApiUsageEventsCreateRoute: typeof ApiUsageEventsCreateRoute
|
||||
}
|
||||
|
||||
const ApiUsageEventsRouteChildren: ApiUsageEventsRouteChildren = {
|
||||
ApiUsageEventsCreateRoute: ApiUsageEventsCreateRoute,
|
||||
}
|
||||
|
||||
const ApiUsageEventsRouteWithChildren = ApiUsageEventsRoute._addFileChildren(
|
||||
ApiUsageEventsRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
UsernameRoute: UsernameRoute,
|
||||
AuthRoute: AuthRoute,
|
||||
BlocksRoute: BlocksRoute,
|
||||
CanvasRoute: CanvasRouteWithChildren,
|
||||
ChatRoute: ChatRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
MarketplaceRoute: MarketplaceRoute,
|
||||
SessionsRoute: SessionsRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
UsersRoute: UsersRoute,
|
||||
ApiBrowserSessionsRoute: ApiBrowserSessionsRouteWithChildren,
|
||||
ApiCanvasRoute: ApiCanvasRouteWithChildren,
|
||||
ApiChatMessagesRoute: ApiChatMessagesRoute,
|
||||
ApiChatThreadsRoute: ApiChatThreadsRoute,
|
||||
ApiContextItemsRoute: ApiContextItemsRoute,
|
||||
ApiProfileRoute: ApiProfileRoute,
|
||||
ApiStreamRoute: ApiStreamRoute,
|
||||
ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren,
|
||||
ApiUsersRoute: ApiUsersRoute,
|
||||
I1focusDemoRoute: I1focusDemoRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiChatAiRoute: ApiChatAiRoute,
|
||||
ApiChatGuestRoute: ApiChatGuestRoute,
|
||||
ApiChatMutationsRoute: ApiChatMutationsRoute,
|
||||
ApiFlowgladSplatRoute: ApiFlowgladSplatRoute,
|
||||
ApiStreamsUsernameRoute: ApiStreamsUsernameRoute,
|
||||
DemoApiNamesRoute: DemoApiNamesRoute,
|
||||
DemoStartApiRequestRoute: DemoStartApiRequestRoute,
|
||||
DemoStartServerFuncsRoute: DemoStartServerFuncsRoute,
|
||||
DemoStartSsrDataOnlyRoute: DemoStartSsrDataOnlyRoute,
|
||||
DemoStartSsrFullSsrRoute: DemoStartSsrFullSsrRoute,
|
||||
DemoStartSsrSpaModeRoute: DemoStartSsrSpaModeRoute,
|
||||
DemoStartSsrIndexRoute: DemoStartSsrIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
16
packages/web/src/router.tsx
Normal file
16
packages/web/src/router.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createRouter } from "@tanstack/react-router"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import "./styles.css"
|
||||
|
||||
export const getRouter = () =>
|
||||
createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "viewport",
|
||||
scrollRestoration: true,
|
||||
})
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
137
packages/web/src/routes/$username.tsx
Normal file
137
packages/web/src/routes/$username.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db"
|
||||
import { VideoPlayer } from "@/components/VideoPlayer"
|
||||
|
||||
export const Route = createFileRoute("/$username")({
|
||||
ssr: false,
|
||||
component: StreamPage,
|
||||
})
|
||||
|
||||
// Cloudflare Stream HLS URL
|
||||
const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8"
|
||||
|
||||
// Hardcoded user for nikiv
|
||||
const NIKIV_DATA: StreamPageData = {
|
||||
user: {
|
||||
id: "nikiv",
|
||||
name: "Nikita",
|
||||
username: "nikiv",
|
||||
image: null,
|
||||
},
|
||||
stream: {
|
||||
id: "nikiv-stream",
|
||||
title: "Live Coding",
|
||||
description: "Building in public",
|
||||
is_live: true,
|
||||
viewer_count: 0,
|
||||
hls_url: HLS_URL,
|
||||
thumbnail_url: null,
|
||||
started_at: null,
|
||||
},
|
||||
}
|
||||
|
||||
function StreamPage() {
|
||||
const { username } = Route.useParams()
|
||||
const [data, setData] = useState<StreamPageData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [streamReady, setStreamReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Special handling for nikiv - hardcoded stream
|
||||
if (username === "nikiv") {
|
||||
setData(NIKIV_DATA)
|
||||
setLoading(false)
|
||||
// Check if stream is actually live
|
||||
fetch(HLS_URL)
|
||||
.then((res) => setStreamReady(res.ok))
|
||||
.catch(() => setStreamReady(false))
|
||||
return
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await getStreamByUsername(username)
|
||||
setData(result)
|
||||
if (result?.stream?.hls_url) {
|
||||
const res = await fetch(result.stream.hls_url)
|
||||
setStreamReady(res.ok)
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to load stream")
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [username])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
||||
<div className="text-xl">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">Error</h1>
|
||||
<p className="mt-2 text-neutral-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">User not found</h1>
|
||||
<p className="mt-2 text-neutral-400">
|
||||
This username doesn't exist or hasn't set up streaming.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { user, stream } = data
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-black">
|
||||
{stream?.is_live && stream.hls_url && streamReady ? (
|
||||
<VideoPlayer src={stream.hls_url} muted={false} />
|
||||
) : stream?.is_live && stream.hls_url ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
<p className="mt-4 text-xl text-neutral-400">
|
||||
Connecting to stream...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-medium">Streaming soon</p>
|
||||
<a
|
||||
href="https://nikiv.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-block text-lg text-neutral-400 underline hover:text-white transition-colors"
|
||||
>
|
||||
nikiv.dev
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
packages/web/src/routes/__root.tsx
Normal file
103
packages/web/src/routes/__root.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
Outlet,
|
||||
HeadContent,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
Link,
|
||||
} from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
import { BillingProvider } from "@/components/BillingProvider"
|
||||
|
||||
import appCss from "../styles.css?url"
|
||||
|
||||
const SITE_URL = "https://linsa.io"
|
||||
const SITE_NAME = "Linsa"
|
||||
const SITE_TITLE = "Linsa – Save anything privately. Share it."
|
||||
const SITE_DESCRIPTION = "Save anything privately. Share it."
|
||||
|
||||
function DevtoolsToggle() {
|
||||
const [show, setShow] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Use Ctrl+Shift+D to avoid conflicts with browser shortcuts
|
||||
if (e.ctrlKey && e.shiftKey && e.key === "D") {
|
||||
e.preventDefault()
|
||||
setShow((prev) => !prev)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
if (!show) return null
|
||||
return <TanStackRouterDevtools />
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-4">404</h1>
|
||||
<p className="text-slate-600 mb-4">Page not found</p>
|
||||
<Link to="/" className="text-slate-900 underline hover:no-underline">
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ title: SITE_TITLE },
|
||||
{ name: "description", content: SITE_DESCRIPTION },
|
||||
{
|
||||
name: "keywords",
|
||||
content: "save, bookmarks, private, share, organize",
|
||||
},
|
||||
{ name: "author", content: SITE_NAME },
|
||||
{ name: "theme-color", content: "#03050a" },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: SITE_URL },
|
||||
{ property: "og:title", content: SITE_TITLE },
|
||||
{ property: "og:description", content: SITE_DESCRIPTION },
|
||||
{ property: "og:site_name", content: SITE_NAME },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
{ name: "twitter:title", content: SITE_TITLE },
|
||||
{ name: "twitter:description", content: SITE_DESCRIPTION },
|
||||
{ name: "twitter:creator", content: "@linaborisova" },
|
||||
],
|
||||
links: [
|
||||
{ rel: "canonical", href: SITE_URL },
|
||||
{ rel: "icon", href: "/favicon.ico" },
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
notFoundComponent: NotFound,
|
||||
component: () => (
|
||||
<BillingProvider>
|
||||
<Outlet />
|
||||
<DevtoolsToggle />
|
||||
</BillingProvider>
|
||||
),
|
||||
})
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
67
packages/web/src/routes/api/auth/$.ts
Normal file
67
packages/web/src/routes/api/auth/$.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
|
||||
export const Route = createFileRoute("/api/auth/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
console.log("[api/auth] GET request:", request.url)
|
||||
try {
|
||||
const auth = getAuth()
|
||||
console.log("[api/auth] Auth instance created")
|
||||
const response = await auth.handler(request)
|
||||
console.log("[api/auth] Response status:", response.status)
|
||||
// Log response body for debugging
|
||||
if (response.status >= 400) {
|
||||
const cloned = response.clone()
|
||||
const body = await cloned.text()
|
||||
console.log("[api/auth] Error response body:", body)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[api/auth] GET error:", error)
|
||||
console.error("[api/auth] GET error stack:", error instanceof Error ? error.stack : "no stack")
|
||||
return new Response(JSON.stringify({ error: String(error) }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
},
|
||||
POST: async ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
console.log("[api/auth] POST request:", url.pathname)
|
||||
|
||||
// Clone request to read body for logging
|
||||
const clonedReq = request.clone()
|
||||
try {
|
||||
const bodyText = await clonedReq.text()
|
||||
console.log("[api/auth] POST body:", bodyText)
|
||||
} catch {
|
||||
console.log("[api/auth] Could not read body")
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = getAuth()
|
||||
console.log("[api/auth] Auth instance created, calling handler...")
|
||||
const response = await auth.handler(request)
|
||||
console.log("[api/auth] Response status:", response.status)
|
||||
|
||||
// Log response body for debugging
|
||||
if (response.status >= 400) {
|
||||
const cloned = response.clone()
|
||||
const body = await cloned.text()
|
||||
console.log("[api/auth] Error response body:", body)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[api/auth] POST error:", error)
|
||||
console.error("[api/auth] POST error stack:", error instanceof Error ? error.stack : "no stack")
|
||||
return new Response(JSON.stringify({ error: String(error) }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
141
packages/web/src/routes/api/browser-sessions.$sessionId.ts
Normal file
141
packages/web/src/routes/api/browser-sessions.$sessionId.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { browser_sessions, browser_session_tabs } from "@/db/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
const jsonResponse = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/api/browser-sessions/$sessionId")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { sessionId: string }
|
||||
}) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const { sessionId } = params
|
||||
|
||||
// Get session
|
||||
const [browserSession] = await db()
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, sessionId),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!browserSession) {
|
||||
return jsonResponse({ error: "Session not found" }, 404)
|
||||
}
|
||||
|
||||
// Get tabs
|
||||
const tabs = await db()
|
||||
.select()
|
||||
.from(browser_session_tabs)
|
||||
.where(eq(browser_session_tabs.session_id, sessionId))
|
||||
.orderBy(browser_session_tabs.position)
|
||||
|
||||
return jsonResponse({ session: browserSession, tabs })
|
||||
},
|
||||
|
||||
PATCH: async ({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { sessionId: string }
|
||||
}) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const { sessionId } = params
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
name?: string
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const [existing] = await db()
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, sessionId),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return jsonResponse({ error: "Session not found" }, 404)
|
||||
}
|
||||
|
||||
// Build update
|
||||
const updates: Partial<{ name: string; is_favorite: boolean }> = {}
|
||||
if (body.name !== undefined) updates.name = body.name
|
||||
if (body.is_favorite !== undefined) updates.is_favorite = body.is_favorite
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return jsonResponse({ error: "No updates provided" }, 400)
|
||||
}
|
||||
|
||||
const [updated] = await db()
|
||||
.update(browser_sessions)
|
||||
.set(updates)
|
||||
.where(eq(browser_sessions.id, sessionId))
|
||||
.returning()
|
||||
|
||||
return jsonResponse({ session: updated })
|
||||
},
|
||||
|
||||
DELETE: async ({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { sessionId: string }
|
||||
}) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const { sessionId } = params
|
||||
|
||||
await db()
|
||||
.delete(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, sessionId),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
|
||||
return jsonResponse({ success: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
364
packages/web/src/routes/api/browser-sessions.ts
Normal file
364
packages/web/src/routes/api/browser-sessions.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { browser_sessions, browser_session_tabs } from "@/db/schema"
|
||||
import { eq, and, desc, ilike, or, sql } from "drizzle-orm"
|
||||
|
||||
interface TabInput {
|
||||
title: string
|
||||
url: string
|
||||
favicon_url?: string
|
||||
}
|
||||
|
||||
interface SaveSessionBody {
|
||||
action: "save"
|
||||
name: string
|
||||
browser?: string
|
||||
tabs: TabInput[]
|
||||
captured_at?: string // ISO date string
|
||||
}
|
||||
|
||||
interface ListSessionsBody {
|
||||
action: "list"
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
interface GetSessionBody {
|
||||
action: "get"
|
||||
session_id: string
|
||||
}
|
||||
|
||||
interface UpdateSessionBody {
|
||||
action: "update"
|
||||
session_id: string
|
||||
name?: string
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
interface DeleteSessionBody {
|
||||
action: "delete"
|
||||
session_id: string
|
||||
}
|
||||
|
||||
interface SearchTabsBody {
|
||||
action: "searchTabs"
|
||||
query: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
type RequestBody =
|
||||
| SaveSessionBody
|
||||
| ListSessionsBody
|
||||
| GetSessionBody
|
||||
| UpdateSessionBody
|
||||
| DeleteSessionBody
|
||||
| SearchTabsBody
|
||||
|
||||
const jsonResponse = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/api/browser-sessions")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const database = db()
|
||||
const body = (await request.json().catch(() => ({}))) as RequestBody
|
||||
|
||||
try {
|
||||
switch (body.action) {
|
||||
case "save": {
|
||||
const { name, browser = "safari", tabs, captured_at } = body
|
||||
|
||||
if (!name || !tabs || !Array.isArray(tabs)) {
|
||||
return jsonResponse({ error: "Missing name or tabs" }, 400)
|
||||
}
|
||||
|
||||
// Create session
|
||||
const [newSession] = await database
|
||||
.insert(browser_sessions)
|
||||
.values({
|
||||
user_id: session.user.id,
|
||||
name,
|
||||
browser,
|
||||
tab_count: tabs.length,
|
||||
captured_at: captured_at ? new Date(captured_at) : new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Insert tabs
|
||||
if (tabs.length > 0) {
|
||||
await database.insert(browser_session_tabs).values(
|
||||
tabs.map((tab, index) => ({
|
||||
session_id: newSession.id,
|
||||
title: tab.title || "",
|
||||
url: tab.url,
|
||||
position: index,
|
||||
favicon_url: tab.favicon_url,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return jsonResponse({ session: newSession })
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const page = Math.max(1, body.page || 1)
|
||||
const limit = Math.min(100, Math.max(1, body.limit || 50))
|
||||
const offset = (page - 1) * limit
|
||||
const search = body.search?.trim()
|
||||
|
||||
// Build query
|
||||
let query = database
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(eq(browser_sessions.user_id, session.user.id))
|
||||
.orderBy(desc(browser_sessions.captured_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
if (search) {
|
||||
query = database
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
ilike(browser_sessions.name, `%${search}%`),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(browser_sessions.captured_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
}
|
||||
|
||||
const sessions = await query
|
||||
|
||||
// Get total count
|
||||
const [countResult] = await database
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
search
|
||||
? and(
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
ilike(browser_sessions.name, `%${search}%`),
|
||||
)
|
||||
: eq(browser_sessions.user_id, session.user.id),
|
||||
)
|
||||
|
||||
const total = Number(countResult?.count || 0)
|
||||
|
||||
return jsonResponse({
|
||||
sessions,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case "get": {
|
||||
const { session_id } = body
|
||||
|
||||
if (!session_id) {
|
||||
return jsonResponse({ error: "Missing session_id" }, 400)
|
||||
}
|
||||
|
||||
// Get session
|
||||
const [browserSession] = await database
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, session_id),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!browserSession) {
|
||||
return jsonResponse({ error: "Session not found" }, 404)
|
||||
}
|
||||
|
||||
// Get tabs
|
||||
const tabs = await database
|
||||
.select()
|
||||
.from(browser_session_tabs)
|
||||
.where(eq(browser_session_tabs.session_id, session_id))
|
||||
.orderBy(browser_session_tabs.position)
|
||||
|
||||
return jsonResponse({ session: browserSession, tabs })
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const { session_id, name, is_favorite } = body
|
||||
|
||||
if (!session_id) {
|
||||
return jsonResponse({ error: "Missing session_id" }, 400)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const [existing] = await database
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, session_id),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return jsonResponse({ error: "Session not found" }, 404)
|
||||
}
|
||||
|
||||
// Build update
|
||||
const updates: Partial<{
|
||||
name: string
|
||||
is_favorite: boolean
|
||||
}> = {}
|
||||
if (name !== undefined) updates.name = name
|
||||
if (is_favorite !== undefined) updates.is_favorite = is_favorite
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return jsonResponse({ error: "No updates provided" }, 400)
|
||||
}
|
||||
|
||||
const [updated] = await database
|
||||
.update(browser_sessions)
|
||||
.set(updates)
|
||||
.where(eq(browser_sessions.id, session_id))
|
||||
.returning()
|
||||
|
||||
return jsonResponse({ session: updated })
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const { session_id } = body
|
||||
|
||||
if (!session_id) {
|
||||
return jsonResponse({ error: "Missing session_id" }, 400)
|
||||
}
|
||||
|
||||
// Delete (cascade will handle tabs)
|
||||
await database
|
||||
.delete(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, session_id),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
case "searchTabs": {
|
||||
const { query, limit = 100 } = body
|
||||
|
||||
if (!query?.trim()) {
|
||||
return jsonResponse({ error: "Missing query" }, 400)
|
||||
}
|
||||
|
||||
const searchTerm = `%${query.trim()}%`
|
||||
|
||||
// Search tabs across user's sessions
|
||||
const tabs = await database
|
||||
.select({
|
||||
tab: browser_session_tabs,
|
||||
session: browser_sessions,
|
||||
})
|
||||
.from(browser_session_tabs)
|
||||
.innerJoin(
|
||||
browser_sessions,
|
||||
eq(browser_session_tabs.session_id, browser_sessions.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
or(
|
||||
ilike(browser_session_tabs.title, searchTerm),
|
||||
ilike(browser_session_tabs.url, searchTerm),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(browser_sessions.captured_at))
|
||||
.limit(Math.min(limit, 500))
|
||||
|
||||
return jsonResponse({
|
||||
results: tabs.map((t) => ({
|
||||
...t.tab,
|
||||
session_name: t.session.name,
|
||||
session_captured_at: t.session.captured_at,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
return jsonResponse({ error: "Unknown action" }, 400)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[browser-sessions] error", error)
|
||||
return jsonResponse({ error: "Operation failed" }, 500)
|
||||
}
|
||||
},
|
||||
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"))
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, parseInt(url.searchParams.get("limit") || "50")),
|
||||
)
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const sessions = await db()
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(eq(browser_sessions.user_id, session.user.id))
|
||||
.orderBy(desc(browser_sessions.captured_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const [countResult] = await db()
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(browser_sessions)
|
||||
.where(eq(browser_sessions.user_id, session.user.id))
|
||||
|
||||
const total = Number(countResult?.count || 0)
|
||||
|
||||
return jsonResponse({
|
||||
sessions,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
46
packages/web/src/routes/api/canvas.$canvasId.ts
Normal file
46
packages/web/src/routes/api/canvas.$canvasId.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import {
|
||||
getCanvasOwner,
|
||||
getCanvasSnapshotById,
|
||||
} from "@/lib/canvas/db"
|
||||
import { resolveCanvasUser } from "@/lib/canvas/user-session"
|
||||
|
||||
const json = (data: unknown, status = 200, setCookie?: string) => {
|
||||
const headers = new Headers({ "content-type": "application/json" })
|
||||
if (setCookie) {
|
||||
headers.set("set-cookie", setCookie)
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/canvas/$canvasId")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const canvasId = params.canvasId
|
||||
|
||||
const owner = await getCanvasOwner(canvasId)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
const snapshot = await getCanvasSnapshotById(canvasId)
|
||||
if (!snapshot) {
|
||||
return json({ error: "Not found" }, 404, setCookie)
|
||||
}
|
||||
|
||||
return json(snapshot, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/:canvasId] GET", error)
|
||||
return json({ error: "Failed to load canvas" }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
178
packages/web/src/routes/api/canvas.images.$imageId.generate.ts
Normal file
178
packages/web/src/routes/api/canvas.images.$imageId.generate.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import {
|
||||
getCanvasImageRecord,
|
||||
getCanvasOwner,
|
||||
updateCanvasImage,
|
||||
} from "@/lib/canvas/db"
|
||||
import { generateGeminiImage, DEFAULT_GEMINI_IMAGE_MODEL } from "@/lib/ai/gemini-image"
|
||||
import { generateOpenAIImage } from "@/lib/ai/openai-image"
|
||||
import { resolveCanvasUser } from "@/lib/canvas/user-session"
|
||||
import { STYLE_PRESETS } from "@/features/canvas/styles-presets"
|
||||
import { checkUsageAllowed, recordUsage } from "@/lib/billing"
|
||||
|
||||
const json = (data: unknown, status = 200, setCookie?: string) => {
|
||||
const headers = new Headers({ "content-type": "application/json" })
|
||||
if (setCookie) {
|
||||
headers.set("set-cookie", setCookie)
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const applyStylePrompt = (styleId: string | null | undefined, prompt: string) => {
|
||||
if (!styleId || styleId === "default") {
|
||||
return { resolvedStyleId: "default", prompt: prompt.trim() }
|
||||
}
|
||||
const preset = STYLE_PRESETS.find((item) => item.id === styleId)
|
||||
if (!preset || preset.id === "default") {
|
||||
return { resolvedStyleId: preset?.id ?? "default", prompt: prompt.trim() }
|
||||
}
|
||||
const stylePrompt = preset.prompt.trim()
|
||||
const basePrompt = prompt.trim()
|
||||
const combined = stylePrompt ? `${stylePrompt}\n\n${basePrompt}` : basePrompt
|
||||
return { resolvedStyleId: preset.id, prompt: combined }
|
||||
}
|
||||
|
||||
const normalizeGeminiModelId = (modelId?: string | null) => {
|
||||
if (!modelId) return DEFAULT_GEMINI_IMAGE_MODEL
|
||||
if (
|
||||
modelId.includes("gemini-2.0-flash-exp-image-generation") ||
|
||||
modelId === "gemini-1.5-flash" ||
|
||||
modelId === "gemini-1.5-flash-latest"
|
||||
) {
|
||||
return DEFAULT_GEMINI_IMAGE_MODEL
|
||||
}
|
||||
return modelId
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/canvas/images/$imageId/generate")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const imageId = params.imageId
|
||||
const record = await getCanvasImageRecord(imageId)
|
||||
if (!record) {
|
||||
return json({ error: "Not found" }, 404, setCookie)
|
||||
}
|
||||
|
||||
const owner = await getCanvasOwner(record.canvas_id)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
// Check usage limits
|
||||
const usageCheck = await checkUsageAllowed(request)
|
||||
if (!usageCheck.allowed) {
|
||||
return json(
|
||||
{
|
||||
error: "Usage limit exceeded",
|
||||
reason: usageCheck.reason,
|
||||
remaining: usageCheck.remaining,
|
||||
limit: usageCheck.limit,
|
||||
},
|
||||
429,
|
||||
setCookie,
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const prompt =
|
||||
typeof body.prompt === "string" && body.prompt.trim().length > 0
|
||||
? body.prompt
|
||||
: record.prompt
|
||||
|
||||
if (!prompt || !prompt.trim()) {
|
||||
return json({ error: "Prompt required" }, 400, setCookie)
|
||||
}
|
||||
|
||||
const basePrompt = prompt.trim()
|
||||
const modelId =
|
||||
typeof body.modelId === "string" && body.modelId.trim().length > 0
|
||||
? body.modelId
|
||||
: record.model_id
|
||||
const styleId =
|
||||
typeof body.styleId === "string" && body.styleId.trim().length > 0
|
||||
? body.styleId
|
||||
: record.style_id
|
||||
|
||||
const { prompt: styledPrompt, resolvedStyleId } = applyStylePrompt(styleId, basePrompt)
|
||||
|
||||
const temperature =
|
||||
typeof body.temperature === "number" && Number.isFinite(body.temperature)
|
||||
? body.temperature
|
||||
: undefined
|
||||
|
||||
const provider = modelId?.includes("gpt-image") || modelId?.includes("dall") ? "openai" : "gemini"
|
||||
const resolvedModelId =
|
||||
provider === "gemini" ? normalizeGeminiModelId(modelId) : modelId ?? undefined
|
||||
|
||||
let generation: {
|
||||
base64: string
|
||||
mimeType: string
|
||||
description?: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
if (provider === "openai") {
|
||||
const result = await generateOpenAIImage({
|
||||
prompt: styledPrompt,
|
||||
model: resolvedModelId,
|
||||
})
|
||||
generation = {
|
||||
base64: result.base64Image,
|
||||
mimeType: result.mimeType,
|
||||
description: result.revisedPrompt ?? styledPrompt,
|
||||
provider: "openai.dall-e-3",
|
||||
}
|
||||
} else {
|
||||
const result = await generateGeminiImage({
|
||||
prompt: styledPrompt,
|
||||
model: resolvedModelId,
|
||||
temperature,
|
||||
})
|
||||
generation = {
|
||||
base64: result.base64Image,
|
||||
mimeType: result.mimeType,
|
||||
description: styledPrompt,
|
||||
provider: "google.gemini",
|
||||
}
|
||||
}
|
||||
|
||||
const image = await updateCanvasImage({
|
||||
imageId,
|
||||
data: {
|
||||
prompt: basePrompt,
|
||||
modelId: provider === "gemini" ? resolvedModelId : modelId ?? record.model_id,
|
||||
modelUsed: provider === "gemini" ? resolvedModelId : modelId ?? record.model_id,
|
||||
styleId: resolvedStyleId,
|
||||
imageDataBase64: generation.base64,
|
||||
metadata: {
|
||||
provider: generation.provider,
|
||||
mimeType: generation.mimeType,
|
||||
description: generation.description ?? styledPrompt,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Record usage for paid users
|
||||
await recordUsage(request, 1, `canvas-${imageId}-${Date.now()}`)
|
||||
|
||||
return json({ image }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/images/:id/generate] POST", error)
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Gemini generation failed"
|
||||
return json({ error: message }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
96
packages/web/src/routes/api/canvas.images.$imageId.ts
Normal file
96
packages/web/src/routes/api/canvas.images.$imageId.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { resolveCanvasUser } from "@/lib/canvas/user-session"
|
||||
import {
|
||||
deleteCanvasImage,
|
||||
getCanvasImageRecord,
|
||||
getCanvasOwner,
|
||||
updateCanvasImage,
|
||||
} from "@/lib/canvas/db"
|
||||
|
||||
const json = (data: unknown, status = 200, setCookie?: string) => {
|
||||
const headers = new Headers({ "content-type": "application/json" })
|
||||
if (setCookie) {
|
||||
headers.set("set-cookie", setCookie)
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/canvas/images/$imageId")({
|
||||
server: {
|
||||
handlers: {
|
||||
PATCH: async ({ request, params }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const imageId = params.imageId
|
||||
const record = await getCanvasImageRecord(imageId)
|
||||
if (!record) {
|
||||
return json({ error: "Not found" }, 404, setCookie)
|
||||
}
|
||||
|
||||
const owner = await getCanvasOwner(record.canvas_id)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const image = await updateCanvasImage({
|
||||
imageId,
|
||||
data: {
|
||||
name: typeof body.name === "string" ? body.name : undefined,
|
||||
prompt: typeof body.prompt === "string" ? body.prompt : undefined,
|
||||
modelId: typeof body.modelId === "string" ? body.modelId : undefined,
|
||||
styleId: typeof body.styleId === "string" ? body.styleId : undefined,
|
||||
position:
|
||||
body.position &&
|
||||
typeof body.position.x === "number" &&
|
||||
typeof body.position.y === "number"
|
||||
? { x: body.position.x, y: body.position.y }
|
||||
: undefined,
|
||||
size:
|
||||
body.size &&
|
||||
typeof body.size.width === "number" &&
|
||||
typeof body.size.height === "number"
|
||||
? { width: body.size.width, height: body.size.height }
|
||||
: undefined,
|
||||
rotation:
|
||||
typeof body.rotation === "number" && Number.isFinite(body.rotation)
|
||||
? body.rotation
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
return json({ image }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/images/:id] PATCH", error)
|
||||
return json({ error: "Failed to update image" }, 500)
|
||||
}
|
||||
},
|
||||
DELETE: async ({ request, params }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const imageId = params.imageId
|
||||
const record = await getCanvasImageRecord(imageId)
|
||||
if (!record) {
|
||||
return json({ error: "Not found" }, 404, setCookie)
|
||||
}
|
||||
|
||||
const owner = await getCanvasOwner(record.canvas_id)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
await deleteCanvasImage(imageId)
|
||||
return json({ id: imageId }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/images/:id] DELETE", error)
|
||||
return json({ error: "Failed to delete image" }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user