Compare commits
2 Commits
07df39184e
...
e841860bd4
| Author | SHA1 | Date | |
|---|---|---|---|
| e841860bd4 | |||
| ef52695bd9 |
8687
package-lock.json
generated
8687
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"firebase": "^12.9.0",
|
"firebase": "^12.9.0",
|
||||||
"libsodium-wrappers": "^0.8.2",
|
"libsodium-wrappers": "^0.8.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
366
src/App.css
366
src/App.css
@@ -754,6 +754,114 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-edit-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #22c55e;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-edit-btn:hover {
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Sniglet", system-ui;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-avatar {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #86efac 0%, #22c55e 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
font-family: "Sniglet", system-ui;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-avatar-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-remove-photo {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-remove-photo:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-save {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-save:hover:not(:disabled) {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-save:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -932,6 +1040,30 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* See Tutorial */
|
||||||
|
.settings-tutorial-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem 1.125rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary, #22c55e);
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
font-family: "Sniglet", system-ui;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
.settings-tutorial-btn:hover {
|
||||||
|
background: var(--color-accent-light, #dcfce7);
|
||||||
|
}
|
||||||
|
|
||||||
/* Clear Data */
|
/* Clear Data */
|
||||||
.settings-clear-btn {
|
.settings-clear-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1655,6 +1787,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* -- Settings buttons -- */
|
/* -- Settings buttons -- */
|
||||||
|
[data-theme="dark"] .settings-tutorial-btn {
|
||||||
|
background: #1a1a1a;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .settings-tutorial-btn:hover {
|
||||||
|
background: rgba(74, 222, 128, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .settings-clear-btn {
|
[data-theme="dark"] .settings-clear-btn {
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
color: #f87171;
|
color: #f87171;
|
||||||
@@ -1682,6 +1823,19 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .settings-edit-btn {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .settings-edit-btn:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .edit-modal-title {
|
||||||
|
color: #e8f5e8;
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Settings divider -- */
|
/* -- Settings divider -- */
|
||||||
[data-theme="dark"] .settings-divider {
|
[data-theme="dark"] .settings-divider {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
@@ -1872,3 +2026,215 @@
|
|||||||
border-color: rgba(74, 222, 128, 0.3);
|
border-color: rgba(74, 222, 128, 0.3);
|
||||||
box-shadow: 0 1px 8px rgba(74, 222, 128, 0.1);
|
box-shadow: 0 1px 8px rgba(74, 222, 128, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
WELCOME MODAL
|
||||||
|
============================ */
|
||||||
|
.welcome-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1.5rem;
|
||||||
|
animation: welcome-fade-in 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes welcome-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes welcome-slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal {
|
||||||
|
background: var(--color-surface, #ffffff);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2.5rem 2rem 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
animation: welcome-slide-up 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 1.25rem;
|
||||||
|
background: var(--color-accent-light, #dcfce7);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--color-primary, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal-title {
|
||||||
|
font-family: "Sniglet", system-ui;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal-text {
|
||||||
|
font-family: "Sniglet", system-ui;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal-btn {
|
||||||
|
font-family: "Sniglet", system-ui;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-primary, #22c55e);
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.875rem 2rem;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal-btn:hover {
|
||||||
|
background: #16a34a;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal-skip {
|
||||||
|
font-family: "Sniglet", system-ui;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal-skip:hover {
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
DRIVER.JS TOUR CUSTOMIZATION
|
||||||
|
============================ */
|
||||||
|
.gj-tour-popover {
|
||||||
|
font-family: "Sniglet", system-ui !important;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gj-tour-popover .driver-popover-title {
|
||||||
|
font-family: "Sniglet", system-ui !important;
|
||||||
|
font-size: 1.05rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: var(--color-text, #1a1a1a) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gj-tour-popover .driver-popover-description {
|
||||||
|
font-family: "Sniglet", system-ui !important;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
line-height: 1.55 !important;
|
||||||
|
color: var(--color-text-muted, #6b7280) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gj-tour-popover .driver-popover-progress-text {
|
||||||
|
font-family: "Sniglet", system-ui !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
color: var(--color-text-muted, #6b7280) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gj-tour-popover .driver-popover-navigation-btns button {
|
||||||
|
font-family: "Sniglet", system-ui !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
font-size: 0.825rem !important;
|
||||||
|
padding: 0.4rem 1rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gj-tour-popover .driver-popover-next-btn {
|
||||||
|
background: var(--color-primary, #22c55e) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border: none !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gj-tour-popover .driver-popover-prev-btn {
|
||||||
|
color: var(--color-text-muted, #6b7280) !important;
|
||||||
|
border: 1px solid var(--color-border, #d4e8d4) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gj-tour-popover .driver-popover-close-btn {
|
||||||
|
color: var(--color-text-muted, #6b7280) !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gj-tour-popover .driver-popover-close-btn:focus,
|
||||||
|
.gj-tour-popover .driver-popover-close-btn:focus-visible {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Dark theme: welcome modal -- */
|
||||||
|
[data-theme="dark"] .welcome-modal {
|
||||||
|
background: #1a1a1a;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Dark theme: driver.js -- */
|
||||||
|
[data-theme="dark"] .gj-tour-popover {
|
||||||
|
background: #1a1a1a !important;
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .gj-tour-popover .driver-popover-title {
|
||||||
|
color: #e8f5e8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .gj-tour-popover .driver-popover-description {
|
||||||
|
color: #7a8a7a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .gj-tour-popover .driver-popover-prev-btn {
|
||||||
|
border-color: rgba(74, 222, 128, 0.15) !important;
|
||||||
|
color: #7a8a7a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .gj-tour-popover .driver-popover-close-btn {
|
||||||
|
color: #7a8a7a !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default function BottomNav() {
|
|||||||
{/* History */}
|
{/* History */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
id="tour-nav-history"
|
||||||
className={`bottom-nav-btn ${isActive('/history') ? 'bottom-nav-btn-active' : ''}`}
|
className={`bottom-nav-btn ${isActive('/history') ? 'bottom-nav-btn-active' : ''}`}
|
||||||
onClick={() => navigate('/history')}
|
onClick={() => navigate('/history')}
|
||||||
aria-label="History"
|
aria-label="History"
|
||||||
@@ -44,6 +45,7 @@ export default function BottomNav() {
|
|||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
id="tour-nav-settings"
|
||||||
className={`bottom-nav-btn ${isActive('/settings') ? 'bottom-nav-btn-active' : ''}`}
|
className={`bottom-nav-btn ${isActive('/settings') ? 'bottom-nav-btn-active' : ''}`}
|
||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
|
|||||||
30
src/components/WelcomeModal.tsx
Normal file
30
src/components/WelcomeModal.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
interface WelcomeModalProps {
|
||||||
|
onStart: () => void
|
||||||
|
onSkip: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WelcomeModal({ onStart, onSkip }: WelcomeModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="welcome-modal-overlay" onClick={onSkip}>
|
||||||
|
<div className="welcome-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="welcome-modal-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="welcome-modal-title">Welcome to Grateful Journal</h2>
|
||||||
|
<p className="welcome-modal-text">
|
||||||
|
A private, peaceful space to capture what you're grateful for — every day.
|
||||||
|
Your entries are end-to-end encrypted, so only you can read them.
|
||||||
|
No feeds, no noise — just you and your thoughts.
|
||||||
|
</p>
|
||||||
|
<button className="welcome-modal-btn" onClick={onStart}>
|
||||||
|
Start Your Journey
|
||||||
|
</button>
|
||||||
|
<button className="welcome-modal-skip" onClick={onSkip}>
|
||||||
|
Skip tour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,13 +29,23 @@ import {
|
|||||||
getEncryptedSecretKey,
|
getEncryptedSecretKey,
|
||||||
} from '../lib/crypto'
|
} from '../lib/crypto'
|
||||||
|
|
||||||
|
type MongoUser = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
displayName?: string
|
||||||
|
photoURL?: string
|
||||||
|
theme?: string
|
||||||
|
}
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: User | null
|
user: User | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
|
mongoUser: MongoUser | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
secretKey: Uint8Array | null
|
secretKey: Uint8Array | null
|
||||||
signInWithGoogle: () => Promise<void>
|
signInWithGoogle: () => Promise<void>
|
||||||
signOut: () => Promise<void>
|
signOut: () => Promise<void>
|
||||||
|
refreshMongoUser: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
@@ -43,6 +53,7 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [userId, setUserId] = useState<string | null>(null)
|
const [userId, setUserId] = useState<string | null>(null)
|
||||||
|
const [mongoUser, setMongoUser] = useState<MongoUser | null>(null)
|
||||||
const [secretKey, setSecretKey] = useState<Uint8Array | null>(null)
|
const [secretKey, setSecretKey] = useState<Uint8Array | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -114,9 +125,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Try to get existing user
|
// Try to get existing user
|
||||||
try {
|
try {
|
||||||
console.log('[Auth] Fetching user by email:', email)
|
console.log('[Auth] Fetching user by email:', email)
|
||||||
const existingUser = await getUserByEmail(email, token) as { id: string }
|
const existingUser = await getUserByEmail(email, token) as MongoUser
|
||||||
console.log('[Auth] Found existing user:', existingUser.id)
|
console.log('[Auth] Found existing user:', existingUser.id)
|
||||||
setUserId(existingUser.id)
|
setUserId(existingUser.id)
|
||||||
|
setMongoUser(existingUser)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Auth] User not found, registering...', error)
|
console.warn('[Auth] User not found, registering...', error)
|
||||||
// User doesn't exist, register them
|
// User doesn't exist, register them
|
||||||
@@ -127,9 +139,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
photoURL: authUser.photoURL || undefined,
|
photoURL: authUser.photoURL || undefined,
|
||||||
},
|
},
|
||||||
token
|
token
|
||||||
) as { id: string }
|
) as MongoUser
|
||||||
console.log('[Auth] Registered new user:', newUser.id)
|
console.log('[Auth] Registered new user:', newUser.id)
|
||||||
setUserId(newUser.id)
|
setUserId(newUser.id)
|
||||||
|
setMongoUser(newUser)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Auth] Error syncing user with database:', error)
|
console.error('[Auth] Error syncing user with database:', error)
|
||||||
@@ -148,6 +161,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setUserId(null)
|
setUserId(null)
|
||||||
|
setMongoUser(null)
|
||||||
setSecretKey(null)
|
setSecretKey(null)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -160,9 +174,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
await signInWithPopup(auth, googleProvider)
|
await signInWithPopup(auth, googleProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshMongoUser() {
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
const token = await user.getIdToken()
|
||||||
|
const email = user.email!
|
||||||
|
const updated = await getUserByEmail(email, token) as MongoUser
|
||||||
|
setMongoUser(updated)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Error refreshing mongo user:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
// Clear secret key from memory
|
// Clear secret key from memory
|
||||||
setSecretKey(null)
|
setSecretKey(null)
|
||||||
|
setMongoUser(null)
|
||||||
|
// Reset onboarding so tour shows again on next login
|
||||||
|
localStorage.removeItem('gj-onboarding-done')
|
||||||
|
localStorage.removeItem('gj-tour-pending-step')
|
||||||
// Keep device key and encrypted key for next login
|
// Keep device key and encrypted key for next login
|
||||||
// Do NOT clear localStorage or IndexedDB
|
// Do NOT clear localStorage or IndexedDB
|
||||||
await firebaseSignOut(auth)
|
await firebaseSignOut(auth)
|
||||||
@@ -172,10 +202,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const value: AuthContextValue = {
|
const value: AuthContextValue = {
|
||||||
user,
|
user,
|
||||||
userId,
|
userId,
|
||||||
|
mongoUser,
|
||||||
secretKey,
|
secretKey,
|
||||||
loading,
|
loading,
|
||||||
signInWithGoogle,
|
signInWithGoogle,
|
||||||
signOut,
|
signOut,
|
||||||
|
refreshMongoUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
216
src/hooks/useOnboardingTour.ts
Normal file
216
src/hooks/useOnboardingTour.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { driver, type DriveStep } from 'driver.js'
|
||||||
|
import 'driver.js/dist/driver.css'
|
||||||
|
|
||||||
|
const ONBOARDING_KEY = 'gj-onboarding-done'
|
||||||
|
const TOUR_PENDING_KEY = 'gj-tour-pending-step'
|
||||||
|
|
||||||
|
export function hasSeenOnboarding(): boolean {
|
||||||
|
return localStorage.getItem(ONBOARDING_KEY) === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markOnboardingDone(): void {
|
||||||
|
localStorage.setItem(ONBOARDING_KEY, 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPendingTourStep(): string | null {
|
||||||
|
return localStorage.getItem(TOUR_PENDING_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingTourStep(): void {
|
||||||
|
localStorage.removeItem(TOUR_PENDING_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverDefaults() {
|
||||||
|
return {
|
||||||
|
showProgress: true,
|
||||||
|
animate: true,
|
||||||
|
allowClose: true,
|
||||||
|
overlayColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
stagePadding: 8,
|
||||||
|
stageRadius: 12,
|
||||||
|
popoverClass: 'gj-tour-popover',
|
||||||
|
nextBtnText: 'Next',
|
||||||
|
prevBtnText: 'Back',
|
||||||
|
doneBtnText: 'Got it!',
|
||||||
|
progressText: '{{current}} of {{total}}',
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHomeSteps(isMobile: boolean): DriveStep[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
element: '#tour-title-input',
|
||||||
|
popover: {
|
||||||
|
title: 'Give it a Title',
|
||||||
|
description: 'Start by naming your gratitude entry. A short title helps you find it later.',
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-content-textarea',
|
||||||
|
popover: {
|
||||||
|
title: 'Write Your Thoughts',
|
||||||
|
description: 'Pour out what you\'re grateful for today. There\'s no right or wrong — just write from the heart.',
|
||||||
|
side: isMobile ? 'top' : 'bottom',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-save-btn',
|
||||||
|
popover: {
|
||||||
|
title: 'Save Your Entry',
|
||||||
|
description: 'Hit save and your entry is securely encrypted and stored. Only you can read it.',
|
||||||
|
side: 'top',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-nav-history',
|
||||||
|
popover: {
|
||||||
|
title: 'View Your History',
|
||||||
|
description: 'This takes you to the History page. Let\'s go there next!',
|
||||||
|
side: isMobile ? 'top' : 'right',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistorySteps(isMobile: boolean): DriveStep[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
element: '#tour-calendar',
|
||||||
|
popover: {
|
||||||
|
title: 'Your Calendar',
|
||||||
|
description: 'Green dots mark days you wrote entries. Navigate between months using the arrows.',
|
||||||
|
side: isMobile ? 'bottom' : 'right',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-entries-list',
|
||||||
|
popover: {
|
||||||
|
title: 'Your Past Entries',
|
||||||
|
description: 'Tap any date on the calendar to see entries from that day. Tap an entry card to read the full content.',
|
||||||
|
side: isMobile ? 'top' : 'left',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-nav-settings',
|
||||||
|
popover: {
|
||||||
|
title: 'Your Settings',
|
||||||
|
description: 'Let\'s check out your settings next!',
|
||||||
|
side: isMobile ? 'top' : 'right',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettingsSteps(isMobile: boolean): DriveStep[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
element: '#tour-theme-switcher',
|
||||||
|
popover: {
|
||||||
|
title: 'Pick Your Theme',
|
||||||
|
description: 'Switch between Light and Dark mode. Your choice is saved automatically.',
|
||||||
|
side: isMobile ? 'top' : 'bottom',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnboardingTour() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const driverRef = useRef<ReturnType<typeof driver> | null>(null)
|
||||||
|
|
||||||
|
const startTour = useCallback(() => {
|
||||||
|
const isMobile = window.innerWidth < 860
|
||||||
|
|
||||||
|
const driverObj = driver({
|
||||||
|
...driverDefaults(),
|
||||||
|
onDestroyStarted: () => {
|
||||||
|
markOnboardingDone()
|
||||||
|
clearPendingTourStep()
|
||||||
|
driverObj.destroy()
|
||||||
|
},
|
||||||
|
onNextClick: () => {
|
||||||
|
const activeIndex = driverObj.getActiveIndex()
|
||||||
|
const steps = driverObj.getConfig().steps || []
|
||||||
|
|
||||||
|
// Last home step → navigate to /history
|
||||||
|
if (activeIndex === steps.length - 1) {
|
||||||
|
localStorage.setItem(TOUR_PENDING_KEY, 'history')
|
||||||
|
driverObj.destroy()
|
||||||
|
navigate('/history')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
driverObj.moveNext()
|
||||||
|
},
|
||||||
|
steps: getHomeSteps(isMobile),
|
||||||
|
})
|
||||||
|
|
||||||
|
driverRef.current = driverObj
|
||||||
|
setTimeout(() => driverObj.drive(), 150)
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const continueTourOnHistory = useCallback(() => {
|
||||||
|
const isMobile = window.innerWidth < 860
|
||||||
|
|
||||||
|
const driverObj = driver({
|
||||||
|
...driverDefaults(),
|
||||||
|
onDestroyStarted: () => {
|
||||||
|
markOnboardingDone()
|
||||||
|
clearPendingTourStep()
|
||||||
|
driverObj.destroy()
|
||||||
|
},
|
||||||
|
onNextClick: () => {
|
||||||
|
const activeIndex = driverObj.getActiveIndex()
|
||||||
|
const steps = driverObj.getConfig().steps || []
|
||||||
|
|
||||||
|
// Last history step → navigate to /settings
|
||||||
|
if (activeIndex === steps.length - 1) {
|
||||||
|
localStorage.setItem(TOUR_PENDING_KEY, 'settings')
|
||||||
|
driverObj.destroy()
|
||||||
|
navigate('/settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
driverObj.moveNext()
|
||||||
|
},
|
||||||
|
steps: getHistorySteps(isMobile),
|
||||||
|
})
|
||||||
|
|
||||||
|
driverRef.current = driverObj
|
||||||
|
setTimeout(() => driverObj.drive(), 300)
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const continueTourOnSettings = useCallback(() => {
|
||||||
|
const isMobile = window.innerWidth < 860
|
||||||
|
|
||||||
|
const driverObj = driver({
|
||||||
|
...driverDefaults(),
|
||||||
|
onDestroyStarted: () => {
|
||||||
|
markOnboardingDone()
|
||||||
|
clearPendingTourStep()
|
||||||
|
driverObj.destroy()
|
||||||
|
},
|
||||||
|
onDestroyed: () => {
|
||||||
|
markOnboardingDone()
|
||||||
|
clearPendingTourStep()
|
||||||
|
},
|
||||||
|
steps: getSettingsSteps(isMobile),
|
||||||
|
})
|
||||||
|
|
||||||
|
driverRef.current = driverObj
|
||||||
|
setTimeout(() => driverObj.drive(), 300)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { startTour, continueTourOnHistory, continueTourOnSettings }
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ export async function updateUserProfile(
|
|||||||
updates: { displayName?: string; photoURL?: string; theme?: string },
|
updates: { displayName?: string; photoURL?: string; theme?: string },
|
||||||
token: string
|
token: string
|
||||||
) {
|
) {
|
||||||
return apiCall(`/api/users/update/${userId}`, {
|
return apiCall(`/api/users/${userId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: updates,
|
body: updates,
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getUserEntries, type JournalEntry } from '../lib/api'
|
|||||||
import { decryptEntry } from '../lib/crypto'
|
import { decryptEntry } from '../lib/crypto'
|
||||||
import { formatIST, getISTDateComponents } from '../lib/timezone'
|
import { formatIST, getISTDateComponents } from '../lib/timezone'
|
||||||
import BottomNav from '../components/BottomNav'
|
import BottomNav from '../components/BottomNav'
|
||||||
|
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
|
||||||
|
|
||||||
interface DecryptedEntry extends JournalEntry {
|
interface DecryptedEntry extends JournalEntry {
|
||||||
decryptedTitle?: string
|
decryptedTitle?: string
|
||||||
@@ -19,6 +20,16 @@ export default function HistoryPage() {
|
|||||||
const [loadingEntries, setLoadingEntries] = useState(false)
|
const [loadingEntries, setLoadingEntries] = useState(false)
|
||||||
const [selectedEntry, setSelectedEntry] = useState<DecryptedEntry | null>(null)
|
const [selectedEntry, setSelectedEntry] = useState<DecryptedEntry | null>(null)
|
||||||
|
|
||||||
|
const { continueTourOnHistory } = useOnboardingTour()
|
||||||
|
|
||||||
|
// Continue onboarding tour if navigated here from the home page tour
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasPendingTourStep() === 'history') {
|
||||||
|
clearPendingTourStep()
|
||||||
|
continueTourOnHistory()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Fetch entries on mount and when userId changes
|
// Fetch entries on mount and when userId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !userId) return
|
if (!user || !userId) return
|
||||||
@@ -189,7 +200,7 @@ export default function HistoryPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="history-container">
|
<main className="history-container">
|
||||||
<div className="calendar-card">
|
<div id="tour-calendar" className="calendar-card">
|
||||||
<div className="calendar-header">
|
<div className="calendar-header">
|
||||||
<h2 className="calendar-month">{monthName}</h2>
|
<h2 className="calendar-month">{monthName}</h2>
|
||||||
<div className="calendar-nav">
|
<div className="calendar-nav">
|
||||||
@@ -239,7 +250,7 @@ export default function HistoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="recent-entries">
|
<section id="tour-entries-list" className="recent-entries">
|
||||||
<h3 className="recent-entries-title">
|
<h3 className="recent-entries-title">
|
||||||
{selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).toUpperCase()}
|
{selectedDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).toUpperCase()}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { createEntry } from '../lib/api'
|
import { createEntry } from '../lib/api'
|
||||||
import { encryptEntry } from '../lib/crypto'
|
import { encryptEntry } from '../lib/crypto'
|
||||||
import BottomNav from '../components/BottomNav'
|
import BottomNav from '../components/BottomNav'
|
||||||
|
import WelcomeModal from '../components/WelcomeModal'
|
||||||
|
import { useOnboardingTour, hasSeenOnboarding, markOnboardingDone } from '../hooks/useOnboardingTour'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { user, userId, secretKey, loading } = useAuth()
|
const { user, userId, secretKey, loading } = useAuth()
|
||||||
@@ -11,10 +13,30 @@ export default function HomePage() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
const [showWelcome, setShowWelcome] = useState(false)
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||||
const contentTextareaRef = useRef<HTMLTextAreaElement>(null)
|
const contentTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const { startTour } = useOnboardingTour()
|
||||||
|
|
||||||
|
// Check if onboarding should be shown after login
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && user && userId && !hasSeenOnboarding()) {
|
||||||
|
setShowWelcome(true)
|
||||||
|
}
|
||||||
|
}, [loading, user, userId])
|
||||||
|
|
||||||
|
const handleStartTour = () => {
|
||||||
|
setShowWelcome(false)
|
||||||
|
startTour()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSkipTour = () => {
|
||||||
|
setShowWelcome(false)
|
||||||
|
markOnboardingDone()
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="home-page" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
<div className="home-page" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -106,6 +128,9 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
|
{showWelcome && (
|
||||||
|
<WelcomeModal onStart={handleStartTour} onSkip={handleSkipTour} />
|
||||||
|
)}
|
||||||
<main className="journal-container">
|
<main className="journal-container">
|
||||||
<div className="journal-card">
|
<div className="journal-card">
|
||||||
<div className="journal-date">{dateString}</div>
|
<div className="journal-date">{dateString}</div>
|
||||||
@@ -115,6 +140,7 @@ export default function HomePage() {
|
|||||||
<div className="journal-writing-area">
|
<div className="journal-writing-area">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
id="tour-title-input"
|
||||||
className="journal-title-input"
|
className="journal-title-input"
|
||||||
placeholder="Title your thoughts..."
|
placeholder="Title your thoughts..."
|
||||||
value={title}
|
value={title}
|
||||||
@@ -125,6 +151,7 @@ export default function HomePage() {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="tour-content-textarea"
|
||||||
className="journal-entry-textarea"
|
className="journal-entry-textarea"
|
||||||
placeholder="Start writing your entry here..."
|
placeholder="Start writing your entry here..."
|
||||||
value={entry}
|
value={entry}
|
||||||
@@ -151,6 +178,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div style={{ marginTop: '1.5rem', display: 'flex', gap: '0.75rem' }}>
|
<div style={{ marginTop: '1.5rem', display: 'flex', gap: '0.75rem' }}>
|
||||||
<button
|
<button
|
||||||
|
id="tour-save-btn"
|
||||||
className="journal-write-btn"
|
className="journal-write-btn"
|
||||||
onClick={handleWrite}
|
onClick={handleWrite}
|
||||||
disabled={saving || !title.trim() || !entry.trim()}
|
disabled={saving || !title.trim() || !entry.trim()}
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { deleteUser as deleteUserApi } from '../lib/api'
|
import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api'
|
||||||
import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto'
|
import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import BottomNav from '../components/BottomNav'
|
import BottomNav from '../components/BottomNav'
|
||||||
|
import { useOnboardingTour, hasPendingTourStep, clearPendingTourStep } from '../hooks/useOnboardingTour'
|
||||||
|
|
||||||
|
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
|
||||||
|
|
||||||
|
function resizeImage(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = MAX_PHOTO_SIZE
|
||||||
|
canvas.height = MAX_PHOTO_SIZE
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
// Crop to square from center
|
||||||
|
const side = Math.min(img.width, img.height)
|
||||||
|
const sx = (img.width - side) / 2
|
||||||
|
const sy = (img.height - side) / 2
|
||||||
|
ctx.drawImage(img, sx, sy, side, side, 0, 0, MAX_PHOTO_SIZE, MAX_PHOTO_SIZE)
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', 0.8))
|
||||||
|
}
|
||||||
|
img.onerror = reject
|
||||||
|
img.src = reader.result as string
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user, userId, signOut, loading } = useAuth()
|
const { user, userId, mongoUser, signOut, loading, refreshMongoUser } = useAuth()
|
||||||
// const [passcodeEnabled, setPasscodeEnabled] = useState(false) // Passcode lock — disabled for now
|
// const [passcodeEnabled, setPasscodeEnabled] = useState(false) // Passcode lock — disabled for now
|
||||||
// const [faceIdEnabled, setFaceIdEnabled] = useState(false) // Face ID — disabled for now
|
// const [faceIdEnabled, setFaceIdEnabled] = useState(false) // Face ID — disabled for now
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
||||||
@@ -18,7 +47,77 @@ export default function SettingsPage() {
|
|||||||
const [confirmEmail, setConfirmEmail] = useState('')
|
const [confirmEmail, setConfirmEmail] = useState('')
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
const displayName = user?.displayName || 'User'
|
const { continueTourOnSettings } = useOnboardingTour()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Edit profile modal state
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [editName, setEditName] = useState('')
|
||||||
|
const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Continue onboarding tour if navigated here from the history page tour
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasPendingTourStep() === 'settings') {
|
||||||
|
clearPendingTourStep()
|
||||||
|
continueTourOnSettings()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSeeTutorial = () => {
|
||||||
|
localStorage.removeItem('gj-onboarding-done')
|
||||||
|
localStorage.removeItem('gj-tour-pending-step')
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = mongoUser?.displayName || user?.displayName || 'User'
|
||||||
|
// Prefer mongo photo; only fall back to Google photo if mongo has no photo set
|
||||||
|
const photoURL = (mongoUser && 'photoURL' in mongoUser) ? (mongoUser.photoURL || null) : (user?.photoURL || null)
|
||||||
|
|
||||||
|
const openEditModal = () => {
|
||||||
|
setEditName(displayName)
|
||||||
|
setEditPhotoPreview(photoURL)
|
||||||
|
setShowEditModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const resized = await resizeImage(file)
|
||||||
|
setEditPhotoPreview(resized)
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to process image' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!user || !userId) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const token = await user.getIdToken()
|
||||||
|
const updates: { displayName?: string; photoURL?: string } = {}
|
||||||
|
if (editName.trim() && editName.trim() !== displayName) {
|
||||||
|
updates.displayName = editName.trim()
|
||||||
|
}
|
||||||
|
if (editPhotoPreview !== photoURL) {
|
||||||
|
updates.photoURL = editPhotoPreview || ''
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await updateUserProfile(userId, updates, token)
|
||||||
|
await refreshMongoUser()
|
||||||
|
setMessage({ type: 'success', text: 'Profile updated!' })
|
||||||
|
setTimeout(() => setMessage(null), 2000)
|
||||||
|
}
|
||||||
|
setShowEditModal(false)
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Failed to update profile'
|
||||||
|
setMessage({ type: 'error', text: msg })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply theme to DOM
|
// Apply theme to DOM
|
||||||
const applyTheme = useCallback((t: 'light' | 'dark') => {
|
const applyTheme = useCallback((t: 'light' | 'dark') => {
|
||||||
@@ -108,14 +207,23 @@ export default function SettingsPage() {
|
|||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<div className="settings-profile">
|
<div className="settings-profile">
|
||||||
<div className="settings-avatar">
|
<div className="settings-avatar">
|
||||||
<div className="settings-avatar-placeholder" style={{ fontSize: '1.75rem', background: 'linear-gradient(135deg, #86efac 0%, #22c55e 100%)' }}>
|
{photoURL ? (
|
||||||
🍀
|
<img src={photoURL} alt={displayName} className="settings-avatar-img" />
|
||||||
</div>
|
) : (
|
||||||
|
<div className="settings-avatar-placeholder" style={{ fontSize: '1.75rem', background: 'linear-gradient(135deg, #86efac 0%, #22c55e 100%)' }}>
|
||||||
|
{displayName.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-profile-info">
|
<div className="settings-profile-info">
|
||||||
<h2 className="settings-profile-name">{displayName}</h2>
|
<h2 className="settings-profile-name">{displayName}</h2>
|
||||||
{/* <span className="settings-profile-badge">PRO MEMBER</span> */}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" className="settings-edit-btn" onClick={openEditModal} title="Edit profile">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Privacy & Security */}
|
{/* Privacy & Security */}
|
||||||
@@ -203,7 +311,7 @@ export default function SettingsPage() {
|
|||||||
<div className="settings-divider"></div>
|
<div className="settings-divider"></div>
|
||||||
*/}
|
*/}
|
||||||
|
|
||||||
<div className="settings-item">
|
<div id="tour-theme-switcher" className="settings-item">
|
||||||
<div className="settings-item-icon settings-item-icon-blue">
|
<div className="settings-item-icon settings-item-icon-blue">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="13.5" cy="6.5" r=".5"></circle>
|
<circle cx="13.5" cy="6.5" r=".5"></circle>
|
||||||
@@ -249,6 +357,16 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* See Tutorial */}
|
||||||
|
<button type="button" className="settings-tutorial-btn" onClick={handleSeeTutorial}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
<span>See Tutorial</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Clear Data */}
|
{/* Clear Data */}
|
||||||
<button type="button" className="settings-clear-btn" onClick={handleClearData}>
|
<button type="button" className="settings-clear-btn" onClick={handleClearData}>
|
||||||
<span>Clear All Data</span>
|
<span>Clear All Data</span>
|
||||||
@@ -310,6 +428,80 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Profile Modal */}
|
||||||
|
{showEditModal && (
|
||||||
|
<div className="confirm-modal-overlay" onClick={() => !saving && setShowEditModal(false)}>
|
||||||
|
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="edit-modal-title">Edit Profile</h3>
|
||||||
|
|
||||||
|
<div className="edit-modal-avatar" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
{editPhotoPreview ? (
|
||||||
|
<img src={editPhotoPreview} alt="Preview" className="edit-modal-avatar-img" />
|
||||||
|
) : (
|
||||||
|
<div className="edit-modal-avatar-placeholder">
|
||||||
|
{editName.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="edit-modal-avatar-overlay">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||||
|
<circle cx="12" cy="13" r="4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handlePhotoSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{editPhotoPreview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="edit-modal-remove-photo"
|
||||||
|
onClick={() => setEditPhotoPreview(null)}
|
||||||
|
>
|
||||||
|
Remove photo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="confirm-modal-label" style={{ marginTop: '1rem' }}>Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="confirm-modal-input"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
maxLength={50}
|
||||||
|
autoFocus
|
||||||
|
style={{ borderColor: '#d1d5db' }}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = '#22c55e')}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = '#d1d5db')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="confirm-modal-actions" style={{ marginTop: '1rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="confirm-modal-cancel"
|
||||||
|
onClick={() => setShowEditModal(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="edit-modal-save"
|
||||||
|
onClick={handleSaveProfile}
|
||||||
|
disabled={saving || !editName.trim()}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user