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 ( + <> + + + + + +