diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..36a756870
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,53 @@
+# Dependencies & Package Managers
+node_modules/
+.pnpm-store
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# Coverage
+coverage/
+
+# Build Output
+dist/
+
+# Production / Deployment
+.vercel/
+.turbo/
+
+# Environment Variables
+.env
+.env.*
+!.env.example
+
+# Logs & Debug
+logs/
+*.log
+*.log.*
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+lerna-debug.log*
+
+# TypeScript
+*.tsbuildinfo
+
+# IDE & System Files
+.idea/
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Miscellaneous
+*.pem
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..ded82e2f6
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+auto-install-peers = true
diff --git a/apps/backend/.env.example b/apps/backend/.env.example
new file mode 100644
index 000000000..98d51eb37
--- /dev/null
+++ b/apps/backend/.env.example
@@ -0,0 +1,24 @@
+NODE_ENV=development
+
+# Server
+PORT=3000
+HOST=0.0.0.0
+
+# URLs
+FRONTEND_URL=http://localhost:5173
+BACKEND_URL=http://localhost:3000
+
+# Database
+MONGODB_URI=mongodb://localhost:27017/
+DATABASE_NAME=ViNotes
+
+# Auth
+AUTH_GOOGLE_ID=your_google_client_id
+AUTH_GOOGLE_SECRET=your_google_client_secret
+BETTER_AUTH_SECRET=your_better_auth_secret_32_chars_min
+
+# SMTP for sending emails
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_EMAIL=your_smtp_email@example.com
+SMTP_EMAIL_PASSWORD=your_email_password
diff --git a/apps/backend/eslint.config.mjs b/apps/backend/eslint.config.mjs
new file mode 100644
index 000000000..9363ac25e
--- /dev/null
+++ b/apps/backend/eslint.config.mjs
@@ -0,0 +1,15 @@
+import config from "@repo/eslint-config/node";
+
+export default [
+ ...config,
+
+ {
+ files: ["**/*.ts"],
+ languageOptions: {
+ parserOptions: {
+ project: "./tsconfig.json",
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ },
+];
\ No newline at end of file
diff --git a/apps/backend/package.json b/apps/backend/package.json
new file mode 100644
index 000000000..8aa365dc0
--- /dev/null
+++ b/apps/backend/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "backend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch --env-file=.env src/index.ts",
+ "build": "tsup",
+ "start": "node --env-file=.env dist/index.js",
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@repo/auth": "workspace:*",
+ "cors": "^2.8.6",
+ "express": "^5.2.1",
+ "express-rate-limit": "^8.3.2",
+ "mongodb": "^7.1.1",
+ "mongoose": "^9.4.1",
+ "nodemailer": "^8.0.4"
+ },
+ "devDependencies": {
+ "@repo/eslint-config": "workspace:*",
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.6",
+ "@types/nodemailer": "^8.0.0",
+ "tsup": "^8.5.1",
+ "tsx": "^4.21.0"
+ }
+}
diff --git a/apps/backend/src/auth.ts b/apps/backend/src/auth.ts
new file mode 100644
index 000000000..a9c724823
--- /dev/null
+++ b/apps/backend/src/auth.ts
@@ -0,0 +1,91 @@
+import { betterAuth } from "better-auth";
+import { mongodbAdapter } from "better-auth/adapters/mongodb";
+import { twoFactor, emailOTP, admin as adminPlugin } from "better-auth/plugins";
+import { getMongoClient } from "./lib/mongodb";
+import { ENV } from "./env";
+import { sendNoReplyMail } from "./lib/sendMail";
+import { ac, roles } from "@repo/auth";
+
+// MongoDB
+const db = getMongoClient(ENV.MONGODB_URI).db(ENV.DATABASE_NAME);
+
+// Auth config
+export const auth = betterAuth({
+ appName: "Vi Notes",
+ baseURL: ENV.BACKEND_URL,
+ trustedOrigins: [ENV.FRONTEND_URL],
+
+ database: mongodbAdapter(db),
+
+ account: {
+ accountLinking: { enabled: true },
+ },
+
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: true,
+ onExistingUserSignUp: async ({ user }) => {
+ await sendNoReplyMail({
+ sendTo: user.email,
+ subject: "Sign-up attempt with your email",
+ html: "Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.",
+ });
+ },
+ },
+
+ socialProviders: {
+ google: {
+ clientId: ENV.AUTH_GOOGLE_ID,
+ clientSecret: ENV.AUTH_GOOGLE_SECRET,
+ },
+ },
+
+ emailVerification: {
+ sendVerificationEmail: async ({ user, url }) => {
+ try {
+ await sendNoReplyMail({
+ sendTo: user.email,
+ subject: "Verify your email address",
+ html: `
+
Hello ${user.name ?? ""},
+ Please verify your email by clicking the link below:
+ ${url}
+ `,
+ });
+ } catch (error) {
+ console.error("Verify email send failed", error);
+ }
+ },
+
+ sendOnSignUp: true,
+ sendOnSignIn: true,
+
+ autoSignInAfterVerification: true,
+ },
+
+ plugins: [
+ twoFactor(),
+ emailOTP({
+ disableSignUp: false,
+
+ async sendVerificationOTP({ email, otp, type }) {
+ let subject = "Your verification code";
+ const html = `${otp}`;
+
+ if (type === "sign-in") subject = "Sign-in verification code";
+ else if (type === "email-verification") subject = "Verify your email";
+ else if (type === "forget-password") subject = "Reset your password";
+
+ try {
+ await sendNoReplyMail({ sendTo: email, subject, html });
+ } catch (error) {
+ console.error("OTP email send failed", error);
+ }
+ },
+ }),
+
+ adminPlugin({ ac, roles }),
+ ],
+});
+
+export type Session = typeof auth.$Infer.Session;
diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts
new file mode 100644
index 000000000..0c553fc09
--- /dev/null
+++ b/apps/backend/src/env.ts
@@ -0,0 +1,40 @@
+import { z } from "zod";
+
+const EnvSchema = z.object({
+ NODE_ENV: z.enum(["development", "production", "test"]),
+
+ // Server
+ PORT: z.coerce.number().default(3000),
+ HOST: z.string().default("0.0.0.0"),
+
+ // URLs
+ FRONTEND_URL: z.url(),
+ BACKEND_URL: z.url(),
+
+ // Database
+ MONGODB_URI: z.url(),
+ DATABASE_NAME: z.string().default("ViNotes"),
+
+ // Auth
+ AUTH_GOOGLE_ID: z.string(),
+ AUTH_GOOGLE_SECRET: z.string(),
+ BETTER_AUTH_SECRET: z.string().min(32),
+
+ // SMTP for sending emails
+ SMTP_HOST: z.string(),
+ SMTP_PORT: z.coerce.number().default(465),
+ SMTP_EMAIL: z.email(),
+ SMTP_EMAIL_PASSWORD: z.string(),
+});
+
+const parsed = EnvSchema.safeParse(process.env);
+
+if (!parsed.success) {
+ console.error("β Invalid environment variables:");
+ console.error(z.treeifyError(parsed.error));
+ console.error(parsed.error.issues);
+ process.exit(1);
+}
+
+export const ENV = parsed.data;
+export type Env = typeof ENV;
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
new file mode 100644
index 000000000..54f11c6dc
--- /dev/null
+++ b/apps/backend/src/index.ts
@@ -0,0 +1,85 @@
+import express from "express";
+
+// Middleware
+import { errorMiddleware } from "./middleware/error.middleware";
+import { corsMiddleware } from "./middleware/cors.middleware";
+import { rateLimitMiddleware } from "./middleware/rateLimit.middleware";
+
+import registerRoutes from "./routes/index";
+import { toNodeHandler } from "better-auth/node";
+import { auth } from "./auth";
+import { ENV } from "./env";
+import { getLocalIP } from "./lib/getLocalIP";
+import { authMiddleware } from "./middleware/auth.middleware";
+import connectToDatabase from "./lib/mongoose";
+import {
+ startStaleWritingSessionSweep,
+ stopStaleWritingSessionSweep,
+} from "./services/writingSession.staleSweep";
+
+async function bootstrap() {
+ const app = express();
+
+ try {
+ await connectToDatabase();
+ startStaleWritingSessionSweep();
+ } catch (err) {
+ console.error("β Failed to connect DB:", err);
+ process.exit(1);
+ }
+
+ // CORS
+ app.use(corsMiddleware);
+
+ // Auth (must be before body parsing and other middlewares)
+ app.all("/api/auth/*splat", toNodeHandler(auth));
+
+ // Body parsing
+ app.use(express.json({ limit: "50mb" }));
+ app.use(express.urlencoded({ extended: true }));
+
+ // Middleware
+ app.use((req, res, next) => {
+ if (req.path.startsWith("/api/auth")) return next();
+ return rateLimitMiddleware(req, res, next);
+ });
+
+ // Attach user and session to req
+ app.use(authMiddleware);
+
+ // Routes
+ registerRoutes(app);
+
+ // Error handler must be last
+ app.use(errorMiddleware);
+
+ // Start server
+ const server = app.listen(ENV.PORT, ENV.HOST, () => {
+ console.info(`\nπ€ Server running on:`);
+
+ console.info(`β Local: http://localhost:${ENV.PORT}`);
+
+ if (ENV.HOST === "0.0.0.0") {
+ console.info(`β Network: http://${getLocalIP()}:${ENV.PORT}`);
+ } else {
+ console.info(`β Host: http://${ENV.HOST}:${ENV.PORT}`);
+ }
+ });
+
+ // Graceful shutdown
+ const shutdown = (signal: string) => {
+ console.info(`Received ${signal}, shutting down...`);
+
+ stopStaleWritingSessionSweep();
+
+ server.close(() => {
+ console.info("Server closed");
+ process.exit(0);
+ });
+ };
+
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
+ process.on("SIGINT", () => void shutdown("SIGINT"));
+}
+
+void bootstrap();
diff --git a/apps/backend/src/lib/getLocalIP.ts b/apps/backend/src/lib/getLocalIP.ts
new file mode 100644
index 000000000..38ca15d01
--- /dev/null
+++ b/apps/backend/src/lib/getLocalIP.ts
@@ -0,0 +1,13 @@
+import os from "os";
+
+export function getLocalIP() {
+ const nets = os.networkInterfaces();
+ for (const name of Object.keys(nets)) {
+ for (const net of nets[name] ?? []) {
+ if (net.family === "IPv4" && !net.internal) {
+ return net.address;
+ }
+ }
+ }
+ return "localhost";
+}
diff --git a/apps/backend/src/lib/mongodb.ts b/apps/backend/src/lib/mongodb.ts
new file mode 100644
index 000000000..ca434f173
--- /dev/null
+++ b/apps/backend/src/lib/mongodb.ts
@@ -0,0 +1,22 @@
+import { MongoClient } from "mongodb";
+
+const NODE_ENV = process.env.NODE_ENV;
+
+// Extend NodeJS global type to avoid TS errors in development
+declare global {
+ var _mongoClient: MongoClient | undefined;
+}
+
+let mongoClient: MongoClient | undefined;
+
+export function getMongoClient(uri: string) {
+ // In development, reuse global instance to prevent multiple connections on HMR
+ if (NODE_ENV === "development") {
+ global._mongoClient ??= new MongoClient(uri);
+ return global._mongoClient;
+ }
+
+ mongoClient ??= new MongoClient(uri);
+
+ return mongoClient;
+}
diff --git a/apps/backend/src/lib/mongoose.ts b/apps/backend/src/lib/mongoose.ts
new file mode 100644
index 000000000..97de282cb
--- /dev/null
+++ b/apps/backend/src/lib/mongoose.ts
@@ -0,0 +1,37 @@
+import { ENV } from "@/env";
+import mongoose from "mongoose";
+
+const uri = ENV.MONGODB_URI;
+const dbName = ENV.DATABASE_NAME;
+
+interface CachedConnection {
+ conn: typeof mongoose | null;
+ promise: Promise | null;
+}
+
+declare global {
+ var _db: CachedConnection | undefined;
+}
+
+const cached = global._db ?? { conn: null, promise: null };
+
+export default async function connectToDatabase() {
+ if (cached.conn) return cached.conn;
+
+ if (!cached.promise) {
+ console.info("π Connecting to DB...");
+
+ cached.promise = mongoose.connect(uri, { dbName, bufferCommands: false });
+ }
+
+ try {
+ cached.conn = await cached.promise;
+ console.info("β
Connected to DB");
+ } catch (error) {
+ cached.promise = null;
+ throw error;
+ }
+
+ global._db = cached;
+ return cached.conn;
+}
diff --git a/apps/backend/src/lib/sendMail.ts b/apps/backend/src/lib/sendMail.ts
new file mode 100644
index 000000000..4049d19cb
--- /dev/null
+++ b/apps/backend/src/lib/sendMail.ts
@@ -0,0 +1,61 @@
+import nodemailer from "nodemailer";
+import { ENV } from "../env";
+
+export async function sendNoReplyMail({
+ sendTo,
+ subject,
+ html,
+ fromName = "Vi Notes Team",
+}: {
+ sendTo: string;
+ subject: string;
+ html: string;
+ fromName?: string;
+}) {
+ if (!sendTo || !subject || !html) {
+ console.error("Missing Parameters in sendMail");
+ return { success: false, error: "Missing required parameters" };
+ }
+
+ const transporter = nodemailer.createTransport({
+ host: ENV.SMTP_HOST,
+ port: ENV.SMTP_PORT,
+ auth: {
+ user: ENV.SMTP_EMAIL,
+ pass: ENV.SMTP_EMAIL_PASSWORD,
+ },
+ });
+
+ try {
+ // Verify SMTP connection
+ await transporter.verify();
+
+ // Send email
+ const info = await transporter.sendMail({
+ from: `"${fromName}" <${ENV.SMTP_EMAIL}>`,
+ to: sendTo,
+ subject: subject,
+ html: html,
+ });
+ return { success: true, messageId: info.messageId };
+ } catch (error) {
+ console.error("Email sending failed:", error);
+
+ let errorMessage = "Something went wrong while sending the email.";
+
+ // Narrow unknown β object
+ if (typeof error === "object" && error !== null) {
+ const maybeErr = error as { responseCode?: number; message?: string };
+
+ if (maybeErr.responseCode === 550) {
+ errorMessage = "Invalid recipient email address.";
+ } else if (maybeErr.responseCode === 535) {
+ errorMessage = "Authentication failed. Check SMTP credentials.";
+ } else if (maybeErr.message?.includes("getaddrinfo ENOTFOUND")) {
+ errorMessage = "SMTP server not found. Check SMTP_HOST.";
+ }
+ }
+
+ return { success: false, error: errorMessage };
+ }
+}
diff --git a/apps/backend/src/middleware/auth.middleware.ts b/apps/backend/src/middleware/auth.middleware.ts
new file mode 100644
index 000000000..06294279b
--- /dev/null
+++ b/apps/backend/src/middleware/auth.middleware.ts
@@ -0,0 +1,21 @@
+import type { Request, Response, NextFunction } from "express";
+import { auth } from "../auth";
+import { fromNodeHeaders } from "better-auth/node";
+
+export async function authMiddleware(req: Request, res: Response, next: NextFunction) {
+ try {
+ const sessionData = await auth.api.getSession({
+ headers: fromNodeHeaders(req.headers),
+ });
+
+ req.session = sessionData?.session ?? null;
+ req.user = sessionData?.user ?? null;
+ } catch (error) {
+ console.error("Auth middleware error:", error);
+
+ req.session = null;
+ req.user = null;
+ }
+
+ next();
+}
diff --git a/apps/backend/src/middleware/cors.middleware.ts b/apps/backend/src/middleware/cors.middleware.ts
new file mode 100644
index 000000000..08c9dc8bb
--- /dev/null
+++ b/apps/backend/src/middleware/cors.middleware.ts
@@ -0,0 +1,7 @@
+import cors from "cors";
+import { ENV } from "../env";
+
+export const corsMiddleware = cors({
+ origin: [ENV.FRONTEND_URL, ENV.BACKEND_URL],
+ credentials: true,
+});
diff --git a/apps/backend/src/middleware/error.middleware.ts b/apps/backend/src/middleware/error.middleware.ts
new file mode 100644
index 000000000..7f1254c5a
--- /dev/null
+++ b/apps/backend/src/middleware/error.middleware.ts
@@ -0,0 +1,9 @@
+import type { Request, Response, NextFunction } from "express";
+import { ENV } from "../env";
+
+export const errorMiddleware = (err: Error, _req: Request, res: Response, _next: NextFunction) => {
+ console.error(err.stack);
+ res.status(500).json({
+ error: ENV.NODE_ENV === "production" ? "Internal server error" : err.message,
+ });
+};
diff --git a/apps/backend/src/middleware/rateLimit.middleware.ts b/apps/backend/src/middleware/rateLimit.middleware.ts
new file mode 100644
index 000000000..1d869caa7
--- /dev/null
+++ b/apps/backend/src/middleware/rateLimit.middleware.ts
@@ -0,0 +1,9 @@
+import { rateLimit } from "express-rate-limit";
+
+export const rateLimitMiddleware = rateLimit({
+ windowMs: 60 * 1000,
+ max: 100,
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: { error: "Too many requests, please try again later." },
+});
diff --git a/apps/backend/src/middleware/requireAuth.middleware.ts b/apps/backend/src/middleware/requireAuth.middleware.ts
new file mode 100644
index 000000000..f9b5edd62
--- /dev/null
+++ b/apps/backend/src/middleware/requireAuth.middleware.ts
@@ -0,0 +1,15 @@
+import type { Request, Response, NextFunction } from "express";
+
+export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
+ try {
+ if (!req.user) {
+ return res.status(401).json({ message: "Unauthorized" });
+ }
+
+ return next();
+ } catch (err) {
+ console.error("requireAuth error:", err);
+
+ return res.status(401).json({ message: "Unauthorized" });
+ }
+};
diff --git a/apps/backend/src/models/note.model.ts b/apps/backend/src/models/note.model.ts
new file mode 100644
index 000000000..0a5ad1ab5
--- /dev/null
+++ b/apps/backend/src/models/note.model.ts
@@ -0,0 +1,26 @@
+import mongoose, {
+ type HydratedDocument,
+ type InferSchemaType,
+ type Model,
+ Schema,
+} from "mongoose";
+
+const noteSchema = new Schema(
+ {
+ userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
+ title: { type: String, default: "" },
+ content: { type: String, default: "" },
+ wordCount: { type: Number, default: 0 },
+ favorite: { type: Boolean, default: false },
+ tags: { type: [String], default: [] },
+ },
+ { timestamps: true },
+);
+
+export type Note = InferSchemaType;
+export type NoteDocument = HydratedDocument;
+
+const Note: Model =
+ (mongoose.models.Note as Model) ?? mongoose.model("Note", noteSchema);
+
+export default Note;
diff --git a/apps/backend/src/models/writingSession.model.ts b/apps/backend/src/models/writingSession.model.ts
new file mode 100644
index 000000000..c92e49b4b
--- /dev/null
+++ b/apps/backend/src/models/writingSession.model.ts
@@ -0,0 +1,57 @@
+import mongoose, {
+ type HydratedDocument,
+ type InferSchemaType,
+ type Model,
+ Schema,
+} from "mongoose";
+
+const keystrokeTimingSchema = new Schema(
+ {
+ keyDownAtMs: { type: Number, required: true },
+ keyUpAtMs: { type: Number, required: true },
+ holdDurationMs: { type: Number, required: true },
+ interKeyDelayMs: { type: Number, default: null },
+ },
+ { _id: false },
+);
+
+const pasteEventSchema = new Schema(
+ {
+ atMs: { type: Number, required: true },
+ pastedLength: { type: Number, required: true },
+ },
+ { _id: false },
+);
+
+const writingSessionSchema = new Schema(
+ {
+ userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
+ noteId: { type: Schema.Types.ObjectId, ref: "Note", required: true },
+ status: {
+ type: String,
+ enum: ["active", "ended"],
+ default: "active",
+ },
+ keystrokeTimings: { type: [keystrokeTimingSchema], default: [] },
+ pasteEvents: { type: [pasteEventSchema], default: [] },
+ totalKeyPresses: { type: Number, default: 0 },
+ totalKeyReleases: { type: Number, default: 0 },
+ totalPastedCharacters: { type: Number, default: 0 },
+ totalPastes: { type: Number, default: 0 },
+
+ lastPingAt: { type: Date, default: null },
+ endedAt: { type: Date, default: null },
+ },
+ { timestamps: true },
+);
+
+writingSessionSchema.index({ userId: 1, noteId: 1 });
+
+export type IWritingSession = InferSchemaType;
+export type WritingSessionDocument = HydratedDocument;
+
+const WritingSession =
+ (mongoose.models.WritingSession as Model) ??
+ mongoose.model("WritingSession", writingSessionSchema);
+
+export default WritingSession;
diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts
new file mode 100644
index 000000000..b0cd9168c
--- /dev/null
+++ b/apps/backend/src/routes/index.ts
@@ -0,0 +1,17 @@
+import type { Express } from "express";
+import { requireAuth } from "@/middleware/requireAuth.middleware";
+import notesRouter from "./notes.routes";
+
+export default function registerRoutes(app: Express) {
+ app.get("/health", (_req, res) => {
+ res.json({ status: "ok", uptime: process.uptime(), timestamp: Date.now() });
+ });
+
+ // Notes routes
+ app.use("/api/notes", requireAuth, notesRouter);
+
+ // 404 β must be after all routes
+ app.use((_req, res) => {
+ res.status(404).json({ error: "Not found" });
+ });
+}
diff --git a/apps/backend/src/routes/notes.routes.ts b/apps/backend/src/routes/notes.routes.ts
new file mode 100644
index 000000000..8c344ad57
--- /dev/null
+++ b/apps/backend/src/routes/notes.routes.ts
@@ -0,0 +1,188 @@
+import Note from "@/models/note.model";
+import { UpdateNotesSchema, type UpdateNotesSchemaType } from "@/schemas/UpdateNotesSchema";
+import { validateRequest } from "@/utils/validateRequest";
+import { Router, type Request, type Response } from "express";
+import { Types } from "mongoose";
+import writingSessionsRouter from "./writingSessions.routes";
+
+const notesRouter: Router = Router();
+
+notesRouter.use("/:noteId/writing-sessions", writingSessionsRouter);
+
+const ALLOWED_SORTS = ["updatedAt", "createdAt", "title", "wordCount"] as const;
+type SortField = (typeof ALLOWED_SORTS)[number];
+
+interface GetNotesQuery {
+ search?: string;
+ sort?: SortField;
+ order?: "asc" | "desc";
+ page?: string;
+ limit?: string;
+ favorites?: string;
+}
+
+notesRouter.get(
+ "/",
+ async (req: Request, res: Response) => {
+ try {
+ const userId = req.user?.id;
+ if (!userId) return res.status(401).json({ error: "Unauthorized" });
+
+ const {
+ search = "",
+ sort = "updatedAt",
+ order = "desc",
+ page = "1",
+ limit = "15",
+ favorites,
+ } = req.query;
+
+ const pageNum = Math.max(1, parseInt(page));
+ const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
+ const sortField: SortField = ALLOWED_SORTS.includes(sort) ? sort : "updatedAt";
+ const sortOrder = order === "asc" ? 1 : -1;
+
+ const filter: Record = { userId };
+ if (search.trim()) {
+ filter.$or = [{ title: { $regex: search.trim(), $options: "i" } }];
+ }
+ if (favorites === "true") filter.favorite = true;
+
+ const [notes, filteredTotal, totalNotes, totalFavorites] = await Promise.all([
+ Note.find(filter)
+ .sort({ [sortField]: sortOrder })
+ .skip((pageNum - 1) * limitNum)
+ .limit(limitNum)
+ .lean(),
+ Note.countDocuments(filter),
+ Note.countDocuments({ userId }),
+ Note.countDocuments({ userId, favorite: true }),
+ ]);
+
+ res.json({
+ notes: notes.map((n) => ({
+ id: n._id.toString(),
+ title: n.title,
+ wordCount: n.wordCount,
+ favorite: n.favorite ?? false,
+ updatedAt: n.updatedAt,
+ createdAt: n.createdAt,
+ })),
+ pagination: {
+ page: pageNum,
+ limit: limitNum,
+ total: filteredTotal,
+ totalPages: Math.ceil(filteredTotal / limitNum),
+ },
+ totalNotes,
+ totalFavorites,
+ });
+ } catch (error) {
+ console.error("Error fetching notes:", error);
+ res.status(500).json({ error: "Failed to fetch notes" });
+ }
+ },
+);
+
+// create a new blank note
+notesRouter.post("/", async (req: Request, res: Response) => {
+ try {
+ const userId = req.user?.id;
+ if (!userId) return res.status(401).json({ error: "Unauthorized" });
+
+ const newNote = await Note.create({ userId });
+
+ res.status(201).json({ noteId: newNote._id.toString() });
+ } catch (error) {
+ console.error("Error creating note:", error);
+ res.status(500).json({ error: "Failed to create note" });
+ }
+});
+
+notesRouter.get("/:noteId", async (req: Request<{ noteId: string }>, res: Response) => {
+ try {
+ const { noteId } = req.params;
+ if (!Types.ObjectId.isValid(noteId)) return res.status(400).json({ error: "Invalid note ID" });
+
+ const userId = req.user?.id;
+ if (!userId) return res.status(401).json({ error: "Unauthorized" });
+
+ const note = await Note.findOne({ _id: noteId, userId }).lean();
+ if (!note) return res.status(404).json({ error: "Note not found" });
+
+ res.json({
+ note: {
+ id: note._id.toString(),
+ title: note.title,
+ content: note.content,
+ wordCount: note.wordCount,
+ tags: note.tags,
+ favorite: note.favorite,
+ createdAt: note.createdAt,
+ updatedAt: note.updatedAt,
+ },
+ });
+ } catch (error) {
+ console.error("Error fetching note:", error);
+ res.status(500).json({ error: "Failed to fetch note" });
+ }
+});
+
+notesRouter.put(
+ "/:noteId",
+ validateRequest(UpdateNotesSchema),
+ async (req: Request<{ noteId: string }, unknown, UpdateNotesSchemaType>, res: Response) => {
+ try {
+ const { noteId } = req.params;
+ if (!Types.ObjectId.isValid(noteId))
+ return res.status(400).json({ error: "Invalid note ID" });
+
+ const userId = req.user?.id;
+ if (!userId) return res.status(401).json({ error: "Unauthorized" });
+
+ const { title, content, wordCount, tags } = req.body;
+
+ const note = await Note.findOneAndUpdate(
+ { _id: noteId, userId: userId },
+ { title, content, wordCount, tags },
+ { returnDocument: "after" },
+ ).lean();
+
+ if (!note) return res.status(404).json({ error: "Note not found" });
+
+ res.json({
+ message: "Note updated successfully",
+ note: { updatedAt: note.updatedAt },
+ });
+ } catch (error) {
+ console.error("Error updating note:", error);
+ res.status(500).json({ error: "Failed to update note" });
+ }
+ },
+);
+
+notesRouter.patch("/:noteId/favorite", async (req: Request<{ noteId: string }>, res: Response) => {
+ try {
+ const { noteId } = req.params;
+ if (!Types.ObjectId.isValid(noteId)) return res.status(400).json({ error: "Invalid note ID" });
+
+ const userId = req.user?.id;
+ if (!userId) return res.status(401).json({ error: "Unauthorized" });
+
+ const current = await Note.findOne({ _id: noteId, userId }).select("favorite").lean();
+ if (!current) return res.status(404).json({ error: "Note not found" });
+
+ const updated = await Note.findOneAndUpdate(
+ { _id: noteId, userId },
+ { favorite: !current.favorite },
+ { returnDocument: "after" },
+ ).lean();
+
+ res.json({ favorite: updated!.favorite });
+ } catch (error) {
+ console.error("Error toggling favorite:", error);
+ res.status(500).json({ error: "Failed to toggle favorite" });
+ }
+});
+
+export default notesRouter;
diff --git a/apps/backend/src/routes/writingSessions.routes.ts b/apps/backend/src/routes/writingSessions.routes.ts
new file mode 100644
index 000000000..8fc2898d5
--- /dev/null
+++ b/apps/backend/src/routes/writingSessions.routes.ts
@@ -0,0 +1,152 @@
+import Note from "@/models/note.model";
+import WritingSession, { type IWritingSession } from "@/models/writingSession.model";
+import {
+ WritingSessionUpsertSchema,
+ type WritingSessionUpsertSchemaType,
+} from "@/schemas/WritingSessionSchema";
+import { validateRequest } from "@/utils/validateRequest";
+import { Router, type Request, type Response } from "express";
+import { Types } from "mongoose";
+
+type WritingSessionLean = IWritingSession & {
+ _id: Types.ObjectId;
+};
+
+const writingSessionsRouter: Router = Router({ mergeParams: true });
+
+function mapSession(session: WritingSessionLean) {
+ return {
+ id: session._id.toString(),
+ userId: session.userId.toString(),
+ noteId: session.noteId.toString(),
+
+ status: session.status,
+
+ keystrokeTimings: session.keystrokeTimings,
+ pasteEvents: session.pasteEvents,
+ totalKeyPresses: session.totalKeyPresses,
+ totalKeyReleases: session.totalKeyReleases,
+ totalPastedCharacters: session.totalPastedCharacters,
+ totalPastes: session.totalPastes,
+
+ lastPingAt: session.lastPingAt,
+ createdAt: session.createdAt,
+ updatedAt: session.updatedAt,
+ endedAt: session.endedAt,
+ };
+}
+
+async function assertAccessibleNote(noteId: string, userId: string) {
+ return Note.exists({ _id: noteId, userId });
+}
+
+// Create a new writing session for a note
+writingSessionsRouter.post("/", async (req: Request<{ noteId: string }>, res: Response) => {
+ try {
+ const { noteId } = req.params;
+ if (!Types.ObjectId.isValid(noteId)) return res.status(400).json({ error: "Invalid note ID" });
+
+ const userId = req.user?.id;
+ if (!userId) return res.status(401).json({ error: "Unauthorized" });
+
+ const noteExists = await assertAccessibleNote(noteId, userId);
+ if (!noteExists) return res.status(404).json({ error: "Note not found" });
+
+ const session = await WritingSession.create({
+ userId,
+ noteId,
+ }).then((doc) => doc.toObject());
+
+ res.status(201).json({ session: mapSession(session) });
+ } catch (error) {
+ console.error("Error creating writing session:", error);
+ res.status(500).json({ error: "Failed to create writing session" });
+ }
+});
+
+// Update or end a writing session by ID
+type WritingSessionUpdateRequest = Request<
+ { noteId: string; sessionId: string },
+ unknown,
+ WritingSessionUpsertSchemaType
+>;
+
+writingSessionsRouter.patch(
+ "/:sessionId",
+ validateRequest(WritingSessionUpsertSchema),
+ async (req: WritingSessionUpdateRequest, res: Response) => {
+ try {
+ const { noteId, sessionId } = req.params;
+ if (!Types.ObjectId.isValid(noteId))
+ return res.status(400).json({ error: "Invalid note ID" });
+ if (!Types.ObjectId.isValid(sessionId))
+ return res.status(400).json({ error: "Invalid session ID" });
+
+ const userId = req.user?.id;
+ if (!userId) return res.status(401).json({ error: "Unauthorized" });
+
+ const noteExists = await assertAccessibleNote(noteId, userId);
+ if (!noteExists) return res.status(404).json({ error: "Note not found" });
+
+ const payload = req.body;
+
+ const session = await WritingSession.findOneAndUpdate(
+ { userId, noteId, _id: sessionId },
+ {
+ $set: {
+ status: payload.status,
+
+ keystrokeTimings: payload.keystrokeTimings,
+ pasteEvents: payload.pasteEvents,
+ totalKeyPresses: payload.totalKeyPresses,
+ totalKeyReleases: payload.totalKeyReleases,
+ totalPastedCharacters: payload.totalPastedCharacters,
+ totalPastes: payload.totalPastes,
+
+ endedAt: payload.status === "ended" ? Date.now() : null,
+ },
+ },
+ { returnDocument: "after" },
+ ).lean();
+
+ if (!session) throw new Error("Failed to update writing session");
+
+ res.json({ session: mapSession(session) });
+ } catch (error) {
+ console.error("Error updating writing session:", error);
+ res.status(500).json({ error: "Failed to update writing session" });
+ }
+ },
+);
+
+// heartbeat
+writingSessionsRouter.post(
+ "/:sessionId/ping",
+ async (req: Request<{ noteId: string; sessionId: string }>, res: Response) => {
+ try {
+ const { noteId, sessionId } = req.params;
+ if (!Types.ObjectId.isValid(noteId))
+ return res.status(400).json({ error: "Invalid note ID" });
+ if (!Types.ObjectId.isValid(sessionId))
+ return res.status(400).json({ error: "Invalid session ID" });
+
+ const userId = req.user?.id;
+ if (!userId) return res.status(401).json({ error: "Unauthorized" });
+
+ const updated = await WritingSession.findOneAndUpdate(
+ { _id: sessionId, noteId, userId, status: "active" },
+ { $set: { lastPingAt: new Date() } },
+ { returnDocument: "after" },
+ ).lean();
+
+ if (!updated) return res.status(404).json({ error: "Session not found or already ended" });
+
+ res.json({ ok: true });
+ } catch (error) {
+ console.error("Error pinging writing session:", error);
+ res.status(500).json({ error: "Failed to ping writing session" });
+ }
+ },
+);
+
+export default writingSessionsRouter;
diff --git a/apps/backend/src/schemas/UpdateNotesSchema.ts b/apps/backend/src/schemas/UpdateNotesSchema.ts
new file mode 100644
index 000000000..e5e51619e
--- /dev/null
+++ b/apps/backend/src/schemas/UpdateNotesSchema.ts
@@ -0,0 +1,10 @@
+import z from "zod";
+
+export const UpdateNotesSchema = z.object({
+ title: z.string().max(255, "Title must be less than 255 characters"),
+ content: z.string().optional(),
+ wordCount: z.number().int().nonnegative().optional(),
+ tags: z.array(z.string()).optional(),
+});
+
+export type UpdateNotesSchemaType = z.infer;
diff --git a/apps/backend/src/schemas/WritingSessionSchema.ts b/apps/backend/src/schemas/WritingSessionSchema.ts
new file mode 100644
index 000000000..ac7afb056
--- /dev/null
+++ b/apps/backend/src/schemas/WritingSessionSchema.ts
@@ -0,0 +1,26 @@
+import z from "zod";
+
+export const WritingSessionTimingSchema = z.object({
+ keyDownAtMs: z.number().int().nonnegative(),
+ keyUpAtMs: z.number().int().nonnegative(),
+ holdDurationMs: z.number().int().nonnegative(),
+ interKeyDelayMs: z.number().int().nonnegative().nullable().optional(),
+});
+
+export const WritingSessionPasteSchema = z.object({
+ atMs: z.number().int().nonnegative(),
+ pastedLength: z.number().int().nonnegative(),
+});
+
+export const WritingSessionUpsertSchema = z.object({
+ status: z.enum(["active", "ended"]).default("active"),
+
+ keystrokeTimings: z.array(WritingSessionTimingSchema).default([]),
+ pasteEvents: z.array(WritingSessionPasteSchema).default([]),
+ totalKeyPresses: z.number().int().nonnegative().default(0),
+ totalKeyReleases: z.number().int().nonnegative().default(0),
+ totalPastedCharacters: z.number().int().nonnegative().default(0),
+ totalPastes: z.number().int().nonnegative().default(0),
+});
+
+export type WritingSessionUpsertSchemaType = z.infer;
diff --git a/apps/backend/src/services/writingSession.staleSweep.ts b/apps/backend/src/services/writingSession.staleSweep.ts
new file mode 100644
index 000000000..dfd3aaf09
--- /dev/null
+++ b/apps/backend/src/services/writingSession.staleSweep.ts
@@ -0,0 +1,45 @@
+import WritingSession from "@/models/writingSession.model";
+
+const PING_INTERVAL_MS = 30_000;
+const STALE_MULTIPLIER = 4;
+const SWEEP_INTERVAL_MS = 60_000;
+
+const STALE_THRESHOLD_MS = PING_INTERVAL_MS * STALE_MULTIPLIER;
+
+async function sweepStaleWritingSessions() {
+ const cutoff = new Date(Date.now() - STALE_THRESHOLD_MS);
+
+ try {
+ const result = await WritingSession.updateMany(
+ {
+ status: "active",
+ $or: [{ lastPingAt: null, createdAt: { $lt: cutoff } }, { lastPingAt: { $lt: cutoff } }],
+ },
+ { $set: { status: "ended", endedAt: new Date() } },
+ );
+
+ if (result.modifiedCount > 0) {
+ console.info(`[writingSession sweep] Ended ${result.modifiedCount} stale session(s)`);
+ }
+ } catch (err) {
+ console.error("[writingSession sweep] Error sweeping stale sessions:", err);
+ }
+}
+
+let sweepTimer: ReturnType | null = null;
+
+export function startStaleWritingSessionSweep() {
+ if (sweepTimer) return;
+
+ sweepTimer = setInterval(() => void sweepStaleWritingSessions(), SWEEP_INTERVAL_MS);
+
+ void sweepStaleWritingSessions();
+ console.info("[writingSession sweep] Started");
+}
+
+export function stopStaleWritingSessionSweep() {
+ if (sweepTimer) {
+ clearInterval(sweepTimer);
+ sweepTimer = null;
+ }
+}
diff --git a/apps/backend/src/types/express.d.ts b/apps/backend/src/types/express.d.ts
new file mode 100644
index 000000000..34452891f
--- /dev/null
+++ b/apps/backend/src/types/express.d.ts
@@ -0,0 +1,10 @@
+import type { Session } from "../auth";
+
+declare global {
+ namespace Express {
+ interface Request {
+ session: Session["session"] | null;
+ user: Session["user"] | null;
+ }
+ }
+}
diff --git a/apps/backend/src/utils/validateRequest.ts b/apps/backend/src/utils/validateRequest.ts
new file mode 100644
index 000000000..57e45ca72
--- /dev/null
+++ b/apps/backend/src/utils/validateRequest.ts
@@ -0,0 +1,19 @@
+import type { RequestHandler } from "express";
+import type { ZodType } from "zod";
+
+export function validateRequest(schema: T): RequestHandler {
+ return (req, res, next) => {
+ const validationResult = schema.safeParse(req.body);
+ if (!validationResult.success) {
+ return res.status(400).json({
+ error: validationResult.error.issues[0]?.message ?? "Invalid request body",
+ code: validationResult.error.issues[0]?.code ?? "invalid_request",
+ path: validationResult.error.issues[0]?.path.join(".") ?? "unknown",
+ });
+ }
+
+ req.body = validationResult.data;
+
+ next();
+ };
+}
diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json
new file mode 100644
index 000000000..f0fc450c6
--- /dev/null
+++ b/apps/backend/tsconfig.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "outDir": "dist",
+
+ "lib": ["ES2022"],
+ "target": "ES2022",
+ "types": ["node", "express"],
+ "declaration": true,
+ "sourceMap": true,
+
+ "module": "esnext",
+ "moduleResolution": "bundler",
+
+ "strict": true,
+ "strictNullChecks": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+
+ "forceConsistentCasingInFileNames": true,
+ "noUncheckedIndexedAccess": true,
+
+ "allowSyntheticDefaultImports": true,
+ "incremental": true,
+
+ /* Path aliases */
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/apps/backend/tsup.config.ts b/apps/backend/tsup.config.ts
new file mode 100644
index 000000000..77588a967
--- /dev/null
+++ b/apps/backend/tsup.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: ["src/index.ts"],
+ outDir: "dist",
+ format: ["esm"],
+ sourcemap: true,
+ clean: true,
+ dts: false,
+ minify: false,
+ target: "node18",
+ external: ["zod", "better-auth"],
+});
diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example
new file mode 100644
index 000000000..d943a27bc
--- /dev/null
+++ b/apps/frontend/.env.example
@@ -0,0 +1,2 @@
+NODE_ENV=development
+VITE_BACKEND_URL=http://localhost:3000
diff --git a/apps/frontend/eslint.config.mjs b/apps/frontend/eslint.config.mjs
new file mode 100644
index 000000000..9c9a2387b
--- /dev/null
+++ b/apps/frontend/eslint.config.mjs
@@ -0,0 +1,15 @@
+import config from "@repo/eslint-config/vite";
+
+export default [
+ ...config,
+
+ {
+ files: ["**/*.ts", "**/*.tsx"],
+ languageOptions: {
+ parserOptions: {
+ project: ["./tsconfig.app.json", "./tsconfig.node.json"],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ },
+];
diff --git a/apps/frontend/index.html b/apps/frontend/index.html
new file mode 100644
index 000000000..407afb5e8
--- /dev/null
+++ b/apps/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vi Notes
+
+
+
+
+
+
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
new file mode 100644
index 000000000..e0a04cd44
--- /dev/null
+++ b/apps/frontend/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "start": "vite preview",
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@lexical/history": "^0.43.0",
+ "@lexical/list": "^0.43.0",
+ "@lexical/react": "^0.43.0",
+ "@lexical/rich-text": "^0.43.0",
+ "@lexical/selection": "^0.43.0",
+ "@lexical/utils": "^0.43.0",
+ "@repo/auth": "workspace:*",
+ "clsx": "^2.1.1",
+ "lexical": "^0.43.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "react-icons": "^5.6.0",
+ "react-router-dom": "^7.14.0",
+ "react-toastify": "^11.0.5",
+ "tailwind-merge": "^3.5.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.29.0",
+ "@repo/eslint-config": "workspace:*",
+ "@rolldown/plugin-babel": "^0.2.2",
+ "@tailwindcss/vite": "^4.2.2",
+ "@types/babel__core": "^7.20.5",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "tailwindcss": "^4.2.2",
+ "vite": "^8.0.4"
+ }
+}
diff --git a/apps/frontend/public/favicon.svg b/apps/frontend/public/favicon.svg
new file mode 100644
index 000000000..6893eb132
--- /dev/null
+++ b/apps/frontend/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/frontend/src/app/App.tsx b/apps/frontend/src/app/App.tsx
new file mode 100644
index 000000000..d93258e70
--- /dev/null
+++ b/apps/frontend/src/app/App.tsx
@@ -0,0 +1,61 @@
+import { ProtectedRoute } from "@/auth/AuthRouteGuards";
+import { RouteErrorBoundary } from "@/components/ErrorBoundary";
+import RootLayout from "@/layouts/RootLayout";
+import { lazy } from "react";
+import { createBrowserRouter, RouterProvider, Navigate } from "react-router-dom";
+
+const Landing = lazy(() => import("./pages/Landing"));
+const SignUp = lazy(() => import("./pages/SignUp"));
+const SignIn = lazy(() => import("./pages/SignIn"));
+const Dashboard = lazy(() => import("./pages/Dashboard"));
+const Profile = lazy(() => import("./pages/Profile"));
+const EditNote = lazy(() => import("./pages/EditNote"));
+
+const router = createBrowserRouter([
+ {
+ element: ,
+ errorElement: ,
+ children: [
+ { path: "/", element: },
+ {
+ path: "/signin",
+ element: ,
+ },
+ {
+ path: "/signup",
+ element: ,
+ },
+
+ {
+ path: "/dashboard",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "/profile",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "/notes/edit/:noteId",
+ element: (
+
+
+
+ ),
+ },
+
+ { path: "*", element: },
+ ],
+ },
+]);
+
+export default function App() {
+ return ;
+}
diff --git a/apps/frontend/src/app/main.tsx b/apps/frontend/src/app/main.tsx
new file mode 100644
index 000000000..3204f6439
--- /dev/null
+++ b/apps/frontend/src/app/main.tsx
@@ -0,0 +1,15 @@
+// import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "@/styles/index.css";
+import App from "./App";
+import { ThemeProvider } from "@/provider/ThemeProvider";
+import ToastProvider from "@/provider/ToastProvider";
+
+createRoot(document.getElementById("root")!).render(
+ //
+
+
+
+ ,
+ // ,
+);
diff --git a/apps/frontend/src/app/pages/Dashboard.tsx b/apps/frontend/src/app/pages/Dashboard.tsx
new file mode 100644
index 000000000..3b4106f8a
--- /dev/null
+++ b/apps/frontend/src/app/pages/Dashboard.tsx
@@ -0,0 +1,193 @@
+import { useState, useEffect, useCallback } from "react";
+import { BiSolidStar } from "react-icons/bi";
+import { HiMagnifyingGlass } from "react-icons/hi2";
+import { useDebounce } from "@/hooks/useDebounce";
+import { apiFetch } from "@/lib/apiFetch";
+import { NoteCard } from "@/ui/NoteCard";
+import type { Note } from "@/types/notes";
+import { cn } from "@/utils/cn";
+import { Button } from "@/ui/Button";
+import CreateNewNoteButton from "@/components/CreateNewNoteButton";
+
+interface PaginationMeta {
+ page: number;
+ limit: number;
+ total: number;
+ totalPages: number;
+}
+
+interface NotesResponse {
+ notes: Note[];
+ pagination: PaginationMeta;
+ totalNotes: number;
+ totalFavorites: number;
+}
+
+type SortField = "updatedAt" | "createdAt" | "title" | "wordCount";
+type SortOrder = "asc" | "desc";
+type ActiveFilter = "all" | "favorites";
+
+export default function Dashboard() {
+ const [notes, setNotes] = useState([]);
+ const [totalNotes, setTotalNotes] = useState(0);
+ const [totalFavorites, setTotalFavorites] = useState(0);
+ const [pagination, setPagination] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const [search, setSearch] = useState("");
+ const [sort, setSort] = useState("updatedAt");
+ const [order, setOrder] = useState("desc");
+ const [activeFilter, setActiveFilter] = useState("all");
+ const [page, setPage] = useState(1);
+
+ const debouncedSearch = useDebounce(search, 300);
+
+ useEffect(() => {
+ setPage(1);
+ }, [debouncedSearch, sort, order, activeFilter]);
+
+ const fetchNotes = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const params = new URLSearchParams({
+ search: debouncedSearch,
+ sort,
+ order,
+ page: String(page),
+ limit: "15",
+ ...(activeFilter === "favorites" ? { favorites: "true" } : {}),
+ });
+ const data = await apiFetch(`/api/notes?${params}`);
+ setNotes(data.notes);
+ setPagination(data.pagination);
+ setTotalNotes(data.totalNotes);
+ setTotalFavorites(data.totalFavorites);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load notes");
+ } finally {
+ setLoading(false);
+ }
+ }, [debouncedSearch, sort, order, activeFilter, page]);
+
+ useEffect(() => {
+ void fetchNotes();
+ }, [fetchNotes]);
+
+ const handleToggleFavorite = async (noteId: string, current: boolean) => {
+ setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, favorite: !current } : n)));
+ try {
+ await apiFetch(`/api/notes/${noteId}/favorite`, { method: "PATCH" });
+ } catch {
+ setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, favorite: current } : n)));
+ }
+ };
+
+ const changePage = (next: number) => {
+ requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: "smooth" }));
+ setPage(next);
+ };
+
+ return (
+ <>
+
+
All notes
+
+ {totalNotes} notes
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search notesβ¦"
+ className="w-full pl-9 pr-4 py-2 text-sm rounded-lg border bg-transparent focus:outline-none focus:border-secondary transition-colors"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ ))}
+
+ ) : error ? (
+ {error}
+ ) : notes.length === 0 ? (
+
+ {debouncedSearch
+ ? `No notes match "${debouncedSearch}"`
+ : activeFilter === "favorites"
+ ? "No favorite notes yet."
+ : "No notes yet. Create your first one!"}
+
+ ) : (
+
+ {notes.map((note) => (
+
+ ))}
+
+ )}
+
+ {pagination && pagination.totalPages > 1 && (
+
+
+
+ {page} / {pagination.totalPages}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/frontend/src/app/pages/EditNote.tsx b/apps/frontend/src/app/pages/EditNote.tsx
new file mode 100644
index 000000000..777c729a9
--- /dev/null
+++ b/apps/frontend/src/app/pages/EditNote.tsx
@@ -0,0 +1,21 @@
+import ViEditor from "@/features/editor/Editor";
+import { LoadingScreen } from "@/ui/Loading";
+import { useNoteEditor } from "@/hooks/useNoteEditor";
+
+export default function EditNote() {
+ const { note, loading, error } = useNoteEditor();
+
+ if (loading) {
+ return ;
+ }
+
+ if (error || !note) {
+ return (
+
+
{error ?? "Note not found."}
+
+ );
+ }
+
+ return ;
+}
diff --git a/apps/frontend/src/app/pages/Landing.tsx b/apps/frontend/src/app/pages/Landing.tsx
new file mode 100644
index 000000000..0ef213e12
--- /dev/null
+++ b/apps/frontend/src/app/pages/Landing.tsx
@@ -0,0 +1,117 @@
+import { useSession } from "@/auth";
+import Footer from "@/components/Footer";
+import { LinkButton } from "@/ui/Button";
+import { GrCommand } from "react-icons/gr";
+import { MdSecurity } from "react-icons/md";
+
+// TODO: Replace with real stats and features from backend or config
+const stats = [
+ { value: "10k+", label: "Notes created" },
+ { value: "2.4k", label: "Active writers" },
+ { value: "99.9%", label: "Uptime" },
+ { value: "< 50ms", label: "Sync speed" },
+];
+
+const features = [
+ {
+ icon: ,
+ title: "Keyboard driven",
+ desc: "Every action has a shortcut. Stay in flow without reaching for the mouse.",
+ },
+ {
+ icon: ,
+ title: "End-to-end encrypted",
+ desc: "Your notes are encrypted before leaving your device. Only you can read them.",
+ },
+];
+
+export default function Landing() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+function Hero() {
+ const { data } = useSession();
+ const isAuthenticated = !!data?.user;
+ return (
+
+
+
+ β Your second brain
+
+
+ Notes that
+
+ think with you.
+
+
+ Vi Notes is a minimal, fast note-taking app built for writers and thinkers who want focus
+ over features.
+
+
+ {isAuthenticated ? "Go to Dashboard β" : "Start writing free β"}
+
+
+
+ {/* Shadow in the middle of the section */}
+
+
+ );
+}
+
+function Stats() {
+ return (
+
+
+ {stats.map((s) => (
+
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+ );
+}
+
+function Feature() {
+ return (
+
+ β Why Vi Notes
+
+ {features.map((f) => (
+
+
{f.icon}
+
{f.title}
+
{f.desc}
+
+ ))}
+
+
+ );
+}
+
+function JoinNow() {
+ const { data } = useSession();
+ const isAuthenticated = !!data?.user;
+
+ return (
+
+ Ready to start writing?
+
+ Join thousands of writers. Free forever, no limits.
+
+
+ {isAuthenticated ? "Go to Dashboard β" : "Create your account β"}
+
+
+ );
+}
diff --git a/apps/frontend/src/app/pages/Profile.tsx b/apps/frontend/src/app/pages/Profile.tsx
new file mode 100644
index 000000000..9b697b8d9
--- /dev/null
+++ b/apps/frontend/src/app/pages/Profile.tsx
@@ -0,0 +1,28 @@
+import { useSession } from "@/auth";
+import { ProfilePicture } from "@/components/ProfilePicture";
+import { Link } from "react-router-dom";
+
+export default function Profile() {
+ const { data: sessionData } = useSession();
+
+ return (
+ <>
+
+ β Back to notes
+
+
+
+
+
+
{sessionData?.user.name}
+
{sessionData?.user.email}
+
+
+ >
+ );
+}
+
+
diff --git a/apps/frontend/src/app/pages/SignIn.tsx b/apps/frontend/src/app/pages/SignIn.tsx
new file mode 100644
index 000000000..8f9c91459
--- /dev/null
+++ b/apps/frontend/src/app/pages/SignIn.tsx
@@ -0,0 +1,161 @@
+import { useEffect, useState, useTransition } from "react";
+import { Link, useNavigate, useSearchParams } from "react-router-dom";
+import { signInSchema } from "@/schemas/auth/signInSchema";
+import type z from "zod";
+import { signIn, useSession } from "@/auth";
+import { toast } from "react-toastify";
+import { Input, PasswordInput } from "@/ui/Input";
+import { AuthAgreeAndSubmitButton } from "@/components/AuthAgreeAndSubmitButton";
+import SocialLogin from "@/auth/SocialLogin";
+import Navbar from "@/components/Navbar";
+import { resolveCallbackURL } from "@/utils/resolveCallbackURL";
+
+type SignInFormData = z.infer;
+
+type Errors = Partial> & {
+ general?: string;
+};
+
+export default function SignIn() {
+ const { data } = useSession();
+ const navigate = useNavigate();
+
+ const isAuthenticated = !!data?.user;
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ void navigate("/dashboard");
+ }
+ }, [isAuthenticated, navigate]);
+
+ const [searchParams] = useSearchParams();
+ const next = searchParams.get("next") ?? "/dashboard";
+ const callbackURL = resolveCallbackURL(window.location.origin, next);
+
+ const [isPending, startTransition] = useTransition();
+
+ const [formData, setFormData] = useState({
+ email: "",
+ password: "",
+ });
+ const [errors, setErrors] = useState({});
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
+ setErrors({});
+ };
+
+ function handleSignIn(e: React.SubmitEvent) {
+ e.preventDefault();
+ setErrors({});
+ startTransition(async () => {
+ try {
+ const credentials = signInSchema.safeParse({
+ email: formData.email.toLowerCase().replace(/\+.*(?=@)/, ""),
+ password: formData.password,
+ });
+
+ if (!credentials.success) {
+ const err = credentials.error.issues.reduce((acc, { path, message }) => {
+ acc[path[0] as keyof SignInFormData] = message;
+ return acc;
+ }, {});
+ setErrors(err);
+ return;
+ }
+
+ await signIn.email(
+ { ...credentials.data, callbackURL },
+ {
+ onSuccess: () => {
+ toast.success("Sign in successful");
+ },
+
+ onError: (ctx) => {
+ switch (ctx.error.code) {
+ case "INVALID_EMAIL_OR_PASSWORD":
+ setErrors({ general: "Invalid email or password" });
+ break;
+
+ case "EMAIL_NOT_VERIFIED":
+ setErrors({
+ general:
+ "Your email is not verified, please check your inbox for a verification email.",
+ });
+ break;
+
+ default:
+ toast.error(ctx.error.message || "Something went wrong");
+ }
+ },
+ },
+ );
+ } catch {
+ toast.error("Unexpected error occurred");
+ }
+ });
+ }
+
+ return (
+ <>
+
+
+
+
+
+ β Welcome back
+
+
Sign in
+
+
+ {errors?.general && (
+
+ {errors.general}
+
+ )}
+
+
+
+
+
+
+
+
+ No account?{" "}
+
+ Sign up free
+
+
+
+
+ >
+ );
+}
diff --git a/apps/frontend/src/app/pages/SignUp.tsx b/apps/frontend/src/app/pages/SignUp.tsx
new file mode 100644
index 000000000..f49cf0f23
--- /dev/null
+++ b/apps/frontend/src/app/pages/SignUp.tsx
@@ -0,0 +1,185 @@
+import { useEffect, useState, useTransition } from "react";
+import { Link, useNavigate, useSearchParams } from "react-router-dom";
+import Navbar from "@/components/Navbar";
+import SocialLogin from "@/auth/SocialLogin";
+import { Input, PasswordInput } from "@/ui/Input";
+import { signupSchema } from "@/schemas/auth/signupSchema";
+import type z from "zod";
+import { signUp, useSession } from "@/auth";
+import { toast } from "react-toastify";
+import { AuthAgreeAndSubmitButton } from "@/components/AuthAgreeAndSubmitButton";
+import { resolveCallbackURL } from "@/utils/resolveCallbackURL";
+
+type SignUpFormData = z.infer;
+type Errors = Partial>;
+
+export default function SignUp() {
+ const { data } = useSession();
+ const navigate = useNavigate();
+
+ const isAuthenticated = !!data?.user;
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ void navigate("/dashboard");
+ }
+ }, [isAuthenticated, navigate]);
+
+ const [searchParams] = useSearchParams();
+ const goNext = searchParams.get("next") ?? "/dashboard";
+ const callbackURL = resolveCallbackURL(window.location.origin, goNext);
+
+ const [isPending, startTransition] = useTransition();
+
+ const [formData, setFormData] = useState({
+ name: "",
+ email: "",
+ password: "",
+ confirmPassword: "",
+ });
+ const [errors, setErrors] = useState({});
+ const [signUpSuccessMessage, setSignUpSuccessMessage] = useState("");
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
+ setErrors((prev) => ({ ...prev, [e.target.name]: undefined }));
+ };
+
+ const onSubmit = (e: React.SubmitEvent) => {
+ e.preventDefault();
+ setErrors({});
+
+ startTransition(async () => {
+ try {
+ const credentials = signupSchema.safeParse({
+ ...formData,
+ email: formData.email.toLowerCase().replace(/\+.*(?=@)/, ""),
+ });
+
+ if (!credentials.success) {
+ const err = credentials.error.issues.reduce((acc, { path, message }) => {
+ acc[path[0] as keyof SignUpFormData] = message;
+ return acc;
+ }, {});
+ setErrors(err);
+ return;
+ }
+
+ await signUp.email(
+ {
+ email: credentials.data.email,
+ password: credentials.data.password,
+ name: credentials.data.name,
+ callbackURL,
+ },
+ {
+ onSuccess: () => {
+ setSignUpSuccessMessage(
+ "Sign up successful! Please check your email to verify your account.",
+ );
+ setFormData({ name: "", email: "", password: "", confirmPassword: "" });
+ },
+ onError: (ctx) => {
+ const errCode = ctx.error.code as string | undefined;
+
+ if (errCode === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
+ setErrors({ email: "Email already exists" });
+ return;
+ }
+
+ toast.error(ctx.error.message);
+ },
+ },
+ );
+ } catch {
+ toast.error("An unexpected error occurred.");
+ }
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+ β Join us
+
+
Create account
+
+
+ {signUpSuccessMessage && (
+
+ {signUpSuccessMessage}
+
+ )}
+
+
+
+
+
+
+ Already have an account?{" "}
+
+ Sign in
+
+
+
+
+ >
+ );
+}
diff --git a/apps/frontend/src/auth/AuthRouteGuards.tsx b/apps/frontend/src/auth/AuthRouteGuards.tsx
new file mode 100644
index 000000000..cd5c195bd
--- /dev/null
+++ b/apps/frontend/src/auth/AuthRouteGuards.tsx
@@ -0,0 +1,32 @@
+import { Navigate, useLocation } from "react-router-dom";
+import { useSession } from ".";
+
+interface ProtectedRouteProps {
+ children: React.ReactNode;
+ redirectTo?: string;
+}
+
+export function ProtectedRoute({ children, redirectTo = "/signin" }: ProtectedRouteProps) {
+ const { data, isPending, error } = useSession();
+ const location = useLocation();
+
+ const next = location.pathname + location.search;
+
+ if (isPending) {
+ return Loading session...
;
+ }
+
+ if (error) {
+ console.error("Error fetching session:", error);
+ return Error loading session. Please try again later.
;
+ }
+
+ const isAuthenticated = !!data?.user;
+
+ if (!isAuthenticated) {
+ const redirectUrl = `${redirectTo}?next=${encodeURIComponent(next)}`;
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/frontend/src/auth/SocialLogin.tsx b/apps/frontend/src/auth/SocialLogin.tsx
new file mode 100644
index 000000000..7c1a9a84a
--- /dev/null
+++ b/apps/frontend/src/auth/SocialLogin.tsx
@@ -0,0 +1,39 @@
+import { signIn } from ".";
+import { toast } from "react-toastify";
+import { Button } from "@/ui/Button";
+import { FcGoogle } from "react-icons/fc";
+
+interface SocialLoginProps {
+ callbackURL: string;
+}
+
+export default function SocialLogin({ callbackURL }: SocialLoginProps) {
+ const doSocialLogin = async (formData: FormData) => {
+ const action = formData.get("action");
+ if (!action || typeof action !== "string") return;
+
+ switch (action) {
+ case "google":
+ await signIn.social({ provider: "google", callbackURL });
+ break;
+
+ default:
+ console.error("Unknown auth provider:", action);
+ toast.error("Unknown authentication provider.");
+ return;
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/frontend/src/auth/index.ts b/apps/frontend/src/auth/index.ts
new file mode 100644
index 000000000..8792f28f2
--- /dev/null
+++ b/apps/frontend/src/auth/index.ts
@@ -0,0 +1,23 @@
+import { createAuthClient } from "better-auth/react";
+import {
+ twoFactorClient,
+ usernameClient,
+ emailOTPClient,
+ adminClient,
+ organizationClient,
+} from "better-auth/client/plugins";
+import { ac, roles } from "@repo/auth";
+import { ENV } from "@/env";
+
+export const authClient = createAuthClient({
+ baseURL: ENV.VITE_BACKEND_URL,
+ plugins: [
+ twoFactorClient(),
+ usernameClient(),
+ emailOTPClient(),
+ adminClient({ ac, roles }),
+ organizationClient(),
+ ],
+});
+
+export const { useSession, signIn, signOut, signUp } = authClient;
diff --git a/apps/frontend/src/components/AuthAgreeAndSubmitButton.tsx b/apps/frontend/src/components/AuthAgreeAndSubmitButton.tsx
new file mode 100644
index 000000000..0ac69b7d1
--- /dev/null
+++ b/apps/frontend/src/components/AuthAgreeAndSubmitButton.tsx
@@ -0,0 +1,30 @@
+import { Button } from "@/ui/Button";
+import { Link } from "react-router-dom";
+
+export function AuthAgreeAndSubmitButton({
+ disabled,
+ loading,
+}: {
+ disabled: boolean;
+ loading: boolean;
+}) {
+ return (
+
+
+ By continuing, you agree to our{" "}
+
+ Terms of Use
+ {" "}
+ and{" "}
+
+ Privacy Policy
+
+ .
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/components/CreateNewNoteButton.tsx b/apps/frontend/src/components/CreateNewNoteButton.tsx
new file mode 100644
index 000000000..c7012e137
--- /dev/null
+++ b/apps/frontend/src/components/CreateNewNoteButton.tsx
@@ -0,0 +1,46 @@
+import { Button } from "@/ui/Button";
+import { useTransition } from "react";
+import { useNavigate } from "react-router-dom";
+import { LoadingScreen } from "../ui/Loading";
+import { toast } from "react-toastify";
+import { cn } from "@/utils/cn";
+import { BiPlus } from "react-icons/bi";
+import { apiFetch } from "@/lib/apiFetch";
+
+type Props = Omit, "onClick"> & {
+ onClick?: () => void;
+};
+
+export default function CreateNewNoteButton({ className, onClick, ...props }: Props) {
+ const [isPending, startTransition] = useTransition();
+ const navigate = useNavigate();
+ const handleClick = () => {
+ startTransition(async () => {
+ try {
+ const { noteId } = await apiFetch<{ noteId: string }>(`/api/notes`, {
+ method: "POST",
+ });
+
+ onClick?.();
+ void navigate(`/notes/edit/${noteId}`);
+ } catch (error) {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to create new note. Please try again.",
+ );
+ console.error("Error creating new note:", error);
+ }
+ });
+ };
+ return (
+ <>
+ {isPending && }
+
+ >
+ );
+}
diff --git a/apps/frontend/src/components/ErrorBoundary.tsx b/apps/frontend/src/components/ErrorBoundary.tsx
new file mode 100644
index 000000000..8de0b7be4
--- /dev/null
+++ b/apps/frontend/src/components/ErrorBoundary.tsx
@@ -0,0 +1,30 @@
+import { useRouteError, isRouteErrorResponse, Link } from "react-router-dom";
+
+// For react-router route-level errors
+export function RouteErrorBoundary() {
+ const error = useRouteError();
+
+ const title = isRouteErrorResponse(error)
+ ? `${error.status} β ${error.statusText}`
+ : "Something went wrong";
+
+ const message = isRouteErrorResponse(error)
+ ? (error.data as string)
+ : error instanceof Error
+ ? error.message
+ : "An unexpected error occurred.";
+
+ return (
+
+
β Error
+
{title}
+
{message}
+
+ β Go home
+
+
+ );
+}
diff --git a/apps/frontend/src/components/Footer.tsx b/apps/frontend/src/components/Footer.tsx
new file mode 100644
index 000000000..501255531
--- /dev/null
+++ b/apps/frontend/src/components/Footer.tsx
@@ -0,0 +1,10 @@
+export default function Footer() {
+ return (
+
+ );
+}
diff --git a/apps/frontend/src/components/Navbar.tsx b/apps/frontend/src/components/Navbar.tsx
new file mode 100644
index 000000000..30e8b921c
--- /dev/null
+++ b/apps/frontend/src/components/Navbar.tsx
@@ -0,0 +1,96 @@
+import { Link, useLocation, useNavigate } from "react-router-dom";
+import { useSession } from "@/auth";
+import { LinkButton } from "@/ui/Button";
+import { useTheme } from "@/hooks/useTheme";
+import { NavIcon } from "@/ui/NavIcon";
+import { BiMoon, BiSun } from "react-icons/bi";
+import { MdComputer } from "react-icons/md";
+import { RiMenu5Line } from "react-icons/ri";
+import { cn } from "@/utils/cn";
+import { ProfilePicture } from "./ProfilePicture";
+
+interface NavbarProps {
+ onMenuClick?: () => void;
+}
+
+export default function Navbar({ onMenuClick }: NavbarProps) {
+ const { data } = useSession();
+ const isAuthenticated = !!data?.user;
+
+ const { pathname } = useLocation();
+
+ return (
+
+ );
+}
+
+function ThemeToggleIcon() {
+ const { theme, setTheme } = useTheme();
+
+ const cycle = () => {
+ const order = ["light", "dark", "system"] as const;
+ const next = order[(order.indexOf(theme) + 1) % order.length];
+ setTheme(next);
+ };
+
+ return (
+
+ {theme === "light" ? (
+
+ ) : theme === "dark" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function ProfileIcon() {
+ const navigate = useNavigate();
+
+ return (
+ void navigate("/profile")}>
+
+
+ );
+}
diff --git a/apps/frontend/src/components/ProfilePicture.tsx b/apps/frontend/src/components/ProfilePicture.tsx
new file mode 100644
index 000000000..aba09bf6e
--- /dev/null
+++ b/apps/frontend/src/components/ProfilePicture.tsx
@@ -0,0 +1,23 @@
+import { useSession } from "@/auth";
+import { cn } from "@/utils/cn";
+
+export function ProfilePicture({ className }: { className?: string }) {
+ const { data: sessionData } = useSession();
+ const user = sessionData?.user;
+ return (
+
+ {user?.image ? (
+

+ ) : (
+
+ {user.name.charAt(0)}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/frontend/src/components/Sidebar.tsx b/apps/frontend/src/components/Sidebar.tsx
new file mode 100644
index 000000000..043398477
--- /dev/null
+++ b/apps/frontend/src/components/Sidebar.tsx
@@ -0,0 +1,135 @@
+import { signOut, useSession } from "@/auth";
+import { Button } from "@/ui/Button";
+import { cn } from "@/utils/cn";
+import { BiHash, BiHeart, BiTrash, BiX } from "react-icons/bi";
+import { IoSettingsOutline } from "react-icons/io5";
+import { LuNotebook } from "react-icons/lu";
+import { PiSignOutBold } from "react-icons/pi";
+import { Link, NavLink, useNavigate } from "react-router-dom";
+import CreateNewNoteButton from "./CreateNewNoteButton";
+
+const quickLinks = [
+ { label: "All notes", icon: , href: "/dashboard" },
+ { label: "Favorites", icon: , href: "/dashboard/favorites" },
+ { label: "Tags", icon: , href: "/dashboard/tags" },
+ { label: "Trash", icon: , href: "/dashboard/trash" },
+ { label: "Settings", icon: , href: "/dashboard/settings" },
+];
+
+// TODO: Fetch recent tags from backend and show them here
+const recentTags = [
+ "#work",
+ "#ideas",
+ "#reading",
+ "#personal",
+ "#dev",
+ "#travel",
+ "#health",
+ "#finance",
+];
+
+interface SidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export default function Sidebar({ isOpen, onClose }: SidebarProps) {
+ const navigate = useNavigate();
+ const { data } = useSession();
+ const isAuthenticated = !!data?.user;
+
+ const handleSignOut = async () => {
+ onClose();
+ await signOut();
+ void navigate("/signin");
+ };
+
+ return (
+ <>
+
+ {isOpen && }
+ >
+ );
+}
diff --git a/apps/frontend/src/env.ts b/apps/frontend/src/env.ts
new file mode 100644
index 000000000..d8d96d1db
--- /dev/null
+++ b/apps/frontend/src/env.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+const EnvSchema = z.object({
+ // URLs
+ VITE_BACKEND_URL: z.url(),
+});
+
+const parsed = EnvSchema.safeParse(import.meta.env);
+
+if (!parsed.success) {
+ console.error("β Invalid frontend environment variables:");
+ console.error(parsed.error.issues);
+ throw new Error("Missing or invalid frontend environment variables. Check your .env files!");
+}
+
+export const ENV = parsed.data;
+export type Env = typeof ENV;
diff --git a/apps/frontend/src/features/editor/Editor.tsx b/apps/frontend/src/features/editor/Editor.tsx
new file mode 100644
index 000000000..807b2b3e2
--- /dev/null
+++ b/apps/frontend/src/features/editor/Editor.tsx
@@ -0,0 +1,147 @@
+import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer";
+import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
+import { ContentEditable } from "@lexical/react/LexicalContentEditable";
+import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
+import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
+import { ListPlugin } from "@lexical/react/LexicalListPlugin";
+import { HeadingNode, QuoteNode } from "@lexical/rich-text";
+import { ListNode, ListItemNode } from "@lexical/list";
+import { Toolbar } from "./Toolbar";
+import type { EditorThemeClasses } from "lexical";
+import type { Note } from "@/types/notes";
+import { ContentLoaderPlugin } from "./plugins/ContentLoaderPlugin";
+import { Titlebar } from "./Titlebar";
+import { useEffect, useRef, useState } from "react";
+import { AutoSavePlugin } from "./plugins/AutoSavePlugin";
+import { cn } from "@/utils/cn";
+import { WritingSessionPlugin } from "./plugins/WritingSessionPlugin";
+
+const themeClassName = {
+ heading: {
+ h1: "text-3xl font-bold mt-6 mb-3",
+ h2: "text-2xl font-bold mt-5 mb-2",
+ h3: "text-xl font-bold mt-4 mb-1.5",
+ },
+ text: {
+ bold: "font-bold",
+ italic: "italic",
+ underline: "underline",
+ strikethrough: "line-through",
+ underlineStrikethrough: "[text-decoration-line:underline_line-through]",
+ },
+ list: {
+ ul: "list-disc list-outside ml-6 my-2",
+ ol: "list-decimal list-outside ml-6 my-2",
+ listitem: "my-1",
+ nested: {
+ listitem: "list-none",
+ },
+ },
+ quote: "border-l-4 border-secondary pl-4 italic text-text-muted my-4",
+ paragraph: "mb-3 leading-relaxed",
+} satisfies EditorThemeClasses;
+
+interface ViEditorProps {
+ note: Note;
+}
+
+export default function Editor({ note }: ViEditorProps) {
+ const [unsavedChanges, setUnsavedChanges] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [newTitle, setNewTitle] = useState(note.title);
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ const [antiDistraction, setAntiDistraction] = useState(false);
+
+ const containerRef = useRef(null);
+
+ const toggleFullscreen = async (value?: boolean) => {
+ try {
+ if (value === true || (!document.fullscreenElement && value !== false)) {
+ await containerRef.current?.requestFullscreen();
+ } else {
+ await document.exitFullscreen();
+ }
+ } catch (err) {
+ console.error("Fullscreen error:", err);
+ }
+ };
+
+ useEffect(() => {
+ const handleChange = () => {
+ const isNowFullscreen = !!document.fullscreenElement;
+ setIsFullscreen(isNowFullscreen);
+ if (!isNowFullscreen) setAntiDistraction(false);
+ };
+
+ document.addEventListener("fullscreenchange", handleChange);
+
+ return () => {
+ document.removeEventListener("fullscreenchange", handleChange);
+ };
+ }, []);
+
+ const initialConfig: InitialConfigType = {
+ namespace: "ViNotes",
+ theme: themeClassName,
+ nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode],
+ onError: (err) => console.error("Lexical error:", err),
+ editable: false,
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ placeholder={
+
+ Start writingβ¦
+
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+
+
+
+
+ {/* Plugins */}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/features/editor/Titlebar.tsx b/apps/frontend/src/features/editor/Titlebar.tsx
new file mode 100644
index 000000000..2b92d6f89
--- /dev/null
+++ b/apps/frontend/src/features/editor/Titlebar.tsx
@@ -0,0 +1,32 @@
+import { IoCloudDoneOutline, IoCloudOfflineOutline } from "react-icons/io5";
+import { VscLoading } from "react-icons/vsc";
+
+interface TitlebarProps {
+ title: string;
+ setTitle: (title: string) => void;
+ unsavedChanges?: boolean;
+ saving?: boolean;
+}
+export function Titlebar({ title, setTitle, unsavedChanges, saving }: TitlebarProps) {
+ return (
+
+
+
+ setTitle(e.target.value)}
+ placeholder="Untitled note"
+ className="w-full text-lg font-semibold bg-transparent focus:outline-none border-b-2 focus:border-secondary transition-colors"
+ />
+
+ {saving ? (
+
+ ) : unsavedChanges ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/features/editor/Toolbar.tsx b/apps/frontend/src/features/editor/Toolbar.tsx
new file mode 100644
index 000000000..e1202b796
--- /dev/null
+++ b/apps/frontend/src/features/editor/Toolbar.tsx
@@ -0,0 +1,296 @@
+import { useEffect, useState } from "react";
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import {
+ $getSelection,
+ $isRangeSelection,
+ FORMAT_TEXT_COMMAND,
+ UNDO_COMMAND,
+ REDO_COMMAND,
+ CAN_UNDO_COMMAND,
+ CAN_REDO_COMMAND,
+ COMMAND_PRIORITY_CRITICAL,
+} from "lexical";
+import { $isHeadingNode, $createHeadingNode } from "@lexical/rich-text";
+import { $setBlocksType } from "@lexical/selection";
+import {
+ INSERT_ORDERED_LIST_COMMAND,
+ INSERT_UNORDERED_LIST_COMMAND,
+ REMOVE_LIST_COMMAND,
+ $isListNode,
+ ListNode,
+} from "@lexical/list";
+import { $getNearestNodeOfType } from "@lexical/utils";
+import { $createParagraphNode } from "lexical";
+import { BsParagraph } from "react-icons/bs";
+import { FaListOl, FaListUl } from "react-icons/fa";
+import { MdRedo, MdUndo } from "react-icons/md";
+import { RiFullscreenExitLine, RiFullscreenLine } from "react-icons/ri";
+import { TbEye, TbEyeOff } from "react-icons/tb";
+import { ToolbarButton } from "@/ui/ToolbarButton";
+import { cn } from "@/utils/cn";
+
+type ParagraphBlockType = "paragraph";
+type HeadingBlockType = "h1" | "h2" | "h3";
+type ListBlockType = "ul" | "ol";
+
+type BlockType = ParagraphBlockType | HeadingBlockType | ListBlockType;
+
+interface ToolbarState {
+ bold: boolean;
+ italic: boolean;
+ underline: boolean;
+ strikethrough: boolean;
+ blockType: BlockType;
+ canUndo: boolean;
+ canRedo: boolean;
+}
+
+function Divider() {
+ return ;
+}
+
+interface ToolbarProps {
+ isFullscreen: boolean;
+ onToggleFullscreen: (value?: boolean) => Promise;
+ antiDistraction: boolean;
+ setAntiDistraction: (value: boolean) => void;
+}
+
+export function Toolbar({ isFullscreen, onToggleFullscreen, antiDistraction, setAntiDistraction }: ToolbarProps) {
+ const [editor] = useLexicalComposerContext();
+
+ const [state, setState] = useState({
+ bold: false,
+ italic: false,
+ underline: false,
+ strikethrough: false,
+ blockType: "paragraph",
+ canUndo: false,
+ canRedo: false,
+ });
+
+ useEffect(() => {
+ return editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) return;
+
+ const bold = selection.hasFormat("bold");
+ const italic = selection.hasFormat("italic");
+ const underline = selection.hasFormat("underline");
+ const strikethrough = selection.hasFormat("strikethrough");
+
+ const anchor = selection.anchor.getNode();
+ const element = anchor.getKey() === "root" ? anchor : anchor.getTopLevelElementOrThrow();
+
+ let blockType: BlockType = "paragraph";
+
+ if ($isHeadingNode(element)) {
+ blockType = element.getTag() as BlockType;
+ } else if ($isListNode(element)) {
+ const parent = $getNearestNodeOfType(anchor, ListNode);
+ const listType = parent ? parent.getListType() : element.getListType();
+
+ blockType = listType === "bullet" ? "ul" : "ol";
+ }
+
+ setState((prev) => ({
+ ...prev,
+ bold,
+ italic,
+ underline,
+ strikethrough,
+ blockType,
+ }));
+ });
+ });
+ }, [editor]);
+
+ useEffect(() => {
+ const unregisterUndo = editor.registerCommand(
+ CAN_UNDO_COMMAND,
+ (payload) => {
+ setState((prev) => ({ ...prev, canUndo: payload }));
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ );
+
+ const unregisterRedo = editor.registerCommand(
+ CAN_REDO_COMMAND,
+ (payload) => {
+ setState((prev) => ({ ...prev, canRedo: payload }));
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ );
+
+ return () => {
+ unregisterUndo();
+ unregisterRedo();
+ };
+ }, [editor]);
+
+ const setBlock = (type: BlockType) => {
+ editor.update(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) return;
+
+ if (type === "ul") {
+ editor.dispatchCommand(
+ state.blockType === "ul" ? REMOVE_LIST_COMMAND : INSERT_UNORDERED_LIST_COMMAND,
+ undefined,
+ );
+ return;
+ }
+
+ if (type === "ol") {
+ editor.dispatchCommand(
+ state.blockType === "ol" ? REMOVE_LIST_COMMAND : INSERT_ORDERED_LIST_COMMAND,
+ undefined,
+ );
+ return;
+ }
+
+ if (type === "paragraph") {
+ $setBlocksType(selection, () => $createParagraphNode());
+ return;
+ }
+
+ $setBlocksType(selection, () => $createHeadingNode(type));
+ });
+ };
+
+ return (
+
+ {!antiDistraction && (
+ <>
+
editor.dispatchCommand(UNDO_COMMAND, undefined)}
+ >
+
+
+
editor.dispatchCommand(REDO_COMMAND, undefined)}
+ >
+
+
+
+
+
+
setBlock("h1")}
+ title="H1"
+ >
+ H1
+
+
setBlock("h2")}
+ title="H2"
+ >
+ H2
+
+
setBlock("h3")}
+ title="H3"
+ >
+ H3
+
+
setBlock("paragraph")}
+ title="Paragraph"
+ >
+
+
+
+
+
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")}
+ title="Bold"
+ >
+ B
+
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")}
+ title="Italic"
+ >
+ I
+
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline")}
+ title="Underline"
+ >
+ U
+
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough")}
+ title="Strike"
+ >
+ S
+
+
+
+
+
setBlock("ul")}
+ title="Bullet"
+ >
+
+
+
setBlock("ol")}
+ title="Numbered"
+ >
+
+
+ >
+ )}
+
+
{
+ setAntiDistraction(!antiDistraction);
+ void onToggleFullscreen(true);
+ }}
+ title={antiDistraction ? "Exit anti-distraction mode" : "Anti-distraction mode"}
+ rightButton={antiDistraction}
+ >
+ {antiDistraction ? : }
+
+ {!antiDistraction && (
+ <>
+
+
void onToggleFullscreen()}
+ title={isFullscreen ? "Exit fullscreen (Esc)" : "Fullscreen"}
+ rightButton
+ >
+ {isFullscreen ? : }
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/frontend/src/features/editor/plugins/AutoSavePlugin.tsx b/apps/frontend/src/features/editor/plugins/AutoSavePlugin.tsx
new file mode 100644
index 000000000..2fa21ee79
--- /dev/null
+++ b/apps/frontend/src/features/editor/plugins/AutoSavePlugin.tsx
@@ -0,0 +1,96 @@
+import { useEffect, useEffectEvent, useRef } from "react";
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import { $getRoot } from "lexical";
+import { ENV } from "@/env";
+import { toast } from "react-toastify";
+
+const DEBOUNCE_MS = 1000;
+
+interface Props {
+ noteId: string;
+ title: string;
+ initialContent: string;
+ setSaving: (saving: boolean) => void;
+ setUnsavedChanges: (val: boolean) => void;
+}
+
+export function AutoSavePlugin({ noteId, title, initialContent, setSaving, setUnsavedChanges }: Props) {
+ const [editor] = useLexicalComposerContext();
+
+ const timerRef = useRef | null>(null);
+ const controllerRef = useRef(null);
+ const lastSavedRef = useRef({ title, content: initialContent });
+
+ const saveNote = useEffectEvent(async (jsonString: string, wordCount: number) => {
+ try {
+ setSaving(true);
+ controllerRef.current?.abort();
+ controllerRef.current = new AbortController();
+
+ const res = await fetch(`${ENV.VITE_BACKEND_URL}/api/notes/${noteId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ signal: controllerRef.current.signal,
+ body: JSON.stringify({ title, content: jsonString, wordCount }),
+ });
+
+ if (!res.ok) throw new Error("Failed to save");
+
+ lastSavedRef.current = { title, content: jsonString };
+ setUnsavedChanges(false);
+ } catch (err) {
+ if (err.name === "AbortError") return;
+ console.error(err);
+ toast.error(err instanceof Error ? err.message : "Failed to auto-save.");
+ } finally {
+ setSaving(false);
+ }
+ });
+
+ const scheduleSave = useEffectEvent((flush = false) => {
+ const editorState = editor.getEditorState();
+ const json = editorState.toJSON();
+ const jsonString = JSON.stringify(json);
+
+ const hasChanges =
+ jsonString !== lastSavedRef.current.content || title !== lastSavedRef.current.title;
+
+ if (!hasChanges) return;
+
+ setUnsavedChanges(true);
+
+ let wordCount = 0;
+ editorState.read(() => {
+ wordCount = $getRoot().getTextContent().trim().split(/\s+/).filter(Boolean).length;
+ });
+
+ if (timerRef.current) clearTimeout(timerRef.current);
+
+ if (flush) {
+ void saveNote(jsonString, wordCount);
+ return;
+ }
+
+ timerRef.current = setTimeout(() => saveNote(jsonString, wordCount), DEBOUNCE_MS);
+ });
+
+ useEffect(() => {
+ const unregister = editor.registerUpdateListener(() => scheduleSave());
+
+ return () => {
+ unregister();
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ scheduleSave(true);
+ }
+ controllerRef.current?.abort();
+ };
+ }, [editor]);
+
+ useEffect(() => {
+ scheduleSave();
+ }, [title]);
+
+ return null;
+}
diff --git a/apps/frontend/src/features/editor/plugins/ContentLoaderPlugin.tsx b/apps/frontend/src/features/editor/plugins/ContentLoaderPlugin.tsx
new file mode 100644
index 000000000..b015f4080
--- /dev/null
+++ b/apps/frontend/src/features/editor/plugins/ContentLoaderPlugin.tsx
@@ -0,0 +1,21 @@
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import { useEffect } from "react";
+
+interface ContentLoaderPluginProps {
+ content: string;
+}
+
+export function ContentLoaderPlugin({ content }: ContentLoaderPluginProps) {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ if (content) {
+ const newState = editor.parseEditorState(content);
+ editor.setEditorState(newState);
+ }
+
+ editor.setEditable(true);
+ }, [editor, content]);
+
+ return null;
+}
diff --git a/apps/frontend/src/features/editor/plugins/WritingSessionPlugin.tsx b/apps/frontend/src/features/editor/plugins/WritingSessionPlugin.tsx
new file mode 100644
index 000000000..7e0a0ee53
--- /dev/null
+++ b/apps/frontend/src/features/editor/plugins/WritingSessionPlugin.tsx
@@ -0,0 +1,216 @@
+import { useEffect, useEffectEvent, useRef } from "react";
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import { apiFetch } from "@/lib/apiFetch";
+
+const DEBOUNCE_MS = 3_000;
+const PING_INTERVAL_MS = 30_000;
+
+const IGNORED_KEYS = new Set([
+ "ShiftLeft",
+ "ShiftRight",
+ "ControlLeft",
+ "ControlRight",
+ "AltLeft",
+ "AltRight",
+ "MetaLeft",
+ "MetaRight",
+ "CapsLock",
+]);
+
+interface KeystrokeTiming {
+ keyDownAtMs: number;
+ keyUpAtMs: number;
+ holdDurationMs: number;
+ interKeyDelayMs: number | null;
+}
+
+interface Props {
+ noteId: string;
+}
+
+export function WritingSessionPlugin({ noteId }: Props) {
+ const [editor] = useLexicalComposerContext();
+
+ const sessionIdRef = useRef(null);
+ const persistedRef = useRef(false);
+ const debounceTimerRef = useRef | null>(null);
+ const pingTimerRef = useRef | null>(null);
+
+ const activeKeysRef = useRef