feat: add gmail-hook monorepo with watcher, api, and web

Ship the receipt pipeline (Livin/Grab/BRI), PocketBase spendings, PM2 ecosystem, and a proper .gitignore that excludes secrets, SQLite data, and media dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 22:58:47 +07:00
co-authored by Cursor
parent 8fa9f4350e
commit 46a2e2a7b0
67 changed files with 12153 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gmail Reader</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.13",
"@vitejs/plugin-react": "^5.0.2",
"daisyui": "^5.1.13",
"tailwindcss": "^4.1.13",
"vite": "^7.1.5"
}
}
+56
View File
@@ -0,0 +1,56 @@
import { Navigate, Outlet, Route, Routes } from "react-router-dom";
import { useAuth } from "./auth.jsx";
import Layout from "./components/Layout.jsx";
import Inbox from "./pages/Inbox.jsx";
import Login from "./pages/Login.jsx";
import Message from "./pages/Message.jsx";
function Protected() {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="grid min-h-screen place-items-center">
<span className="loading loading-spinner loading-lg" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
return (
<Layout>
<Outlet />
</Layout>
);
}
function GuestOnly({ children }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="grid min-h-screen place-items-center">
<span className="loading loading-spinner loading-lg" />
</div>
);
}
if (user) return <Navigate to="/" replace />;
return children;
}
export default function App() {
return (
<Routes>
<Route
path="/login"
element={
<GuestOnly>
<Login />
</GuestOnly>
}
/>
<Route element={<Protected />}>
<Route path="/" element={<Inbox />} />
<Route path="/messages/:id" element={<Message />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+29
View File
@@ -0,0 +1,29 @@
export class AuthError extends Error {
constructor() {
super("Not authenticated");
this.name = "AuthError";
}
}
export async function api(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (options.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(path, {
credentials: "include",
...options,
headers,
});
if (res.status === 401) {
throw new AuthError();
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || res.statusText);
}
return data;
}
+49
View File
@@ -0,0 +1,49 @@
import { createContext, useContext, useEffect, useState } from "react";
import { api, AuthError } from "./api.js";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
api("/api/me")
.then((me) => {
if (!cancelled) setUser(me);
})
.catch((err) => {
if (!cancelled && !(err instanceof AuthError)) {
console.error(err);
}
if (!cancelled) setUser(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
async function logout() {
try {
await api("/auth/logout", { method: "POST" });
} finally {
setUser(null);
}
}
return (
<AuthContext.Provider value={{ user, loading, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+34
View File
@@ -0,0 +1,34 @@
import { Link } from "react-router-dom";
import { useAuth } from "../auth.jsx";
export default function Layout({ children }) {
const { user, logout } = useAuth();
return (
<div className="min-h-screen bg-base-200">
<header className="navbar border-b border-base-300 bg-base-100 px-4">
<div className="flex-1">
<Link to="/" className="text-lg font-semibold tracking-tight">
Gmail Reader
</Link>
</div>
<div className="flex items-center gap-3">
<span className="hidden text-sm opacity-70 sm:inline">
{user?.email}
</span>
{user?.picture ? (
<div className="avatar">
<div className="w-8 rounded-full">
<img src={user.picture} alt="" referrerPolicy="no-referrer" />
</div>
</div>
) : null}
<button type="button" className="btn btn-ghost btn-sm" onClick={logout}>
Log out
</button>
</div>
</header>
<main className="mx-auto max-w-4xl px-4 py-6">{children}</main>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: corporate --default, business --prefersdark;
}
html,
body,
#root {
min-height: 100%;
}
.message-body {
white-space: pre-wrap;
word-break: break-word;
font-family: ui-sans-serif, system-ui, sans-serif;
line-height: 1.55;
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import { AuthProvider } from "./auth.jsx";
import "./index.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { api } from "../api.js";
function formatDate(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function Inbox() {
const [searchParams, setSearchParams] = useSearchParams();
const q = searchParams.get("q") || "";
const [draft, setDraft] = useState(q);
const [messages, setMessages] = useState([]);
const [nextPageToken, setNextPageToken] = useState(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setDraft(q);
}, [q]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (q) params.set("q", q);
api(`/api/messages?${params}`)
.then((data) => {
if (cancelled) return;
setMessages(data.messages);
setNextPageToken(data.nextPageToken);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [q]);
function onSearch(event) {
event.preventDefault();
const next = draft.trim();
if (next) setSearchParams({ q: next });
else setSearchParams({});
}
async function loadMore() {
if (!nextPageToken) return;
setLoadingMore(true);
setError(null);
try {
const params = new URLSearchParams({ pageToken: nextPageToken });
if (q) params.set("q", q);
const data = await api(`/api/messages?${params}`);
setMessages((prev) => [...prev, ...data.messages]);
setNextPageToken(data.nextPageToken);
} catch (err) {
setError(err.message);
} finally {
setLoadingMore(false);
}
}
return (
<div className="space-y-4">
<form onSubmit={onSearch} className="join w-full">
<input
className="input join-item input-bordered w-full"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder='Search Gmail, e.g. from:alice subject:invoice'
aria-label="Search mail"
/>
<button type="submit" className="btn btn-primary join-item">
Search
</button>
</form>
{q ? (
<p className="text-sm opacity-70">
Results for <span className="font-mono">{q}</span>{" "}
<button
type="button"
className="link link-hover"
onClick={() => setSearchParams({})}
>
clear
</button>
</p>
) : (
<h2 className="text-sm font-medium opacity-70">Latest mail</h2>
)}
{error ? (
<div role="alert" className="alert alert-error text-sm">
{error}
</div>
) : null}
{loading ? (
<div className="flex justify-center py-16">
<span className="loading loading-spinner loading-lg" />
</div>
) : messages.length === 0 ? (
<div className="card bg-base-100">
<div className="card-body items-center text-center opacity-70">
<p>No messages found.</p>
</div>
</div>
) : (
<ul className="menu rounded-box bg-base-100 p-0 shadow">
{messages.map((message) => (
<li
key={message.id}
className="border-b border-base-200 last:border-b-0"
>
<Link
to={`/messages/${message.id}`}
className="flex flex-col items-stretch gap-1 py-3"
>
<div className="flex items-baseline justify-between gap-3">
<span className="truncate font-medium">{message.from}</span>
<time className="shrink-0 text-xs opacity-60">
{formatDate(message.date)}
</time>
</div>
<span className="truncate">{message.subject}</span>
<span className="line-clamp-1 text-sm opacity-60">
{message.snippet}
</span>
</Link>
</li>
))}
</ul>
)}
{nextPageToken && !loading ? (
<div className="flex justify-center">
<button
type="button"
className="btn btn-outline"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? (
<span className="loading loading-spinner" />
) : (
"Load more"
)}
</button>
</div>
) : null}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useSearchParams } from "react-router-dom";
const ERRORS = {
denied: "Google access was denied.",
missing_code: "OAuth callback was missing a code.",
invalid_state: "OAuth state did not match. Try signing in again.",
exchange_failed: "Could not complete Google sign-in.",
};
export default function Login() {
const [params] = useSearchParams();
const error = ERRORS[params.get("error")] || null;
return (
<div className="grid min-h-screen place-items-center bg-base-200 px-4">
<div className="card w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body gap-4">
<h1 className="card-title text-2xl">Gmail Reader</h1>
<p className="text-sm opacity-70">
Sign in with Google to list, search, and read mail. Access is
read-only.
</p>
{error ? (
<div role="alert" className="alert alert-error text-sm">
{error}
</div>
) : null}
<a href="/auth/google" className="btn btn-primary">
Sign in with Google
</a>
</div>
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api.js";
function formatDate(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
export default function Message() {
const { id } = useParams();
const [message, setMessage] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api(`/api/messages/${encodeURIComponent(id)}`)
.then((data) => {
if (!cancelled) setMessage(data);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id]);
return (
<div className="space-y-4">
<Link to="/" className="btn btn-ghost btn-sm">
← Inbox
</Link>
{loading ? (
<div className="flex justify-center py-16">
<span className="loading loading-spinner loading-lg" />
</div>
) : error ? (
<div role="alert" className="alert alert-error text-sm">
{error}
</div>
) : message ? (
<article className="card bg-base-100 shadow">
<div className="card-body gap-4">
<h1 className="card-title text-xl">{message.subject}</h1>
<dl className="grid gap-1 text-sm">
<div className="flex gap-2">
<dt className="opacity-60">From</dt>
<dd>{message.from}</dd>
</div>
{message.to ? (
<div className="flex gap-2">
<dt className="opacity-60">To</dt>
<dd>{message.to}</dd>
</div>
) : null}
<div className="flex gap-2">
<dt className="opacity-60">Date</dt>
<dd>{formatDate(message.date)}</dd>
</div>
</dl>
<div className="divider my-0" />
<div className="message-body text-sm">
{message.body || message.snippet || "(empty message)"}
</div>
</div>
</article>
) : null}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
strictPort: true,
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/callback": "http://localhost:3000",
},
},
});