Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 26 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ Drag fields onto an open canvas, connect them like nodes, watch responses light

## Tech stack

| Layer | Choice |
| --- | --- |
| Monorepo | Turborepo + pnpm workspaces |
| Web app | Next.js 16 (App Router, Turbopack), React 19, Tailwind v4, Radix UI primitives, Motion |
| Canvas builder | `@xyflow/react` |
| Charts | Recharts (lazy-loaded per route) |
| API | Express 5 + tRPC 11 (OpenAPI generated via `trpc-to-openapi`) |
| Auth | Better Auth with email/password, Google OAuth, GitHub OAuth, signed cookie cache |
| DB | PostgreSQL via Drizzle ORM, `pg` connection pool, migrations via `drizzle-kit` |
| Validation | Zod (shared between client, server, and OpenAPI schema) |
| State / data | TanStack Query (tRPC React Query adapter) |
| Logging | Winston |
| Layer | Choice |
| -------------- | -------------------------------------------------------------------------------------- |
| Monorepo | Turborepo + pnpm workspaces |
| Web app | Next.js 16 (App Router, Turbopack), React 19, Tailwind v4, Radix UI primitives, Motion |
| Canvas builder | `@xyflow/react` |
| Charts | Recharts (lazy-loaded per route) |
| API | Express 5 + tRPC 11 (OpenAPI generated via `trpc-to-openapi`) |
| Auth | Better Auth with email/password, Google OAuth, GitHub OAuth, signed cookie cache |
| DB | PostgreSQL via Drizzle ORM, `pg` connection pool, migrations via `drizzle-kit` |
| Validation | Zod (shared between client, server, and OpenAPI schema) |
| State / data | TanStack Query (tRPC React Query adapter) |
| Logging | Winston |

## Architecture

Expand Down Expand Up @@ -123,28 +123,28 @@ pnpm dev

This boots, in parallel:

| URL | What |
| --- | --- |
| `http://localhost:3000` | Next.js studio (web) |
| `http://localhost:8000/trpc` | tRPC HTTP endpoint |
| `http://localhost:8000/docs` | Auto-generated Scalar API reference |
| `http://localhost:8000/openapi.json` | OpenAPI 3.1 spec |
| URL | What |
| ------------------------------------ | ----------------------------------- |
| `http://localhost:3000` | Next.js studio (web) |
| `http://localhost:8000/trpc` | tRPC HTTP endpoint |
| `http://localhost:8000/docs` | Auto-generated Scalar API reference |
| `http://localhost:8000/openapi.json` | OpenAPI 3.1 spec |

Sign up at `/signUp`, build a form, publish it, share `/forms/<id>`, watch responses populate `/dashboard/analytics`.

## Scripts

All scripts are turbo-orchestrated and `dotenv -- ...` wrapped so workspaces share the root env.

| Script | What it does |
| --- | --- |
| `pnpm dev` | Run every workspace's dev task |
| `pnpm build` | Build the API and the web app for production |
| `pnpm lint` | ESLint across all workspaces (zero-warning) |
| `pnpm check-types` | TypeScript no-emit type-check |
| `pnpm format` | Prettier across `**/*.{ts,tsx,md}` |
| Script | What it does |
| ------------------ | ------------------------------------------------ |
| `pnpm dev` | Run every workspace's dev task |
| `pnpm build` | Build the API and the web app for production |
| `pnpm lint` | ESLint across all workspaces (zero-warning) |
| `pnpm check-types` | TypeScript no-emit type-check |
| `pnpm format` | Prettier across `**/*.{ts,tsx,md}` |
| `pnpm db:generate` | Generate a Drizzle migration from schema changes |
| `pnpm db:migrate` | Apply pending migrations |
| `pnpm db:migrate` | Apply pending migrations |

Filter to a single workspace with `pnpm -F web <script>` or `pnpm -F @repo/api <script>`.

Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";

import * as trpcExpress from "@trpc/server/adapters/express";
import { generateOpenApiDocument, createOpenApiExpressMiddleware } from "trpc-to-openapi";
import cookieParser from 'cookie-parser'
import cookieParser from "cookie-parser";

import { serverRouter, createContext } from "@repo/trpc/server";
import { auth } from "@repo/trpc/server/auth";
Expand Down Expand Up @@ -73,7 +73,7 @@ app.use(
if (req.headers["x-no-compression"]) return false;
return compression.filter(req, res);
},
})
}),
);

app.use(cookieParser());
Expand Down Expand Up @@ -125,7 +125,7 @@ const authGlobalLimiter = rateLimit({
// HTTP path includes the procedure name, so we can match on it.
app.use(
["/trpc/analytics.recordView", "/trpc/analytics.recordFieldAnswer", "/trpc/form.submitForm"],
publicWriteLimiter
publicWriteLimiter,
);

// Better Auth handler must be mounted BEFORE express.json().
Expand Down
4 changes: 2 additions & 2 deletions apps/api/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export default defineConfig({
minify: false,
sourcemap: false,
banner: {
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`
}
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`,
},
});
16 changes: 4 additions & 12 deletions apps/web/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,8 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
className="relative inline-flex items-center gap-3 z-10"
aria-label="CanvasFlow home"
>
<Image
src="/logo.svg"
alt=""
width={32}
height={32}
className="object-contain"
/>
<span className="cf-display text-[22px] leading-none">
CanvasFlow
</span>
<Image src="/logo.svg" alt="" width={32} height={32} className="object-contain" />
<span className="cf-display text-[22px] leading-none">CanvasFlow</span>
</Link>

{/* big serif quote */}
Expand All @@ -50,8 +42,8 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
<span className="block">in golden hour.</span>
</h2>
<p className="mt-5 max-w-sm text-[14.5px] leading-relaxed text-[color:var(--cf-ink-soft)]">
A studio for makers of beautiful data. Sketch on an open canvas,
ship durable forms, watch responses land in real time.
A studio for makers of beautiful data. Sketch on an open canvas, ship durable forms,
watch responses land in real time.
</p>
</div>

Expand Down
38 changes: 20 additions & 18 deletions apps/web/app/(auth)/signIn/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,13 @@ export default function SignInPage() {
<div className="flex flex-col justify-between h-full w-full">
{/* top eyebrow */}
<div className="flex justify-between items-center">
<span className="cf-eyebrow text-[color:var(--cf-ink-soft)]">
Studio · Sign in
</span>
<span className="cf-eyebrow text-[color:var(--cf-ink-soft)]">Studio · Sign in</span>
</div>

{/* form */}
<div className="my-auto py-8 w-full">
<div className="mx-auto w-full max-w-md">
<h1 className="cf-display text-[40px] sm:text-[48px] leading-[0.95]">
Welcome back
</h1>
<h1 className="cf-display text-[40px] sm:text-[48px] leading-[0.95]">Welcome back</h1>
<p className="mt-3 text-[14.5px] leading-relaxed text-[color:var(--cf-ink-soft)]">
Pick up where you left off. Sign in to open your studio.
</p>
Expand Down Expand Up @@ -165,21 +161,15 @@ function Field({
}) {
return (
<div>
<label className="cf-eyebrow text-[color:var(--cf-ink-soft)] mb-2 block">
{label}
</label>
<label className="cf-eyebrow text-[color:var(--cf-ink-soft)] mb-2 block">{label}</label>
<input
type={type}
placeholder={placeholder}
autoComplete={autoComplete}
{...register}
className="w-full bg-white rounded-md ring-1 ring-[color:var(--cf-line-strong)] focus:ring-2 focus:ring-[color:var(--cf-orange)] focus:outline-none px-4 h-[46px] text-[14.5px] text-[color:var(--cf-ink)] placeholder:text-[color:var(--cf-ink-soft)]/55 transition-shadow"
/>
{error && (
<p className="text-[12px] text-[color:var(--cf-orange)] mt-1.5 ml-0.5">
{error}
</p>
)}
{error && <p className="text-[12px] text-[color:var(--cf-orange)] mt-1.5 ml-0.5">{error}</p>}
</div>
);
}
Expand All @@ -204,10 +194,22 @@ function SocialButton({
>
{provider === "google" ? (
<svg className="size-4" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
) : (
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
Expand Down
41 changes: 21 additions & 20 deletions apps/web/app/(auth)/signUp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,15 @@ export default function SignUpPage() {
<div className="flex flex-col justify-between h-full w-full">
{/* top eyebrow */}
<div className="flex justify-between items-center">
<span className="cf-eyebrow text-[color:var(--cf-ink-soft)]">
Studio · Sign up
</span>
<span className="cf-eyebrow text-[color:var(--cf-ink-soft)]">Studio · Sign up</span>
</div>

{/* form */}
<div className="my-auto py-8 w-full">
<div className="mx-auto w-full max-w-md">
<h1 className="cf-display text-[40px] sm:text-[48px] leading-[0.95]">
Open your studio
</h1>
<h1 className="cf-display text-[40px] sm:text-[48px] leading-[0.95]">Open your studio</h1>
<p className="mt-3 text-[14.5px] leading-relaxed text-[color:var(--cf-ink-soft)]">
Free to start, no card required. Sketch on an open canvas in
minutes.
Free to start, no card required. Sketch on an open canvas in minutes.
</p>

<form onSubmit={handleSubmit(onSubmit)} className="mt-9 space-y-5">
Expand Down Expand Up @@ -176,21 +171,15 @@ function Field({
}) {
return (
<div>
<label className="cf-eyebrow text-[color:var(--cf-ink-soft)] mb-2 block">
{label}
</label>
<label className="cf-eyebrow text-[color:var(--cf-ink-soft)] mb-2 block">{label}</label>
<input
type={type}
placeholder={placeholder}
autoComplete={autoComplete}
{...register}
className="w-full bg-white rounded-md ring-1 ring-[color:var(--cf-line-strong)] focus:ring-2 focus:ring-[color:var(--cf-orange)] focus:outline-none px-4 h-[46px] text-[14.5px] text-[color:var(--cf-ink)] placeholder:text-[color:var(--cf-ink-soft)]/55 transition-shadow"
/>
{error && (
<p className="text-[12px] text-[color:var(--cf-orange)] mt-1.5 ml-0.5">
{error}
</p>
)}
{error && <p className="text-[12px] text-[color:var(--cf-orange)] mt-1.5 ml-0.5">{error}</p>}
</div>
);
}
Expand All @@ -215,10 +204,22 @@ function SocialButton({
>
{provider === "google" ? (
<svg className="size-4" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
) : (
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
Expand Down
Loading
Loading