google sign in page done
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Firebase – copy to .env and fill with your Firebase project config
|
||||||
|
# Get these from Firebase Console → Project settings → General → Your apps
|
||||||
|
|
||||||
|
VITE_FIREBASE_API_KEY=
|
||||||
|
VITE_FIREBASE_AUTH_DOMAIN=
|
||||||
|
VITE_FIREBASE_DATABASE_URL=
|
||||||
|
VITE_FIREBASE_PROJECT_ID=
|
||||||
|
VITE_FIREBASE_STORAGE_BUCKET=
|
||||||
|
VITE_FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
VITE_FIREBASE_APP_ID=
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
63
docs/auth-options-vite-react.md
Normal file
63
docs/auth-options-vite-react.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Auth options for Vite + React (SPA)
|
||||||
|
|
||||||
|
In **Next.js**, **NextAuth.js** (now **Auth.js**) is the standard: it gives you OAuth (Google, GitHub, etc.), sessions, callbacks, and JWT/cookies on the server.
|
||||||
|
|
||||||
|
For a **Vite + React** app (client-side SPA), there is no single “NextAuth for Vite.” You pick by how much you want to host yourself vs use a service.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. **Firebase Authentication** (what this project uses)
|
||||||
|
|
||||||
|
- **What it is:** Google’s auth service; add SDK, configure providers (e.g. Google), get sign-in in the browser.
|
||||||
|
- **Pros:** Free tier, no backend required for basic “Sign in with Google,” works great with Vite + React.
|
||||||
|
- **Cons:** You’re tied to Firebase; for custom session logic you still need your own backend.
|
||||||
|
- **Use when:** You want the fastest “Sign in with Google” (or email link, etc.) with no backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. **Clerk**
|
||||||
|
|
||||||
|
- **What it is:** Hosted auth (like Auth0) with pre-built React components and APIs.
|
||||||
|
- **Pros:** Drop-in `<SignIn />` / `<SignUp />`, many providers, user management dashboard, works with any frontend (including Vite + React).
|
||||||
|
- **Cons:** Paid after free tier; vendor lock-in.
|
||||||
|
- **Use when:** You want a “NextAuth-like” experience (components + dashboard) without running your own auth server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. **Auth0** (Okta)
|
||||||
|
|
||||||
|
- **What it is:** Hosted identity platform; SDK + hosted login pages or embedded UI.
|
||||||
|
- **Pros:** Enterprise-ready, many providers, fine-grained controls.
|
||||||
|
- **Cons:** Can be heavy and costly for small apps.
|
||||||
|
- **Use when:** You need enterprise features or already use Okta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. **Supabase Auth**
|
||||||
|
|
||||||
|
- **What it is:** Auth layer from Supabase (DB + realtime + storage).
|
||||||
|
- **Pros:** Google, magic link, email/password; integrates with Supabase DB and RLS.
|
||||||
|
- **Cons:** Makes most sense if you use Supabase for data.
|
||||||
|
- **Use when:** Your backend (or “BaaS”) is Supabase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. **Auth.js (formerly NextAuth) + your own backend**
|
||||||
|
|
||||||
|
- **What it is:** The same library NextAuth uses; you run it on **Express**, **Fastify**, or another Node server. Your Vite app is just a client that talks to that API.
|
||||||
|
- **Pros:** Full control, sessions/callbacks like in Next.js, same mental model as NextAuth.
|
||||||
|
- **Cons:** You build and run the backend; CORS and cookie/session handling for SPA.
|
||||||
|
- **Use when:** You want “NextAuth” behavior (sessions, callbacks, multiple providers) with an Express (or similar) API and a Vite React frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Need | Good fit |
|
||||||
|
|-----------------------------|------------------------|
|
||||||
|
| “Sign in with Google” only | **Firebase Auth** |
|
||||||
|
| Pre-built UI + dashboard | **Clerk** |
|
||||||
|
| Backend is Supabase | **Supabase Auth** |
|
||||||
|
| NextAuth-style + Express | **Auth.js on Express** |
|
||||||
|
|
||||||
|
This app uses **Firebase Auth** for a simple, backend-free Google sign-in that works well with Vite + React.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>grateful-journal</title>
|
<title>Grateful Journal</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1056
package-lock.json
generated
1056
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"firebase": "^12.9.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
293
src/App.css
293
src/App.css
@@ -1,42 +1,277 @@
|
|||||||
|
/* Grateful Journal – green palette. Responsive layout. */
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
max-width: 1280px;
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
min-height: 100dvh;
|
||||||
padding: 2rem;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Protected route loading ----- */
|
||||||
|
.protected-route__loading {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--color-bg-soft);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.protected-route__spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: login-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes login-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Login page – green accent aesthetic ----- */
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(160deg, var(--color-bg-soft) 0%, var(--color-accent-light) 100%);
|
||||||
|
padding: clamp(0.75rem, 3vw, 1.5rem);
|
||||||
|
padding-left: max(clamp(0.75rem, 3vw, 1.5rem), env(safe-area-inset-left));
|
||||||
|
padding-right: max(clamp(0.75rem, 3vw, 1.5rem), env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: login-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login card – green accent */
|
||||||
|
.login-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: clamp(1.5rem, 6vw, 2.75rem);
|
||||||
|
border-radius: 16px;
|
||||||
|
border-top: 4px solid var(--color-primary);
|
||||||
|
box-shadow: 0 8px 32px rgba(27, 230, 44, 0.08), 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.login-card__brand {
|
||||||
height: 6em;
|
margin-bottom: 1.75rem;
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes logo-spin {
|
.login-card__title {
|
||||||
from {
|
margin: 0 0 0.5rem;
|
||||||
transform: rotate(0deg);
|
font-size: clamp(1.5rem, 4.5vw, 1.875rem);
|
||||||
}
|
font-weight: 600;
|
||||||
to {
|
color: var(--color-primary);
|
||||||
transform: rotate(360deg);
|
letter-spacing: -0.02em;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
.login-card__tagline {
|
||||||
a:nth-of-type(2) .logo {
|
margin: 0;
|
||||||
animation: logo-spin infinite 20s linear;
|
color: var(--color-text-muted);
|
||||||
}
|
font-size: clamp(0.875rem, 2vw, 0.9375rem);
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.login-card__actions {
|
||||||
padding: 2em;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.read-the-docs {
|
.login-card__error {
|
||||||
color: #888;
|
margin: 0;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sign in with Google button – standard Google-style UI */
|
||||||
|
.google-sign-in-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #3c4043;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-sign-in-btn:hover:not(:disabled) {
|
||||||
|
background: #f8f9fa;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-sign-in-btn:active:not(:disabled) {
|
||||||
|
background: #f1f3f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-sign-in-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-sign-in-btn__logo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-sign-in-btn__label {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Home page ----- */
|
||||||
|
.home-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--color-bg-soft, #f1eee1);
|
||||||
|
padding: clamp(0.75rem, 3vw, 1.5rem);
|
||||||
|
padding-left: max(clamp(0.75rem, 3vw, 1.5rem), env(safe-area-inset-left));
|
||||||
|
padding-right: max(clamp(0.75rem, 3vw, 1.5rem), env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.2rem, 3.5vw, 1.5rem);
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-username {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: min(50vw, 180px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-sign-out {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #555);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border, #cff2dc);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-sign-out:hover {
|
||||||
|
background: var(--color-surface, #ffffff);
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-main {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--color-surface, #ffffff);
|
||||||
|
padding: clamp(1rem, 4vw, 2rem);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-welcome {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: clamp(1.15rem, 3vw, 1.35rem);
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-sub {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted, #555);
|
||||||
|
font-size: clamp(0.875rem, 2vw, 0.95rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-login-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-primary, #1be62c);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-login-link:hover {
|
||||||
|
background: var(--color-primary-hover, #18c925);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small screens: stack header and tighten spacing */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.home-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-user {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-username {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/App.tsx
48
src/App.tsx
@@ -1,34 +1,28 @@
|
|||||||
import { useState } from 'react'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import reactLogo from './assets/react.svg'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import viteLogo from '/vite.svg'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
|
import HomePage from './pages/HomePage'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AuthProvider>
|
||||||
<div>
|
<BrowserRouter>
|
||||||
<a href="https://vite.dev" target="_blank">
|
<Routes>
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
<Route
|
||||||
</a>
|
path="/"
|
||||||
<a href="https://react.dev" target="_blank">
|
element={
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
<ProtectedRoute>
|
||||||
</a>
|
<HomePage />
|
||||||
</div>
|
</ProtectedRoute>
|
||||||
<h1>Vite + React</h1>
|
}
|
||||||
<div className="card">
|
/>
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
count is {count}
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</button>
|
</Routes>
|
||||||
<p>
|
</BrowserRouter>
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
</AuthProvider>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="read-the-docs">
|
|
||||||
Click on the Vite and React logos to learn more
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
src/components/GoogleSignInButton.tsx
Normal file
53
src/components/GoogleSignInButton.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from 'react'
|
||||||
|
|
||||||
|
/** Google "G" logo – standard multicolor mark */
|
||||||
|
function GoogleLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoogleSignInButton({ loading = false, disabled, className = '', ...rest }: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`google-sign-in-btn ${className}`.trim()}
|
||||||
|
disabled={disabled ?? loading}
|
||||||
|
aria-busy={loading}
|
||||||
|
aria-label={loading ? 'Signing in…' : 'Sign in with Google'}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<GoogleLogo className="google-sign-in-btn__logo" />
|
||||||
|
<span className="google-sign-in-btn__label">
|
||||||
|
{loading ? 'Signing in…' : 'Sign in with Google'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/components/LoginCard.tsx
Normal file
21
src/components/LoginCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string
|
||||||
|
tagline?: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginCard({ title, tagline, children }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-card__brand">
|
||||||
|
<h1 className="login-card__title">{title}</h1>
|
||||||
|
{tagline && <p className="login-card__tagline">{tagline}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="login-card__actions">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/components/ProtectedRoute.tsx
Normal file
27
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: Props) {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="protected-route__loading" aria-live="polite">
|
||||||
|
<span className="protected-route__spinner" aria-hidden />
|
||||||
|
<p>Loading…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
68
src/contexts/AuthContext.tsx
Normal file
68
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
browserLocalPersistence,
|
||||||
|
onAuthStateChanged,
|
||||||
|
setPersistence,
|
||||||
|
signInWithPopup,
|
||||||
|
signOut as firebaseSignOut,
|
||||||
|
type User,
|
||||||
|
} from 'firebase/auth'
|
||||||
|
import { auth, googleProvider } from '../lib/firebase'
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
signInWithGoogle: () => Promise<void>
|
||||||
|
signOut: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = onAuthStateChanged(auth, (u) => {
|
||||||
|
setUser(u)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
return () => unsubscribe()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function signInWithGoogle() {
|
||||||
|
await setPersistence(auth, browserLocalPersistence)
|
||||||
|
await signInWithPopup(auth, googleProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await firebaseSignOut(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
signInWithGoogle,
|
||||||
|
signOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (ctx == null) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -1,12 +1,29 @@
|
|||||||
|
/* Grateful Journal – green palette from Coolors */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
/* Responsive base: 16px at 320px, scales up to 18px by 768px */
|
||||||
|
font-size: clamp(1rem, 0.9rem + 0.25vw, 1.125rem);
|
||||||
|
|
||||||
color-scheme: light dark;
|
--color-primary: #1be62c;
|
||||||
color: rgba(255, 255, 255, 0.87);
|
--color-primary-hover: #18c925;
|
||||||
background-color: #242424;
|
--color-bg-soft: #f1eee1;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-accent-light: #cff2dc;
|
||||||
|
--color-accent-bright: #c3fd2f;
|
||||||
|
--color-text: #1a1a1a;
|
||||||
|
--color-text-muted: #555;
|
||||||
|
--color-border: #cff2dc;
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg-soft);
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -15,54 +32,37 @@
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #646cff;
|
color: var(--color-primary);
|
||||||
text-decoration: inherit;
|
text-decoration: inherit;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
color: #535bf2;
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
min-width: 280px;
|
||||||
place-items: center;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 3.2em;
|
font-size: 1.75rem;
|
||||||
line-height: 1.1;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
}
|
||||||
button:focus,
|
button:focus,
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
outline: 2px solid var(--color-primary);
|
||||||
}
|
outline-offset: 2px;
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/lib/firebase.ts
Normal file
16
src/lib/firebase.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { initializeApp } from 'firebase/app'
|
||||||
|
import { getAuth, GoogleAuthProvider } from 'firebase/auth'
|
||||||
|
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||||
|
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||||
|
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
|
||||||
|
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||||
|
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||||
|
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||||
|
appId: import.meta.env.VITE_FIREBASE_APP_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = initializeApp(firebaseConfig)
|
||||||
|
export const auth = getAuth(app)
|
||||||
|
export const googleProvider = new GoogleAuthProvider()
|
||||||
47
src/pages/HomePage.tsx
Normal file
47
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const { user, loading, signOut } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="home-page">
|
||||||
|
<p>Loading…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="home-page">
|
||||||
|
<h1>Grateful Journal</h1>
|
||||||
|
<p>Sign in to start your journal.</p>
|
||||||
|
<Link to="/login" className="home-login-link">
|
||||||
|
Go to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
user.displayName ?? user.email ?? 'there'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home-page">
|
||||||
|
<header className="home-header">
|
||||||
|
<h1>Grateful Journal</h1>
|
||||||
|
<div className="home-user">
|
||||||
|
<span className="home-username">{displayName}</span>
|
||||||
|
<button type="button" className="home-sign-out" onClick={() => signOut()}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="home-main">
|
||||||
|
<p className="home-welcome">Hello, {displayName}.</p>
|
||||||
|
<p className="home-sub">Your writing space will go here.</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/pages/LoginPage.tsx
Normal file
61
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { GoogleSignInButton } from '../components/GoogleSignInButton'
|
||||||
|
import { LoginCard } from '../components/LoginCard'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { user, loading, signInWithGoogle } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [signingIn, setSigningIn] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return
|
||||||
|
if (user) {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
}, [user, loading, navigate])
|
||||||
|
|
||||||
|
async function handleGoogleSignIn() {
|
||||||
|
setError(null)
|
||||||
|
setSigningIn(true)
|
||||||
|
try {
|
||||||
|
await signInWithGoogle()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Sign-in failed')
|
||||||
|
} finally {
|
||||||
|
setSigningIn(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-page__loading" aria-live="polite">
|
||||||
|
<span className="login-page__spinner" aria-hidden />
|
||||||
|
<p>Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<LoginCard
|
||||||
|
title="Grateful Journal"
|
||||||
|
tagline="A minimal, private space for gratitude and reflection. No feeds, no noise—just you and your thoughts."
|
||||||
|
>
|
||||||
|
<GoogleSignInButton
|
||||||
|
loading={signingIn}
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="login-card__error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</LoginCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user