added bg feature
This commit is contained in:
15
TODO.md
15
TODO.md
@@ -1,15 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## 1. Tree Growing Animation on Journal Save
|
|
||||||
When a new journal entry is saved, show a tree growing animation — a visual metaphor for gratitude growing over time.
|
|
||||||
- Trigger animation after successful save
|
|
||||||
- Tree sprouts from seed → sapling → full tree
|
|
||||||
- Could use CSS/SVG animation or a canvas-based approach
|
|
||||||
- Consider making it dismissible / auto-fade after completion
|
|
||||||
|
|
||||||
## 2. Smoother Google Auth Flow
|
|
||||||
Improve the UX of the Google OAuth flow.
|
|
||||||
- Reduce redirect friction (loading states, transitions)
|
|
||||||
- Show a proper loading screen during the OAuth callback
|
|
||||||
- Handle errors gracefully with user-friendly messages
|
|
||||||
- Consider persisting intent so users land back where they started
|
|
||||||
Binary file not shown.
@@ -39,6 +39,8 @@ class UserUpdate(BaseModel):
|
|||||||
photoURL: Optional[str] = None
|
photoURL: Optional[str] = None
|
||||||
theme: Optional[str] = None
|
theme: Optional[str] = None
|
||||||
tutorial: Optional[bool] = None
|
tutorial: Optional[bool] = None
|
||||||
|
backgroundImage: Optional[str] = None
|
||||||
|
backgroundImages: Optional[List[str]] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ async def register_user(user_data: UserCreate):
|
|||||||
"displayName": user["displayName"],
|
"displayName": user["displayName"],
|
||||||
"photoURL": user.get("photoURL"),
|
"photoURL": user.get("photoURL"),
|
||||||
"theme": user.get("theme", "light"),
|
"theme": user.get("theme", "light"),
|
||||||
|
"backgroundImage": user.get("backgroundImage"),
|
||||||
|
"backgroundImages": user.get("backgroundImages", []),
|
||||||
"createdAt": user["createdAt"].isoformat(),
|
"createdAt": user["createdAt"].isoformat(),
|
||||||
"updatedAt": user["updatedAt"].isoformat(),
|
"updatedAt": user["updatedAt"].isoformat(),
|
||||||
"message": "User registered successfully" if result.upserted_id else "User already exists"
|
"message": "User registered successfully" if result.upserted_id else "User already exists"
|
||||||
@@ -79,6 +81,8 @@ async def get_user_by_email(email: str):
|
|||||||
"displayName": user.get("displayName"),
|
"displayName": user.get("displayName"),
|
||||||
"photoURL": user.get("photoURL"),
|
"photoURL": user.get("photoURL"),
|
||||||
"theme": user.get("theme", "light"),
|
"theme": user.get("theme", "light"),
|
||||||
|
"backgroundImage": user.get("backgroundImage"),
|
||||||
|
"backgroundImages": user.get("backgroundImages", []),
|
||||||
"tutorial": user.get("tutorial"),
|
"tutorial": user.get("tutorial"),
|
||||||
"createdAt": user["createdAt"].isoformat(),
|
"createdAt": user["createdAt"].isoformat(),
|
||||||
"updatedAt": user["updatedAt"].isoformat()
|
"updatedAt": user["updatedAt"].isoformat()
|
||||||
@@ -111,6 +115,8 @@ async def get_user_by_id(user_id: str):
|
|||||||
"displayName": user.get("displayName"),
|
"displayName": user.get("displayName"),
|
||||||
"photoURL": user.get("photoURL"),
|
"photoURL": user.get("photoURL"),
|
||||||
"theme": user.get("theme", "light"),
|
"theme": user.get("theme", "light"),
|
||||||
|
"backgroundImage": user.get("backgroundImage"),
|
||||||
|
"backgroundImages": user.get("backgroundImages", []),
|
||||||
"createdAt": user["createdAt"].isoformat(),
|
"createdAt": user["createdAt"].isoformat(),
|
||||||
"updatedAt": user["updatedAt"].isoformat()
|
"updatedAt": user["updatedAt"].isoformat()
|
||||||
}
|
}
|
||||||
@@ -152,6 +158,8 @@ async def update_user(user_id: str, user_data: UserUpdate):
|
|||||||
"displayName": user.get("displayName"),
|
"displayName": user.get("displayName"),
|
||||||
"photoURL": user.get("photoURL"),
|
"photoURL": user.get("photoURL"),
|
||||||
"theme": user.get("theme", "light"),
|
"theme": user.get("theme", "light"),
|
||||||
|
"backgroundImage": user.get("backgroundImage"),
|
||||||
|
"backgroundImages": user.get("backgroundImages", []),
|
||||||
"tutorial": user.get("tutorial"),
|
"tutorial": user.get("tutorial"),
|
||||||
"createdAt": user["createdAt"].isoformat(),
|
"createdAt": user["createdAt"].isoformat(),
|
||||||
"updatedAt": user["updatedAt"].isoformat(),
|
"updatedAt": user["updatedAt"].isoformat(),
|
||||||
|
|||||||
316
src/App.css
316
src/App.css
@@ -3095,3 +3095,319 @@
|
|||||||
.static-page__footer span {
|
.static-page__footer span {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
CUSTOM BACKGROUND IMAGE
|
||||||
|
============================ */
|
||||||
|
body.gj-has-bg .home-page,
|
||||||
|
body.gj-has-bg .history-page,
|
||||||
|
body.gj-has-bg .settings-page {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] body.gj-has-bg .home-page,
|
||||||
|
[data-theme="dark"] body.gj-has-bg .history-page,
|
||||||
|
[data-theme="dark"] body.gj-has-bg .settings-page {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
BACKGROUND GALLERY MODAL
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
.bg-modal {
|
||||||
|
background: var(--color-surface, #fff);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: min(440px, calc(100vw - 2rem));
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gallery {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.25rem 0 0.75rem;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gallery::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
/* Shared thumb/swatch styles */
|
||||||
|
.bg-gallery-swatch,
|
||||||
|
.bg-gallery-thumb,
|
||||||
|
.bg-gallery-add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 84px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2.5px solid transparent;
|
||||||
|
overflow: visible;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.15s, transform 0.12s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gallery-swatch,
|
||||||
|
.bg-gallery-thumb {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gallery-swatch:hover,
|
||||||
|
.bg-gallery-thumb:hover,
|
||||||
|
.bg-gallery-add:hover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gallery-item--active {
|
||||||
|
border-color: var(--color-primary, #22c55e) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default color swatch */
|
||||||
|
.bg-gallery-swatch-fill {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #eef6ee;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail image */
|
||||||
|
.bg-gallery-thumb-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active checkmark badge */
|
||||||
|
.bg-gallery-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary, #22c55e);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label below swatch/add button */
|
||||||
|
.bg-gallery-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -18px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "+" add button */
|
||||||
|
.bg-gallery-add {
|
||||||
|
border: 2px dashed var(--color-border, #d4e8d4);
|
||||||
|
background: var(--color-accent-light, #dcfce7);
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
gap: 0.1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gallery-add:hover {
|
||||||
|
border-color: var(--color-primary, #22c55e);
|
||||||
|
color: var(--color-primary, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button */
|
||||||
|
.bg-close-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1.5px solid var(--color-border, #d4e8d4);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text, #1a1a1a);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-close-btn:hover {
|
||||||
|
background: var(--color-accent-light, #dcfce7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
FULLSCREEN IMAGE CROPPER
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
.cropper-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 500;
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-cancel-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-apply-btn {
|
||||||
|
background: #22c55e;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-apply-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark area outside crop box via box-shadow */
|
||||||
|
.cropper-shade {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.52);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crop box border */
|
||||||
|
.cropper-box {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: move;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rule-of-thirds grid */
|
||||||
|
.cropper-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(255,255,255,0.22) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, rgba(255,255,255,0.22) 1px, transparent 1px);
|
||||||
|
background-size: 33.333% 33.333%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handles */
|
||||||
|
.cropper-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-handle-tl { top: -4px; left: -4px; cursor: nw-resize; }
|
||||||
|
.cropper-handle-tr { top: -4px; right: -4px; cursor: ne-resize; }
|
||||||
|
.cropper-handle-bl { bottom: -4px; left: -4px; cursor: sw-resize; }
|
||||||
|
.cropper-handle-br { bottom: -4px; right: -4px; cursor: se-resize; }
|
||||||
|
|
||||||
|
.cropper-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.45rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================
|
||||||
|
DARK THEME — bg gallery
|
||||||
|
============================ */
|
||||||
|
|
||||||
|
[data-theme="dark"] .bg-modal {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .bg-gallery-swatch-fill {
|
||||||
|
background: #0f0f0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .bg-gallery-add {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .bg-gallery-add:hover {
|
||||||
|
border-color: #4ade80;
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .bg-close-btn {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #e8f5e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .bg-close-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|||||||
223
src/components/BgImageCropper.tsx
Normal file
223
src/components/BgImageCropper.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
|
type HandleType = 'move' | 'tl' | 'tr' | 'bl' | 'br'
|
||||||
|
interface CropBox { x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imageSrc: string
|
||||||
|
aspectRatio: number // width / height of the target display area
|
||||||
|
onCrop: (dataUrl: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_SIZE = 80
|
||||||
|
|
||||||
|
function clamp(v: number, lo: number, hi: number) {
|
||||||
|
return Math.max(lo, Math.min(hi, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BgImageCropper({ imageSrc, aspectRatio, onCrop, onCancel }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null)
|
||||||
|
|
||||||
|
// Keep crop box in both a ref (for event handlers, avoids stale closure) and state (for rendering)
|
||||||
|
const cropRef = useRef<CropBox | null>(null)
|
||||||
|
const [cropBox, setCropBox] = useState<CropBox | null>(null)
|
||||||
|
|
||||||
|
const drag = useRef<{
|
||||||
|
type: HandleType
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
startCrop: CropBox
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const setBox = useCallback((b: CropBox) => {
|
||||||
|
cropRef.current = b
|
||||||
|
setCropBox(b)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Centre a crop box filling most of the displayed image at the target aspect ratio
|
||||||
|
const initCrop = useCallback(() => {
|
||||||
|
const c = containerRef.current
|
||||||
|
const img = imgRef.current
|
||||||
|
if (!c || !img) return
|
||||||
|
|
||||||
|
const cW = c.clientWidth
|
||||||
|
const cH = c.clientHeight
|
||||||
|
const scale = Math.min(cW / img.naturalWidth, cH / img.naturalHeight)
|
||||||
|
const dispW = img.naturalWidth * scale
|
||||||
|
const dispH = img.naturalHeight * scale
|
||||||
|
const imgX = (cW - dispW) / 2
|
||||||
|
const imgY = (cH - dispH) / 2
|
||||||
|
|
||||||
|
let w = dispW * 0.9
|
||||||
|
let h = w / aspectRatio
|
||||||
|
if (h > dispH * 0.9) { h = dispH * 0.9; w = h * aspectRatio }
|
||||||
|
|
||||||
|
setBox({
|
||||||
|
x: imgX + (dispW - w) / 2,
|
||||||
|
y: imgY + (dispH - h) / 2,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
})
|
||||||
|
}, [aspectRatio, setBox])
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((e: React.PointerEvent, type: HandleType) => {
|
||||||
|
if (!cropRef.current) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
drag.current = {
|
||||||
|
type,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
startCrop: { ...cropRef.current },
|
||||||
|
}
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (!drag.current || !containerRef.current) return
|
||||||
|
const c = containerRef.current
|
||||||
|
const cW = c.clientWidth
|
||||||
|
const cH = c.clientHeight
|
||||||
|
const dx = e.clientX - drag.current.startX
|
||||||
|
const dy = e.clientY - drag.current.startY
|
||||||
|
const sc = drag.current.startCrop
|
||||||
|
const t = drag.current.type
|
||||||
|
|
||||||
|
let x = sc.x, y = sc.y, w = sc.w, h = sc.h
|
||||||
|
|
||||||
|
if (t === 'move') {
|
||||||
|
x = clamp(sc.x + dx, 0, cW - sc.w)
|
||||||
|
y = clamp(sc.y + dy, 0, cH - sc.h)
|
||||||
|
} else {
|
||||||
|
// Resize: width driven by dx, height derived from aspect ratio
|
||||||
|
let newW: number
|
||||||
|
if (t === 'br' || t === 'tr') newW = clamp(sc.w + dx, MIN_SIZE, cW)
|
||||||
|
else newW = clamp(sc.w - dx, MIN_SIZE, cW)
|
||||||
|
|
||||||
|
const newH = newW / aspectRatio
|
||||||
|
|
||||||
|
if (t === 'br') { x = sc.x; y = sc.y }
|
||||||
|
else if (t === 'bl') { x = sc.x + sc.w - newW; y = sc.y }
|
||||||
|
else if (t === 'tr') { x = sc.x; y = sc.y + sc.h - newH }
|
||||||
|
else { x = sc.x + sc.w - newW; y = sc.y + sc.h - newH }
|
||||||
|
|
||||||
|
x = clamp(x, 0, cW - newW)
|
||||||
|
y = clamp(y, 0, cH - newH)
|
||||||
|
w = newW
|
||||||
|
h = newH
|
||||||
|
}
|
||||||
|
|
||||||
|
setBox({ x, y, w, h })
|
||||||
|
}, [aspectRatio, setBox])
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => { drag.current = null }, [])
|
||||||
|
|
||||||
|
const handleCrop = useCallback(() => {
|
||||||
|
const img = imgRef.current
|
||||||
|
const c = containerRef.current
|
||||||
|
const cb = cropRef.current
|
||||||
|
if (!img || !c || !cb) return
|
||||||
|
|
||||||
|
const cW = c.clientWidth
|
||||||
|
const cH = c.clientHeight
|
||||||
|
const scale = Math.min(cW / img.naturalWidth, cH / img.naturalHeight)
|
||||||
|
const dispW = img.naturalWidth * scale
|
||||||
|
const dispH = img.naturalHeight * scale
|
||||||
|
const offX = (cW - dispW) / 2
|
||||||
|
const offY = (cH - dispH) / 2
|
||||||
|
|
||||||
|
// Map crop box back to source image coordinates
|
||||||
|
const srcX = (cb.x - offX) / scale
|
||||||
|
const srcY = (cb.y - offY) / scale
|
||||||
|
const srcW = cb.w / scale
|
||||||
|
const srcH = cb.h / scale
|
||||||
|
|
||||||
|
// Output resolution: screen size × device pixel ratio, capped at 1440px wide
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
const outW = Math.min(Math.round(window.innerWidth * dpr), 1440)
|
||||||
|
const outH = Math.round(outW / aspectRatio)
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = outW
|
||||||
|
canvas.height = outH
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, outW, outH)
|
||||||
|
onCrop(canvas.toDataURL('image/jpeg', 0.72))
|
||||||
|
}, [aspectRatio, onCrop])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cropper-overlay">
|
||||||
|
<div className="cropper-header">
|
||||||
|
<button type="button" className="cropper-cancel-btn" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span className="cropper-title">Crop Background</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cropper-apply-btn"
|
||||||
|
onClick={handleCrop}
|
||||||
|
disabled={!cropBox}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="cropper-container"
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={imageSrc}
|
||||||
|
className="cropper-image"
|
||||||
|
onLoad={initCrop}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cropBox && (
|
||||||
|
<>
|
||||||
|
{/* Darkened area outside crop box via box-shadow */}
|
||||||
|
<div
|
||||||
|
className="cropper-shade"
|
||||||
|
style={{
|
||||||
|
left: cropBox.x,
|
||||||
|
top: cropBox.y,
|
||||||
|
width: cropBox.w,
|
||||||
|
height: cropBox.h,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Moveable crop box */}
|
||||||
|
<div
|
||||||
|
className="cropper-box"
|
||||||
|
style={{
|
||||||
|
left: cropBox.x,
|
||||||
|
top: cropBox.y,
|
||||||
|
width: cropBox.w,
|
||||||
|
height: cropBox.h,
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => onPointerDown(e, 'move')}
|
||||||
|
>
|
||||||
|
{/* Rule-of-thirds grid */}
|
||||||
|
<div className="cropper-grid" />
|
||||||
|
|
||||||
|
{/* Resize handles */}
|
||||||
|
<div className="cropper-handle cropper-handle-tl" onPointerDown={(e) => onPointerDown(e, 'tl')} />
|
||||||
|
<div className="cropper-handle cropper-handle-tr" onPointerDown={(e) => onPointerDown(e, 'tr')} />
|
||||||
|
<div className="cropper-handle cropper-handle-bl" onPointerDown={(e) => onPointerDown(e, 'bl')} />
|
||||||
|
<div className="cropper-handle cropper-handle-br" onPointerDown={(e) => onPointerDown(e, 'br')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="cropper-hint">Drag to move · Drag corners to resize</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ type MongoUser = {
|
|||||||
photoURL?: string
|
photoURL?: string
|
||||||
theme?: string
|
theme?: string
|
||||||
tutorial?: boolean
|
tutorial?: boolean
|
||||||
|
backgroundImage?: string | null
|
||||||
|
backgroundImages?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
@@ -62,6 +64,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [authError, setAuthError] = useState<string | null>(null)
|
const [authError, setAuthError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Apply custom background image whenever mongoUser changes
|
||||||
|
useEffect(() => {
|
||||||
|
const bg = mongoUser?.backgroundImage
|
||||||
|
if (bg) {
|
||||||
|
document.body.style.backgroundImage = `url(${bg})`
|
||||||
|
document.body.style.backgroundSize = 'cover'
|
||||||
|
document.body.style.backgroundPosition = 'center'
|
||||||
|
document.body.style.backgroundAttachment = 'fixed'
|
||||||
|
document.body.classList.add('gj-has-bg')
|
||||||
|
} else {
|
||||||
|
document.body.style.backgroundImage = ''
|
||||||
|
document.body.classList.remove('gj-has-bg')
|
||||||
|
}
|
||||||
|
}, [mongoUser?.backgroundImage])
|
||||||
|
|
||||||
// Initialize encryption keys on login
|
// Initialize encryption keys on login
|
||||||
async function initializeEncryption(authUser: User) {
|
async function initializeEncryption(authUser: User) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export async function getUserByEmail(email: string, token: string) {
|
|||||||
|
|
||||||
export async function updateUserProfile(
|
export async function updateUserProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
updates: { displayName?: string; photoURL?: string; theme?: string; tutorial?: boolean },
|
updates: { displayName?: string; photoURL?: string; theme?: string; tutorial?: boolean; backgroundImage?: string | null; backgroundImages?: string[] },
|
||||||
token: string
|
token: string
|
||||||
) {
|
) {
|
||||||
return apiCall(`/users/${userId}`, {
|
return apiCall(`/users/${userId}`, {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api'
|
import { deleteUser as deleteUserApi, updateUserProfile } from '../lib/api'
|
||||||
|
import { BgImageCropper } from '../components/BgImageCropper'
|
||||||
import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto'
|
import { clearDeviceKey, clearEncryptedSecretKey } from '../lib/crypto'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import BottomNav from '../components/BottomNav'
|
import BottomNav from '../components/BottomNav'
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from '../hooks/useReminder'
|
} from '../hooks/useReminder'
|
||||||
|
|
||||||
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
|
const MAX_PHOTO_SIZE = 200 // px — resize to 200x200
|
||||||
|
const MAX_BG_HISTORY = 3
|
||||||
|
|
||||||
function resizeImage(file: File): Promise<string> {
|
function resizeImage(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -73,6 +75,16 @@ export default function SettingsPage() {
|
|||||||
const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null)
|
const [editPhotoPreview, setEditPhotoPreview] = useState<string | null>(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Background image state
|
||||||
|
const bgFileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [showBgModal, setShowBgModal] = useState(false)
|
||||||
|
const [cropperSrc, setCropperSrc] = useState<string | null>(null)
|
||||||
|
const [bgApplying, setBgApplying] = useState(false)
|
||||||
|
|
||||||
|
// Derived from mongoUser (no local state — always fresh after refreshMongoUser)
|
||||||
|
const bgImages: string[] = (mongoUser as { backgroundImages?: string[] } | null)?.backgroundImages ?? []
|
||||||
|
const activeImage: string | null = mongoUser?.backgroundImage ?? null
|
||||||
|
|
||||||
// Continue onboarding tour if navigated here from the history page tour
|
// Continue onboarding tour if navigated here from the history page tour
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPendingTourStep() === 'settings') {
|
if (hasPendingTourStep() === 'settings') {
|
||||||
@@ -136,6 +148,52 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bgUpdate(updates: Parameters<typeof updateUserProfile>[1]) {
|
||||||
|
if (!user || !userId) return
|
||||||
|
setBgApplying(true)
|
||||||
|
try {
|
||||||
|
const token = await user.getIdToken()
|
||||||
|
await updateUserProfile(userId, updates, token)
|
||||||
|
await refreshMongoUser()
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Failed to update background'
|
||||||
|
setMessage({ type: 'error', text: msg })
|
||||||
|
} finally {
|
||||||
|
setBgApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyDefault = () => {
|
||||||
|
if (!activeImage) return // already on default
|
||||||
|
bgUpdate({ backgroundImage: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyFromGallery = (img: string) => {
|
||||||
|
if (img === activeImage) return // already active
|
||||||
|
bgUpdate({ backgroundImage: img })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBgFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setShowBgModal(false)
|
||||||
|
setCropperSrc(URL.createObjectURL(file))
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropDone = async (dataUrl: string) => {
|
||||||
|
if (cropperSrc) URL.revokeObjectURL(cropperSrc)
|
||||||
|
setCropperSrc(null)
|
||||||
|
// Prepend to history, deduplicate, cap at MAX_BG_HISTORY
|
||||||
|
const newHistory = [dataUrl, ...bgImages.filter(i => i !== dataUrl)].slice(0, MAX_BG_HISTORY)
|
||||||
|
await bgUpdate({ backgroundImage: dataUrl, backgroundImages: newHistory })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropCancel = () => {
|
||||||
|
if (cropperSrc) URL.revokeObjectURL(cropperSrc)
|
||||||
|
setCropperSrc(null)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply theme to DOM
|
// Apply theme to DOM
|
||||||
const applyTheme = useCallback((t: 'light' | 'dark') => {
|
const applyTheme = useCallback((t: 'light' | 'dark') => {
|
||||||
document.documentElement.setAttribute('data-theme', t)
|
document.documentElement.setAttribute('data-theme', t)
|
||||||
@@ -443,6 +501,27 @@ export default function SettingsPage() {
|
|||||||
<div className="settings-divider"></div>
|
<div className="settings-divider"></div>
|
||||||
*/}
|
*/}
|
||||||
|
|
||||||
|
<button type="button" className="settings-item settings-item-button" onClick={() => setShowBgModal(true)}>
|
||||||
|
<div className="settings-item-icon settings-item-icon-blue">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21 15 16 10 5 21" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="settings-item-content">
|
||||||
|
<h4 className="settings-item-title">Background</h4>
|
||||||
|
<p className="settings-item-subtitle">
|
||||||
|
{activeImage ? 'Custom image active' : bgImages.length > 0 ? `${bgImages.length} saved` : 'Default color'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<svg className="settings-item-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="settings-divider"></div>
|
||||||
|
|
||||||
<div id="tour-theme-switcher" 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">
|
||||||
@@ -718,6 +797,110 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Background Image Gallery Modal */}
|
||||||
|
{showBgModal && (
|
||||||
|
<div className="confirm-modal-overlay" onClick={() => !bgApplying && setShowBgModal(false)}>
|
||||||
|
<div className="bg-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="edit-modal-title" style={{ marginBottom: '0.25rem' }}>Background</h3>
|
||||||
|
<p className="settings-item-subtitle" style={{ marginBottom: '1rem' }}>
|
||||||
|
Tap to apply · + to upload new
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={bgFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleBgFileSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gallery row */}
|
||||||
|
<div className="bg-gallery">
|
||||||
|
{/* Default color swatch — always first */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bg-gallery-swatch${!activeImage ? ' bg-gallery-item--active' : ''}`}
|
||||||
|
onClick={handleApplyDefault}
|
||||||
|
disabled={bgApplying}
|
||||||
|
title="Default color"
|
||||||
|
>
|
||||||
|
<div className="bg-gallery-swatch-fill" />
|
||||||
|
{!activeImage && (
|
||||||
|
<div className="bg-gallery-badge">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="bg-gallery-label">Default</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* History thumbnails */}
|
||||||
|
{bgImages.map((img, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className={`bg-gallery-thumb${img === activeImage ? ' bg-gallery-item--active' : ''}`}
|
||||||
|
onClick={() => handleApplyFromGallery(img)}
|
||||||
|
disabled={bgApplying}
|
||||||
|
title={`Background ${i + 1}`}
|
||||||
|
>
|
||||||
|
<img src={img} alt="" className="bg-gallery-thumb-img" />
|
||||||
|
{img === activeImage && (
|
||||||
|
<div className="bg-gallery-badge">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add new */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-gallery-add"
|
||||||
|
onClick={() => bgFileInputRef.current?.click()}
|
||||||
|
disabled={bgApplying}
|
||||||
|
title="Upload new image"
|
||||||
|
>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<span className="bg-gallery-label">New</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bgApplying && (
|
||||||
|
<p className="settings-item-subtitle" style={{ textAlign: 'center', marginTop: '0.75rem' }}>
|
||||||
|
Saving…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bg-close-btn"
|
||||||
|
onClick={() => setShowBgModal(false)}
|
||||||
|
disabled={bgApplying}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fullscreen image cropper */}
|
||||||
|
{cropperSrc && (
|
||||||
|
<BgImageCropper
|
||||||
|
imageSrc={cropperSrc}
|
||||||
|
aspectRatio={window.innerWidth / window.innerHeight}
|
||||||
|
onCrop={handleCropDone}
|
||||||
|
onCancel={handleCropCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Daily Reminder Modal */}
|
{/* Daily Reminder Modal */}
|
||||||
{showReminderModal && (
|
{showReminderModal && (
|
||||||
<div className="confirm-modal-overlay" onClick={() => !reminderSaving && setShowReminderModal(false)}>
|
<div className="confirm-modal-overlay" onClick={() => !reminderSaving && setShowReminderModal(false)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user