Compare commits
5 Commits
711ad6fb70
...
fa10677e41
| Author | SHA1 | Date | |
|---|---|---|---|
| fa10677e41 | |||
| 625e4709d3 | |||
| 0ea8038f15 | |||
| 57582fbb59 | |||
| bb3bf6b238 |
@@ -11,7 +11,10 @@
|
||||
"Bash(/Users/jeet/Library/Python/3.9/bin/pytest -v 2>&1)",
|
||||
"Bash(conda run:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git remote:*)"
|
||||
"Bash(git remote:*)",
|
||||
"Bash(find /Users/jeet/Desktop/Jio/grateful-journal/src -type f -name *.ts -o -name *.tsx)",
|
||||
"Bash(ls -la /Users/jeet/Desktop/Jio/grateful-journal/*.config.*)",
|
||||
"mcp__ide__getDiagnostics"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
745
src/App.css
745
src/App.css
@@ -43,24 +43,7 @@
|
||||
/* ============================
|
||||
LOGIN PAGE
|
||||
============================ */
|
||||
.login-page {
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(160deg, #eef6ee 0%, #dcfce7 100%);
|
||||
padding: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-page__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── Loading state ──────────────────────────────────────── */
|
||||
.login-page__spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -70,42 +53,141 @@
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #fff;
|
||||
/* ── Login page — two-panel layout ─────────────────────── */
|
||||
.lp {
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 55% 45%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #eef6ee 0%, #dcfce7 100%);
|
||||
}
|
||||
|
||||
/* Loading state wrapper */
|
||||
.lp--loading {
|
||||
grid-template-columns: 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: var(--color-bg-soft);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* ── Left: animated tree hero ───────────────────────────── */
|
||||
.lp__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1.5rem;
|
||||
gap: 1.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lp__hero-words {
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lp__quote {
|
||||
font-family: "Sniglet", system-ui;
|
||||
font-size: 1.625rem;
|
||||
font-weight: 700;
|
||||
color: #16a34a;
|
||||
margin: 0 0 0.4rem;
|
||||
opacity: 0;
|
||||
animation: lp-rise 0.9s ease-out 3.0s forwards;
|
||||
}
|
||||
|
||||
.lp__subquote {
|
||||
font-size: 0.9375rem;
|
||||
color: #22c55e;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
animation: lp-rise 0.9s ease-out 3.35s forwards;
|
||||
}
|
||||
|
||||
/* ── Right: login panel ─────────────────────────────────── */
|
||||
.lp__panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
border-top: 4px solid #22c55e;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.lp__form {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
border-top: 4px solid #22c55e;
|
||||
padding: 2rem 1.75rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.lp__brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
animation: lp-rise 0.7s ease-out 0.1s both;
|
||||
}
|
||||
|
||||
.lp__icon {
|
||||
font-size: 3.25rem;
|
||||
line-height: 1;
|
||||
animation: lp-pop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s both;
|
||||
}
|
||||
|
||||
.lp__title {
|
||||
margin: 0;
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
color: #22c55e;
|
||||
font-family: "Sniglet", system-ui;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-card__brand {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.login-card__title {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 1.625rem;
|
||||
font-weight: 700;
|
||||
color: #22c55e;
|
||||
letter-spacing: -0.02em;
|
||||
font-family: "Sniglet", system-ui;
|
||||
}
|
||||
|
||||
.login-card__tagline {
|
||||
.lp__tagline {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.9375rem;
|
||||
text-align: center;
|
||||
line-height: 1.65;
|
||||
animation: lp-rise 0.7s ease-out 0.25s both;
|
||||
}
|
||||
|
||||
.login-card__actions {
|
||||
.lp__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
animation: lp-rise 0.7s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
.lp__privacy {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lp__error {
|
||||
margin: 0;
|
||||
padding: 0.625rem 0.75rem;
|
||||
color: #b91c1c;
|
||||
font-size: 0.875rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* kept for other callers */
|
||||
.login-card__error {
|
||||
margin: 0;
|
||||
padding: 0.625rem 0.75rem;
|
||||
@@ -116,6 +198,189 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* ── Tree animation ─────────────────────────────────────── */
|
||||
.tree-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tree-svg {
|
||||
width: 100%;
|
||||
max-width: 290px;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.t-trunk {
|
||||
stroke-dasharray: 200;
|
||||
stroke-dashoffset: 200;
|
||||
animation: t-draw 0.95s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
.t-root {
|
||||
stroke-dasharray: 120;
|
||||
stroke-dashoffset: 120;
|
||||
animation: t-draw 0.55s ease-out forwards;
|
||||
}
|
||||
|
||||
.t-branch {
|
||||
stroke-dasharray: 300;
|
||||
stroke-dashoffset: 300;
|
||||
animation: t-draw 0.65s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes t-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
.t-leaf {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
animation: t-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
|
||||
@keyframes t-pop {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
60% { transform: scale(1.18); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.t-canopy {
|
||||
transform-origin: 140px 412px;
|
||||
animation: t-sway 5.5s ease-in-out 3.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes t-sway {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
28% { transform: rotate(1.8deg); }
|
||||
72% { transform: rotate(-1.5deg); }
|
||||
}
|
||||
|
||||
.t-particle {
|
||||
opacity: 0;
|
||||
animation: t-float linear infinite;
|
||||
}
|
||||
|
||||
@keyframes t-float {
|
||||
0% { transform: translateY(0) translateX(0); opacity: 0; }
|
||||
8% { opacity: 0.7; }
|
||||
88% { opacity: 0.35; }
|
||||
100% { transform: translateY(-380px) translateX(28px); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Login animations ───────────────────────────────────── */
|
||||
@keyframes lp-rise {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes lp-pop {
|
||||
from { opacity: 0; transform: scale(0.55); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Login responsive — phones (≤ 640px) ───────────────── */
|
||||
@media (max-width: 640px) {
|
||||
/* Stack vertically: tree hero on top, login below */
|
||||
.lp {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 56vh 1fr;
|
||||
}
|
||||
|
||||
.lp__hero {
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Slightly smaller tree so it fits within the hero panel */
|
||||
.tree-svg {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.lp__quote {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.lp__subquote {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.lp__panel {
|
||||
padding: 1.25rem 1.25rem calc(1.5rem + env(safe-area-inset-bottom));
|
||||
border-left: none;
|
||||
overflow-y: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lp__form {
|
||||
max-width: 420px;
|
||||
gap: 1.25rem;
|
||||
padding: 1.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.lp__title {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.lp__icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.lp__tagline {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Login responsive — small phones (≤ 390px, e.g. iPhone SE) */
|
||||
@media (max-width: 390px) {
|
||||
.lp {
|
||||
grid-template-rows: 50vh 1fr;
|
||||
}
|
||||
|
||||
.tree-svg {
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.lp__quote {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.lp__subquote {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.lp__panel {
|
||||
padding: 1rem 1rem calc(1.25rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.lp__form {
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.lp__title {
|
||||
font-size: 1.625rem;
|
||||
}
|
||||
|
||||
.lp__icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.lp__form {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.lp__tagline {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.google-sign-in-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -310,6 +575,112 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Save success animations ──────────────────────────────── */
|
||||
|
||||
/* Card glows green on save */
|
||||
.journal-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.journal-card--saved {
|
||||
animation: save-card-glow 0.7s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes save-card-glow {
|
||||
0% { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.07); }
|
||||
35% { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.35), 0 6px 32px rgba(34, 197, 94, 0.22); }
|
||||
100% { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.07); }
|
||||
}
|
||||
|
||||
/* Button pops into a checkmark */
|
||||
.journal-write-btn--saved {
|
||||
pointer-events: none;
|
||||
animation: save-btn-pop 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
|
||||
@keyframes save-btn-pop {
|
||||
0% { transform: scale(0.88); }
|
||||
60% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Leaf burst particles */
|
||||
.save-leaves {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.save-leaf {
|
||||
position: absolute;
|
||||
font-size: 1.65rem;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
animation: save-leaf-float 1.9s ease-out forwards;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes save-leaf-float {
|
||||
0% {
|
||||
transform: translateY(0) translateX(0) rotate(0deg) scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
12% { opacity: 1; transform: translateY(-27px) translateX(calc(var(--leaf-dx) * 0.1)) rotate(calc(var(--leaf-rot) * 0.1)) scale(1); }
|
||||
85% { opacity: 0.5; }
|
||||
100% {
|
||||
transform: translateY(-285px) translateX(var(--leaf-dx)) rotate(var(--leaf-rot)) scale(0.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Affirmation quote — centered, appears after book animation */
|
||||
.save-inline-quote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0.875rem 1.5rem;
|
||||
min-height: 66px;
|
||||
border-radius: 100px;
|
||||
background: #f0fdf4;
|
||||
border: 1.5px solid #bbf7d0;
|
||||
font-family: "Sniglet", system-ui;
|
||||
font-size: 1.3125rem;
|
||||
font-weight: 600;
|
||||
color: #15803d;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
opacity: 0;
|
||||
animation: save-inline-quote-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.save-inline-quote {
|
||||
font-size: 1rem;
|
||||
min-height: 52px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.save-inline-quote {
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
padding: 0 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes save-inline-quote-in {
|
||||
0% { opacity: 0; transform: scale(0.88) translateY(4px); }
|
||||
100% { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.journal-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -329,6 +700,142 @@
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* ============================
|
||||
SAVE BOOK ANIMATION OVERLAY
|
||||
============================ */
|
||||
|
||||
.sba-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(238, 246, 238, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation:
|
||||
sba-overlay-in 0.3s ease forwards,
|
||||
sba-overlay-out 0.4s ease 2.5s forwards;
|
||||
}
|
||||
|
||||
@keyframes sba-overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes sba-overlay-out {
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.sba-wrap {
|
||||
animation: sba-wrap-in 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
|
||||
@keyframes sba-wrap-in {
|
||||
from { transform: scale(0.6); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.sba-svg {
|
||||
width: min(280px, 74vw);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Shadow gently expands when book closes */
|
||||
.sba-shadow {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 50% 50%;
|
||||
animation: sba-shadow-grow 0.4s ease 1.95s forwards;
|
||||
}
|
||||
@keyframes sba-shadow-grow {
|
||||
0% { transform: scaleX(1); opacity: 1; }
|
||||
50% { transform: scaleX(1.18); opacity: 1; }
|
||||
100% { transform: scaleX(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Writing lines draw via stroke-dashoffset */
|
||||
.sba-line {
|
||||
stroke-dasharray: 76;
|
||||
stroke-dashoffset: 76;
|
||||
}
|
||||
.sba-line-1 { animation: sba-line-draw 0.28s ease forwards 0.35s; }
|
||||
.sba-line-2 { animation: sba-line-draw 0.28s ease forwards 0.65s; }
|
||||
.sba-line-3 { animation: sba-line-draw 0.25s ease forwards 0.95s; }
|
||||
.sba-line-4 { animation: sba-line-draw 0.22s ease forwards 1.2s; }
|
||||
|
||||
@keyframes sba-line-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
/* Pen moves down through each line then flies off */
|
||||
.sba-pen {
|
||||
animation: sba-pen-write 1.12s ease forwards 0.3s;
|
||||
}
|
||||
@keyframes sba-pen-write {
|
||||
0% { transform: translate(208px, 42px) rotate(20deg); opacity: 1; }
|
||||
17% { transform: translate(208px, 42px) rotate(20deg); opacity: 1; }
|
||||
28% { transform: translate(208px, 64px) rotate(20deg); opacity: 1; }
|
||||
45% { transform: translate(208px, 64px) rotate(20deg); opacity: 1; }
|
||||
57% { transform: translate(198px, 86px) rotate(20deg); opacity: 1; }
|
||||
72% { transform: translate(198px, 86px) rotate(20deg); opacity: 1; }
|
||||
82% { transform: translate(191px, 108px) rotate(20deg); opacity: 1; }
|
||||
94% { transform: translate(191px, 108px) rotate(20deg); opacity: 1; }
|
||||
100% { transform: translate(244px, 148px) rotate(20deg); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Left page folds toward the spine (right edge) */
|
||||
.sba-left-group {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 100% 50%;
|
||||
animation: sba-fold 0.42s cubic-bezier(0.4, 0, 0.9, 0.6) 1.45s forwards;
|
||||
}
|
||||
|
||||
/* Right page folds toward the spine (left edge) */
|
||||
.sba-right-group {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 0% 50%;
|
||||
animation: sba-fold 0.42s cubic-bezier(0.4, 0, 0.9, 0.6) 1.57s forwards;
|
||||
}
|
||||
|
||||
@keyframes sba-fold {
|
||||
to { transform: scaleX(0); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Spine fades as pages close */
|
||||
.sba-spine {
|
||||
animation: sba-fade 0.3s ease 1.65s forwards;
|
||||
}
|
||||
|
||||
@keyframes sba-fade {
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Closed book springs in */
|
||||
.sba-closed-book {
|
||||
transform-box: fill-box;
|
||||
transform-origin: 50% 50%;
|
||||
opacity: 0;
|
||||
transform: scale(0.78);
|
||||
animation: sba-closed-in 0.48s cubic-bezier(0.175, 0.885, 0.32, 1.275) 1.95s forwards;
|
||||
}
|
||||
@keyframes sba-closed-in {
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Checkmark draws itself on the cover */
|
||||
.sba-check {
|
||||
stroke-dasharray: 135;
|
||||
stroke-dashoffset: 135;
|
||||
animation: sba-line-draw 0.55s ease forwards 2.3s;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .sba-overlay {
|
||||
background: rgba(10, 18, 10, 0.76);
|
||||
}
|
||||
|
||||
/* ============================
|
||||
BOTTOM NAVIGATION — Static flex item, always at bottom
|
||||
============================ */
|
||||
@@ -609,6 +1116,32 @@
|
||||
box-shadow: 0 5px 16px rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
|
||||
.entry-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.entry-delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #fee2e2;
|
||||
background: #fff5f5;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.entry-delete-btn:hover {
|
||||
background: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1286,6 +1819,80 @@
|
||||
font-family: "Sniglet", system-ui;
|
||||
}
|
||||
|
||||
/* Delete confirmation modal */
|
||||
.delete-confirm-modal {
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.delete-confirm-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.delete-confirm-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: #111827;
|
||||
font-family: "Sniglet", system-ui;
|
||||
}
|
||||
|
||||
.delete-confirm-body {
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
font-family: "Sniglet", system-ui;
|
||||
margin: 0 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.delete-confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.delete-confirm-cancel,
|
||||
.delete-confirm-delete {
|
||||
flex: 1;
|
||||
max-width: 10rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9375rem;
|
||||
font-family: "Sniglet", system-ui;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.delete-confirm-cancel {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
.delete-confirm-cancel:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.delete-confirm-delete {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
.delete-confirm-delete:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.delete-confirm-cancel:disabled,
|
||||
.delete-confirm-delete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- Responsive: tablet+ (≥ 768px) ---- */
|
||||
@media (min-width: 768px) {
|
||||
.entry-modal-overlay {
|
||||
@@ -1667,9 +2274,7 @@
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-card__title {
|
||||
color: #4ade80;
|
||||
}
|
||||
/* login page is always light — no dark overrides */
|
||||
|
||||
/* -- Calendar -- */
|
||||
[data-theme="dark"] .calendar-day {
|
||||
@@ -1756,6 +2361,13 @@
|
||||
0 0 32px rgba(74, 222, 128, 0.15);
|
||||
}
|
||||
|
||||
/* -- Save inline quote dark mode -- */
|
||||
[data-theme="dark"] .save-inline-quote {
|
||||
background: #1e2a1e;
|
||||
border-color: rgba(74, 222, 128, 0.25);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* -- Home login link -- */
|
||||
[data-theme="dark"] .home-login-link {
|
||||
background: #22c55e;
|
||||
@@ -1955,6 +2567,33 @@
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .entry-delete-btn {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
[data-theme="dark"] .entry-delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .delete-confirm-modal {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
[data-theme="dark"] .delete-confirm-title {
|
||||
color: #e8f5e8;
|
||||
}
|
||||
[data-theme="dark"] .delete-confirm-body {
|
||||
color: #7a8a7a;
|
||||
}
|
||||
[data-theme="dark"] .delete-confirm-cancel {
|
||||
background: #252525;
|
||||
color: #b0b8b0;
|
||||
}
|
||||
[data-theme="dark"] .delete-confirm-cancel:hover:not(:disabled) {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .entry-modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
@@ -2001,27 +2640,7 @@
|
||||
background: #333;
|
||||
}
|
||||
|
||||
/* -- Login page -- */
|
||||
[data-theme="dark"] .login-page {
|
||||
background: linear-gradient(160deg, #0f0f0f 0%, #0a2e14 50%, #0f0f0f 100%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-card {
|
||||
background: #1a1a1a;
|
||||
border-top-color: #4ade80;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(74, 222, 128, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-card__tagline {
|
||||
color: #7a8a7a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-card__error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
}
|
||||
/* -- Login page — light mode only, no dark theme overrides -- */
|
||||
|
||||
/* -- Google sign-in btn -- */
|
||||
[data-theme="dark"] .google-sign-in-btn {
|
||||
|
||||
72
src/components/SaveBookAnimation.tsx
Normal file
72
src/components/SaveBookAnimation.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function SaveBookAnimation({ onDone }: { onDone: () => void }) {
|
||||
useEffect(() => {
|
||||
const t = setTimeout(onDone, 2900)
|
||||
return () => clearTimeout(t)
|
||||
}, [onDone])
|
||||
|
||||
return (
|
||||
<div className="sba-overlay" aria-hidden="true">
|
||||
<div className="sba-wrap">
|
||||
<svg viewBox="0 0 260 185" fill="none" xmlns="http://www.w3.org/2000/svg" className="sba-svg">
|
||||
{/* Drop shadow */}
|
||||
<ellipse className="sba-shadow" cx="130" cy="172" rx="74" ry="9" fill="rgba(34,197,94,0.14)" />
|
||||
|
||||
{/* LEFT PAGE */}
|
||||
<g className="sba-left-group">
|
||||
<rect x="22" y="18" width="98" height="140" rx="4" fill="#ffffff" stroke="#d4e8d4" strokeWidth="1.5" />
|
||||
<line x1="34" y1="50" x2="108" y2="50" stroke="#edf7ed" strokeWidth="1" />
|
||||
<line x1="34" y1="66" x2="108" y2="66" stroke="#edf7ed" strokeWidth="1" />
|
||||
<line x1="34" y1="82" x2="108" y2="82" stroke="#edf7ed" strokeWidth="1" />
|
||||
<line x1="34" y1="98" x2="108" y2="98" stroke="#edf7ed" strokeWidth="1" />
|
||||
<line x1="34" y1="114" x2="108" y2="114" stroke="#edf7ed" strokeWidth="1" />
|
||||
<line x1="34" y1="130" x2="108" y2="130" stroke="#edf7ed" strokeWidth="1" />
|
||||
</g>
|
||||
|
||||
{/* SPINE */}
|
||||
<g className="sba-spine">
|
||||
<rect x="119" y="16" width="7" height="144" rx="2.5" fill="#22c55e" opacity="0.45" />
|
||||
</g>
|
||||
|
||||
{/* RIGHT PAGE (writing lines live here — folds independently) */}
|
||||
<g className="sba-right-group">
|
||||
<rect x="126" y="18" width="98" height="140" rx="4" fill="#f7fdf5" stroke="#d4e8d4" strokeWidth="1.5" />
|
||||
<line className="sba-line sba-line-1" x1="138" y1="50" x2="212" y2="50" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line className="sba-line sba-line-2" x1="138" y1="72" x2="212" y2="72" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line className="sba-line sba-line-3" x1="138" y1="94" x2="202" y2="94" stroke="#16a34a" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line className="sba-line sba-line-4" x1="138" y1="116" x2="195" y2="116" stroke="#16a34a" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</g>
|
||||
|
||||
{/* PEN — independent so it doesn't fold with the page */}
|
||||
<g className="sba-pen">
|
||||
{/* body */}
|
||||
<rect x="-3.5" y="-24" width="7" height="22" rx="2.5" fill="#374151" />
|
||||
{/* metal band */}
|
||||
<rect x="-3.5" y="-5" width="7" height="3" fill="#9ca3af" />
|
||||
{/* nib */}
|
||||
<polygon points="-3.5,-2 3.5,-2 0,7" fill="#f59e0b" />
|
||||
{/* ink dot */}
|
||||
<circle cx="0" cy="7" r="1.8" fill="#15803d" />
|
||||
</g>
|
||||
|
||||
{/* CLOSED BOOK — hidden until pages fold away */}
|
||||
<g className="sba-closed-book">
|
||||
{/* spine side */}
|
||||
<rect x="55" y="18" width="150" height="140" rx="7" fill="#15803d" />
|
||||
{/* cover face */}
|
||||
<rect x="63" y="18" width="135" height="140" rx="5" fill="#22c55e" />
|
||||
{/* spine shadow */}
|
||||
<rect x="55" y="18" width="10" height="140" rx="4" fill="rgba(0,0,0,0.18)" />
|
||||
{/* decorative ruled lines */}
|
||||
<line x1="83" y1="76" x2="183" y2="76" stroke="rgba(255,255,255,0.22)" strokeWidth="1.5" />
|
||||
<line x1="83" y1="93" x2="183" y2="93" stroke="rgba(255,255,255,0.22)" strokeWidth="1.5" />
|
||||
<line x1="83" y1="110" x2="170" y2="110" stroke="rgba(255,255,255,0.22)" strokeWidth="1.5" />
|
||||
{/* checkmark */}
|
||||
<path className="sba-check" d="M96 90 L115 109 L162 62" stroke="white" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
src/components/TreeAnimation.tsx
Normal file
153
src/components/TreeAnimation.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
const LEAVES = [
|
||||
// Left low cluster (b1 tip ~40,308)
|
||||
{ cx: 34, cy: 302, r: 18, fill: '#22c55e', delay: '1.65s' },
|
||||
{ cx: 14, cy: 295, r: 15, fill: '#16a34a', delay: '1.70s' },
|
||||
{ cx: 26, cy: 280, r: 16, fill: '#4ade80', delay: '1.68s' },
|
||||
{ cx: 48, cy: 290, r: 13, fill: '#15803d', delay: '1.72s' },
|
||||
{ cx: 8, cy: 312, r: 12, fill: '#22c55e', delay: '1.75s' },
|
||||
// Right low cluster (b2 tip ~240,302)
|
||||
{ cx: 246, cy: 296, r: 18, fill: '#22c55e', delay: '1.75s' },
|
||||
{ cx: 266, cy: 290, r: 15, fill: '#16a34a', delay: '1.80s' },
|
||||
{ cx: 254, cy: 275, r: 16, fill: '#4ade80', delay: '1.78s' },
|
||||
{ cx: 234, cy: 286, r: 13, fill: '#15803d', delay: '1.82s' },
|
||||
{ cx: 270, cy: 308, r: 12, fill: '#22c55e', delay: '1.85s' },
|
||||
// sb3/sb4 mid-tips
|
||||
{ cx: 50, cy: 270, r: 13, fill: '#4ade80', delay: '1.80s' },
|
||||
{ cx: 228, cy: 267, r: 13, fill: '#4ade80', delay: '1.85s' },
|
||||
// sb1/sb2 outer tips
|
||||
{ cx: 8, cy: 255, r: 14, fill: '#4ade80', delay: '1.90s' },
|
||||
{ cx: 270, cy: 251, r: 14, fill: '#4ade80', delay: '1.90s' },
|
||||
// Left mid cluster (b3 tip ~44,258)
|
||||
{ cx: 38, cy: 252, r: 16, fill: '#22c55e', delay: '2.05s' },
|
||||
{ cx: 18, cy: 246, r: 13, fill: '#4ade80', delay: '2.10s' },
|
||||
{ cx: 30, cy: 232, r: 14, fill: '#16a34a', delay: '2.08s' },
|
||||
{ cx: 52, cy: 240, r: 11, fill: '#86efac', delay: '2.12s' },
|
||||
{ cx: 12, cy: 264, r: 10, fill: '#22c55e', delay: '2.15s' },
|
||||
// Right mid cluster (b4 tip ~236,255)
|
||||
{ cx: 242, cy: 248, r: 16, fill: '#22c55e', delay: '2.10s' },
|
||||
{ cx: 262, cy: 242, r: 13, fill: '#4ade80', delay: '2.15s' },
|
||||
{ cx: 250, cy: 228, r: 14, fill: '#16a34a', delay: '2.12s' },
|
||||
{ cx: 230, cy: 238, r: 11, fill: '#86efac', delay: '2.18s' },
|
||||
{ cx: 266, cy: 260, r: 10, fill: '#22c55e', delay: '2.20s' },
|
||||
// sb5/sb6 outer tips (~16,214 and ~262,210)
|
||||
{ cx: 12, cy: 208, r: 13, fill: '#86efac', delay: '2.30s' },
|
||||
{ cx: 266, cy: 206, r: 13, fill: '#86efac', delay: '2.30s' },
|
||||
// Left upper cluster (b5 tip ~86,218)
|
||||
{ cx: 80, cy: 212, r: 17, fill: '#4ade80', delay: '2.45s' },
|
||||
{ cx: 62, cy: 202, r: 14, fill: '#22c55e', delay: '2.50s' },
|
||||
{ cx: 90, cy: 196, r: 12, fill: '#86efac', delay: '2.48s' },
|
||||
{ cx: 68, cy: 188, r: 13, fill: '#4ade80', delay: '2.52s' },
|
||||
// Right upper cluster (b6 tip ~194,214)
|
||||
{ cx: 200, cy: 208, r: 17, fill: '#4ade80', delay: '2.48s' },
|
||||
{ cx: 218, cy: 198, r: 14, fill: '#22c55e', delay: '2.52s' },
|
||||
{ cx: 192, cy: 193, r: 12, fill: '#86efac', delay: '2.50s' },
|
||||
{ cx: 210, cy: 185, r: 13, fill: '#4ade80', delay: '2.55s' },
|
||||
// Top center canopy (b7 tip ~128,196)
|
||||
{ cx: 120, cy: 188, r: 16, fill: '#4ade80', delay: '2.60s' },
|
||||
{ cx: 140, cy: 176, r: 21, fill: '#22c55e', delay: '2.65s' },
|
||||
{ cx: 160, cy: 188, r: 16, fill: '#4ade80', delay: '2.62s' },
|
||||
{ cx: 126, cy: 166, r: 13, fill: '#16a34a', delay: '2.68s' },
|
||||
{ cx: 154, cy: 164, r: 14, fill: '#86efac', delay: '2.72s' },
|
||||
{ cx: 140, cy: 154, r: 18, fill: '#22c55e', delay: '2.75s' },
|
||||
{ cx: 134, cy: 142, r: 12, fill: '#4ade80', delay: '2.78s' },
|
||||
{ cx: 148, cy: 140, r: 11, fill: '#86efac', delay: '2.80s' },
|
||||
]
|
||||
|
||||
const PARTICLES = [
|
||||
{ cx: 45, cy: 420, r: 5, fill: '#4ade80', delay: '3.5s', dur: '7s' },
|
||||
{ cx: 235, cy: 415, r: 3, fill: '#86efac', delay: '5.0s', dur: '9s' },
|
||||
{ cx: 88, cy: 425, r: 4, fill: '#22c55e', delay: '4.0s', dur: '8s' },
|
||||
{ cx: 192, cy: 418, r: 5, fill: '#4ade80', delay: '6.0s', dur: '10s' },
|
||||
{ cx: 140, cy: 422, r: 3, fill: '#86efac', delay: '3.8s', dur: '6s' },
|
||||
{ cx: 115, cy: 416, r: 4, fill: '#22c55e', delay: '7.0s', dur: '8s' },
|
||||
{ cx: 165, cy: 424, r: 3, fill: '#4ade80', delay: '4.5s', dur: '7s' },
|
||||
]
|
||||
|
||||
export function TreeAnimation() {
|
||||
return (
|
||||
<div className="tree-wrap">
|
||||
<svg
|
||||
className="tree-svg"
|
||||
viewBox="0 115 280 325"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Floating leaf particles */}
|
||||
{PARTICLES.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
className="t-particle"
|
||||
cx={p.cx} cy={p.cy} r={p.r} fill={p.fill}
|
||||
style={{ animationDelay: p.delay, animationDuration: p.dur }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Roots */}
|
||||
<path className="t-root" style={{ animationDelay: '1.00s' }}
|
||||
d="M 134 408 C 108 414 80 412 56 418" stroke="#C4954A" strokeWidth="5" strokeLinecap="round" />
|
||||
<path className="t-root" style={{ animationDelay: '1.05s' }}
|
||||
d="M 146 408 C 172 414 200 412 224 418" stroke="#C4954A" strokeWidth="5" strokeLinecap="round" />
|
||||
<path className="t-root" style={{ animationDelay: '1.02s' }}
|
||||
d="M 140 410 C 138 422 134 430 128 436" stroke="#C4954A" strokeWidth="4" strokeLinecap="round" />
|
||||
<path className="t-root" style={{ animationDelay: '1.08s' }}
|
||||
d="M 140 410 C 142 422 146 430 152 436" stroke="#C4954A" strokeWidth="4" strokeLinecap="round" />
|
||||
|
||||
{/* Trunk — two overlapping strokes for depth */}
|
||||
<path className="t-trunk" style={{ animationDelay: '0.20s' }}
|
||||
d="M 133 410 L 133 265" stroke="#8B6120" strokeWidth="17" strokeLinecap="round" />
|
||||
<path className="t-trunk" style={{ animationDelay: '0.28s' }}
|
||||
d="M 147 410 L 147 265" stroke="#C4954A" strokeWidth="7" strokeLinecap="round" />
|
||||
|
||||
{/* Level-1 branches */}
|
||||
<path className="t-branch" style={{ animationDelay: '1.00s' }}
|
||||
d="M 136 356 C 104 336 70 322 40 308" stroke="#A0732A" strokeWidth="8" strokeLinecap="round" />
|
||||
<path className="t-branch" style={{ animationDelay: '1.10s' }}
|
||||
d="M 144 348 C 176 328 210 314 240 302" stroke="#A0732A" strokeWidth="8" strokeLinecap="round" />
|
||||
|
||||
{/* Level-2 branches */}
|
||||
<path className="t-branch" style={{ animationDelay: '1.50s' }}
|
||||
d="M 136 310 C 104 292 70 276 44 258" stroke="#9B6D28" strokeWidth="6" strokeLinecap="round" />
|
||||
<path className="t-branch" style={{ animationDelay: '1.60s' }}
|
||||
d="M 144 304 C 176 286 210 270 236 255" stroke="#9B6D28" strokeWidth="6" strokeLinecap="round" />
|
||||
|
||||
{/* Level-3 branches */}
|
||||
<path className="t-branch" style={{ animationDelay: '1.90s' }}
|
||||
d="M 136 272 C 115 253 100 237 86 218" stroke="#9B6D28" strokeWidth="5" strokeLinecap="round" />
|
||||
<path className="t-branch" style={{ animationDelay: '2.00s' }}
|
||||
d="M 144 268 C 165 249 180 233 194 214" stroke="#9B6D28" strokeWidth="5" strokeLinecap="round" />
|
||||
<path className="t-branch" style={{ animationDelay: '2.10s' }}
|
||||
d="M 140 252 C 136 232 132 215 128 196" stroke="#9B6D28" strokeWidth="4" strokeLinecap="round" />
|
||||
|
||||
{/* Sub-branches off level-1 */}
|
||||
<path className="t-branch" style={{ animationDelay: '1.55s' }}
|
||||
d="M 40 308 C 24 292 16 276 12 260" stroke="#8B6520" strokeWidth="4" strokeLinecap="round" />
|
||||
<path className="t-branch" style={{ animationDelay: '1.65s' }}
|
||||
d="M 240 302 C 256 286 262 270 266 255" stroke="#8B6520" strokeWidth="4" strokeLinecap="round" />
|
||||
<path className="t-branch" style={{ animationDelay: '1.45s' }}
|
||||
d="M 74 326 C 60 308 54 292 52 276" stroke="#8B6520" strokeWidth="3" strokeLinecap="round" />
|
||||
<path className="t-branch" style={{ animationDelay: '1.55s' }}
|
||||
d="M 206 320 C 220 302 224 286 224 271" stroke="#8B6520" strokeWidth="3" strokeLinecap="round" />
|
||||
|
||||
{/* Sub-branches off level-2 */}
|
||||
<path className="t-branch" style={{ animationDelay: '2.05s' }}
|
||||
d="M 44 258 C 28 242 20 228 16 214" stroke="#8B6520" strokeWidth="3" strokeLinecap="round" />
|
||||
<path className="t-branch" style={{ animationDelay: '2.15s' }}
|
||||
d="M 236 255 C 252 239 258 225 262 210" stroke="#8B6520" strokeWidth="3" strokeLinecap="round" />
|
||||
|
||||
{/* Leaves — inside a group so the whole canopy can sway */}
|
||||
<g className="t-canopy">
|
||||
{LEAVES.map((l, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
className="t-leaf"
|
||||
cx={l.cx} cy={l.cy} r={l.r}
|
||||
fill={l.fill}
|
||||
style={{ animationDelay: l.delay }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
onAuthStateChanged,
|
||||
setPersistence,
|
||||
signInWithPopup,
|
||||
signInWithRedirect,
|
||||
getRedirectResult,
|
||||
signOut as firebaseSignOut,
|
||||
type User,
|
||||
} from 'firebase/auth'
|
||||
@@ -43,6 +45,7 @@ type AuthContextValue = {
|
||||
mongoUser: MongoUser | null
|
||||
loading: boolean
|
||||
secretKey: Uint8Array | null
|
||||
authError: string | null
|
||||
signInWithGoogle: () => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
refreshMongoUser: () => Promise<void>
|
||||
@@ -56,6 +59,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [mongoUser, setMongoUser] = useState<MongoUser | null>(null)
|
||||
const [secretKey, setSecretKey] = useState<Uint8Array | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
|
||||
// Initialize encryption keys on login
|
||||
async function initializeEncryption(authUser: User) {
|
||||
@@ -151,6 +155,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Handle returning from a redirect sign-in (mobile flow)
|
||||
getRedirectResult(auth).catch((error) => {
|
||||
console.error('[Auth] Redirect sign-in error:', error)
|
||||
setAuthError(error instanceof Error ? error.message : 'Sign-in failed')
|
||||
})
|
||||
|
||||
const unsubscribe = onAuthStateChanged(auth, async (u) => {
|
||||
setUser(u)
|
||||
if (u) {
|
||||
@@ -170,8 +180,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [])
|
||||
|
||||
async function signInWithGoogle() {
|
||||
setAuthError(null)
|
||||
await setPersistence(auth, browserLocalPersistence)
|
||||
await signInWithPopup(auth, googleProvider)
|
||||
try {
|
||||
await signInWithPopup(auth, googleProvider)
|
||||
} catch (err: unknown) {
|
||||
const code = (err as { code?: string })?.code
|
||||
if (code === 'auth/popup-blocked') {
|
||||
// Popup was blocked (common on iOS Safari / Android WebViews) — fall back to redirect
|
||||
await signInWithRedirect(auth, googleProvider)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMongoUser() {
|
||||
@@ -205,6 +226,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
mongoUser,
|
||||
secretKey,
|
||||
loading,
|
||||
authError,
|
||||
signInWithGoogle,
|
||||
signOut,
|
||||
refreshMongoUser,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { getUserEntries, type JournalEntry } from '../lib/api'
|
||||
import { getUserEntries, deleteEntry, type JournalEntry } from '../lib/api'
|
||||
import { decryptEntry } from '../lib/crypto'
|
||||
import { formatIST, getISTDateComponents } from '../lib/timezone'
|
||||
import BottomNav from '../components/BottomNav'
|
||||
@@ -19,6 +19,8 @@ export default function HistoryPage() {
|
||||
const [entries, setEntries] = useState<DecryptedEntry[]>([])
|
||||
const [loadingEntries, setLoadingEntries] = useState(false)
|
||||
const [selectedEntry, setSelectedEntry] = useState<DecryptedEntry | null>(null)
|
||||
const [entryToDelete, setEntryToDelete] = useState<DecryptedEntry | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const { continueTourOnHistory } = useOnboardingTour()
|
||||
|
||||
@@ -175,6 +177,22 @@ export default function HistoryPage() {
|
||||
setSelectedDate(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day))
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!entryToDelete || !user || !userId) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
const token = await user.getIdToken()
|
||||
await deleteEntry(userId, entryToDelete.id, token)
|
||||
setEntries((prev) => prev.filter((e) => e.id !== entryToDelete.id))
|
||||
if (selectedEntry?.id === entryToDelete.id) setSelectedEntry(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete entry:', error)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setEntryToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="history-page" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -267,21 +285,38 @@ export default function HistoryPage() {
|
||||
</p>
|
||||
) : (
|
||||
selectedDateEntries.map((entry) => (
|
||||
<button
|
||||
<div
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="entry-card"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedEntry(entry)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setSelectedEntry(entry)}
|
||||
>
|
||||
<div className="entry-header">
|
||||
<span className="entry-date">{formatDate(entry.createdAt)}</span>
|
||||
<span className="entry-time">{formatTime(entry.createdAt)}</span>
|
||||
<div className="entry-header-right">
|
||||
<span className="entry-time">{formatTime(entry.createdAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="entry-delete-btn"
|
||||
title="Delete entry"
|
||||
onClick={(e) => { e.stopPropagation(); setEntryToDelete(entry) }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="entry-title">{entry.decryptedTitle || entry.title || '[Untitled]'}</h4>
|
||||
{entry.decryptedContent && (
|
||||
<p className="entry-preview">{entry.decryptedContent}</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -352,6 +387,49 @@ export default function HistoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{entryToDelete && (
|
||||
<div
|
||||
className="entry-modal-overlay"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !deleting) setEntryToDelete(null)
|
||||
}}
|
||||
>
|
||||
<div className="entry-modal delete-confirm-modal">
|
||||
<div className="delete-confirm-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="delete-confirm-title">Delete entry?</h2>
|
||||
<p className="delete-confirm-body">
|
||||
"{entryToDelete.decryptedTitle || entryToDelete.title || 'Untitled'}" will be permanently deleted and cannot be recovered.
|
||||
</p>
|
||||
<div className="delete-confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="delete-confirm-cancel"
|
||||
onClick={() => setEntryToDelete(null)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="delete-confirm-delete"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,14 +5,37 @@ import { createEntry } from '../lib/api'
|
||||
import { encryptEntry } from '../lib/crypto'
|
||||
import BottomNav from '../components/BottomNav'
|
||||
import WelcomeModal from '../components/WelcomeModal'
|
||||
import { SaveBookAnimation } from '../components/SaveBookAnimation'
|
||||
import { useOnboardingTour, hasSeenOnboarding, markOnboardingDone } from '../hooks/useOnboardingTour'
|
||||
|
||||
const AFFIRMATIONS = [
|
||||
'You showed up for yourself today 🌱',
|
||||
'Another moment beautifully captured ✨',
|
||||
'Gratitude logged. Keep growing 🌿',
|
||||
'Small moments, big growth 🍃',
|
||||
"You're building something beautiful 💚",
|
||||
'One more grateful moment preserved 🌸',
|
||||
'Your thoughts are safe and stored 🔒',
|
||||
]
|
||||
|
||||
const SAVE_LEAVES = [
|
||||
{ left: -80, dx: -30, rot: -25, delay: 0.0, emoji: '🌱' },
|
||||
{ left: -45, dx: -12, rot: 15, delay: 0.08, emoji: '🌿' },
|
||||
{ left: -15, dx: -22, rot: -10, delay: 0.04, emoji: '🌱' },
|
||||
{ left: 8, dx: 18, rot: 20, delay: 0.12, emoji: '🍃' },
|
||||
{ left: 38, dx: 27, rot: -18, delay: 0.06, emoji: '🌿' },
|
||||
{ left: 68, dx: 38, rot: 12, delay: 0.18, emoji: '🌱' },
|
||||
{ left: -62, dx: -33, rot: 28, delay: 0.22, emoji: '🍃' },
|
||||
{ left: 25, dx: 15, rot: -22, delay: 0.28, emoji: '🌿' },
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, userId, secretKey, loading } = useAuth()
|
||||
const [entry, setEntry] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [phase, setPhase] = useState<'idle' | 'saving' | 'book' | 'celebrate'>('idle')
|
||||
const [affirmation, setAffirmation] = useState('')
|
||||
const [message, setMessage] = useState<{ type: 'error'; text: string } | null>(null)
|
||||
const [showWelcome, setShowWelcome] = useState(false)
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -61,6 +84,11 @@ export default function HomePage() {
|
||||
.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })
|
||||
.toUpperCase()
|
||||
|
||||
const handleBookDone = () => {
|
||||
setPhase('celebrate')
|
||||
setTimeout(() => setPhase('idle'), 2500)
|
||||
}
|
||||
|
||||
const handleTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && title.trim()) {
|
||||
e.preventDefault()
|
||||
@@ -79,7 +107,7 @@ export default function HomePage() {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setPhase('saving')
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
@@ -112,17 +140,14 @@ export default function HomePage() {
|
||||
token
|
||||
)
|
||||
|
||||
setMessage({ type: 'success', text: 'Entry saved securely!' })
|
||||
setTitle('')
|
||||
setEntry('')
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
setAffirmation(AFFIRMATIONS[Math.floor(Math.random() * AFFIRMATIONS.length)])
|
||||
setPhase('book')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to save entry'
|
||||
setMessage({ type: 'error', text: errorMessage })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setPhase('idle')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +173,7 @@ export default function HomePage() {
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
enterKeyHint="next"
|
||||
ref={titleInputRef}
|
||||
disabled={saving}
|
||||
disabled={phase !== 'idle'}
|
||||
/>
|
||||
<textarea
|
||||
id="tour-content-textarea"
|
||||
@@ -158,7 +183,7 @@ export default function HomePage() {
|
||||
onChange={(e) => setEntry(e.target.value)}
|
||||
enterKeyHint="enter"
|
||||
ref={contentTextareaRef}
|
||||
disabled={saving}
|
||||
disabled={phase !== 'idle'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -168,27 +193,55 @@ export default function HomePage() {
|
||||
marginTop: '1rem',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.875rem',
|
||||
backgroundColor: message.type === 'success' ? '#f0fdf4' : '#fef2f2',
|
||||
color: message.type === 'success' ? '#15803d' : '#b91c1c',
|
||||
backgroundColor: '#fef2f2',
|
||||
color: '#b91c1c',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1.5rem', display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
id="tour-save-btn"
|
||||
className="journal-write-btn"
|
||||
onClick={handleWrite}
|
||||
disabled={saving || !title.trim() || !entry.trim()}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Entry'}
|
||||
</button>
|
||||
<div style={{ marginTop: '1.5rem', position: 'relative' }}>
|
||||
{phase === 'celebrate' && (
|
||||
<>
|
||||
<div className="save-leaves" aria-hidden>
|
||||
{SAVE_LEAVES.map((leaf, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="save-leaf"
|
||||
style={{
|
||||
left: `calc(50% + ${leaf.left}px)`,
|
||||
animationDelay: `${leaf.delay}s`,
|
||||
'--leaf-dx': `${leaf.dx}px`,
|
||||
'--leaf-rot': `${leaf.rot}deg`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{leaf.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="save-inline-quote" role="status" aria-live="polite">
|
||||
{affirmation}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{phase !== 'celebrate' && (
|
||||
<button
|
||||
id="tour-save-btn"
|
||||
className="journal-write-btn"
|
||||
onClick={handleWrite}
|
||||
disabled={phase !== 'idle' || !title.trim() || !entry.trim()}
|
||||
>
|
||||
{phase === 'saving' ? 'Saving...' : 'Save Entry'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{phase === 'book' && <SaveBookAnimation onDone={handleBookDone} />}
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,19 +2,17 @@ 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'
|
||||
import { TreeAnimation } from '../components/TreeAnimation'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, loading, signInWithGoogle } = useAuth()
|
||||
const { user, loading, signInWithGoogle, authError } = 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 })
|
||||
}
|
||||
if (user) navigate('/', { replace: true })
|
||||
}, [user, loading, navigate])
|
||||
|
||||
async function handleGoogleSignIn() {
|
||||
@@ -31,31 +29,46 @@ export default function LoginPage() {
|
||||
|
||||
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 className="lp lp--loading" aria-live="polite">
|
||||
<span className="login-page__spinner" aria-hidden />
|
||||
<p>Loading…</p>
|
||||
</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}
|
||||
<div className="lp">
|
||||
{/* ── Left: animated tree hero ─────────────────── */}
|
||||
<div className="lp__hero">
|
||||
<TreeAnimation />
|
||||
<div className="lp__hero-words">
|
||||
<p className="lp__quote">Grow your gratitude.</p>
|
||||
<p className="lp__subquote">One small moment at a time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: login panel ───────────────────────── */}
|
||||
<div className="lp__panel">
|
||||
<div className="lp__form">
|
||||
<div className="lp__brand">
|
||||
<span className="lp__icon" aria-hidden>🌱</span>
|
||||
<h1 className="lp__title">Grateful Journal</h1>
|
||||
</div>
|
||||
|
||||
<p className="lp__tagline">
|
||||
A private space for gratitude and reflection.<br />
|
||||
No feeds. No noise. Just you and your thoughts.
|
||||
</p>
|
||||
)}
|
||||
</LoginCard>
|
||||
|
||||
<div className="lp__actions">
|
||||
<GoogleSignInButton loading={signingIn} onClick={handleGoogleSignIn} />
|
||||
<p className="lp__privacy">🔒 End-to-end encrypted. We never read your entries.</p>
|
||||
{(error || authError) && (
|
||||
<p className="lp__error" role="alert">{error || authError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
38
start-all.sh
38
start-all.sh
@@ -5,47 +5,57 @@
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting Grateful Journal..."
|
||||
# Cleanup on Ctrl+C or exit
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Stopping all services..."
|
||||
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
|
||||
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
|
||||
echo "All services stopped."
|
||||
exit 0
|
||||
}
|
||||
trap cleanup INT TERM
|
||||
|
||||
echo "Starting Grateful Journal..."
|
||||
echo ""
|
||||
|
||||
# Check if MongoDB is running
|
||||
echo "📦 Checking MongoDB..."
|
||||
echo "Checking MongoDB..."
|
||||
if lsof -Pi :27017 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
echo "✓ MongoDB already running on port 27017"
|
||||
echo "MongoDB already running on port 27017"
|
||||
else
|
||||
echo "Starting MongoDB..."
|
||||
brew services start mongodb-community
|
||||
sleep 2
|
||||
echo "✓ MongoDB started on port 27017"
|
||||
echo "MongoDB started on port 27017"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Start Backend (FastAPI with conda environment)
|
||||
echo "🔄 Starting FastAPI backend..."
|
||||
# Activate conda and start backend
|
||||
echo "Starting FastAPI backend..."
|
||||
conda run -n yoyo python backend/main.py &
|
||||
BACKEND_PID=$!
|
||||
echo "✓ Backend running on http://localhost:8001 (PID: $BACKEND_PID)"
|
||||
echo "Backend running on http://localhost:8001 (PID: $BACKEND_PID)"
|
||||
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
|
||||
# Start Frontend (Vite)
|
||||
echo "🔄 Starting Vite frontend..."
|
||||
echo "Starting Vite frontend..."
|
||||
npm run dev -- --port 8000 &
|
||||
FRONTEND_PID=$!
|
||||
echo "✓ Frontend running on http://localhost:8000 (PID: $FRONTEND_PID)"
|
||||
echo "Frontend running on http://localhost:8000 (PID: $FRONTEND_PID)"
|
||||
|
||||
echo ""
|
||||
echo "✅ All services started!"
|
||||
echo "All services started!"
|
||||
echo ""
|
||||
echo "📱 Frontend: http://localhost:8000"
|
||||
echo "🔌 Backend: http://localhost:8001"
|
||||
echo "📄 API Docs: http://localhost:8001/docs"
|
||||
echo "Frontend: http://localhost:8000"
|
||||
echo "Backend: http://localhost:8001"
|
||||
echo "API Docs: http://localhost:8001/docs"
|
||||
echo ""
|
||||
echo "To stop all services, press Ctrl+C"
|
||||
echo "Press Ctrl+C to stop all services"
|
||||
echo ""
|
||||
|
||||
# Wait for both processes
|
||||
|
||||
Reference in New Issue
Block a user