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-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Editor directories and files
|
||||
.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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>grateful-journal</title>
|
||||
<title>Grateful Journal</title>
|
||||
</head>
|
||||
<body>
|
||||
<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"
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase": "^12.9.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
293
src/App.css
293
src/App.css
@@ -1,42 +1,277 @@
|
||||
/* Grateful Journal – green palette. Responsive layout. */
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
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;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
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);
|
||||
.login-card__brand {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
.login-card__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(1.5rem, 4.5vw, 1.875rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
.login-card__tagline {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: clamp(0.875rem, 2vw, 0.9375rem);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
.login-card__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
.login-card__error {
|
||||
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 reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import HomePage from './pages/HomePage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
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: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
--color-primary: #1be62c;
|
||||
--color-primary-hover: #18c925;
|
||||
--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;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -15,54 +32,37 @@
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
color: var(--color-primary);
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-width: 280px;
|
||||
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 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
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