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:
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user