diff --git a/.vscode/settings.json b/.vscode/settings.json index 8ef924f..0eee02d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "css.customData": [], - "css.lint.unknownAtRules": "ignore" + "css.lint.unknownAtRules": "ignore", + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..eeb4cb5 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,5 @@ +export const runtime = "nodejs"; + +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/app/api/firebase-token/route.ts b/app/api/firebase-token/route.ts new file mode 100644 index 0000000..7e613f0 --- /dev/null +++ b/app/api/firebase-token/route.ts @@ -0,0 +1,81 @@ +export const runtime = "nodejs"; + +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { getAdminAuth } from "@/lib/firebaseAdmin"; + +/** + * POST /api/firebase-token + * + * UID-Continuity Bridge: Converts a NextAuth session → Firebase custom token. + * + * Flow: + * 1. Verify the caller has a valid NextAuth session. + * 2. Extract email from the session. + * 3. Look up the EXISTING Firebase user by email (getUserByEmail). + * - If found → use that uid. This is CRITICAL: existing Firestore data + * (spots, images, etc.) is scoped to the uid that Firebase originally + * generated for this Google account. If we created a new uid instead, + * all existing data would be orphaned and invisible to the user. + * - If NOT found → this is a genuinely new user who has never signed in. + * Create a new Firebase user (createUser) and use the resulting uid. + * 4. Mint a custom token for that uid and return it. + * + * The client calls signInWithCustomToken(auth, customToken) to establish + * the Firebase session, which populates Firestore security-rules' request.auth. + */ +export async function POST() { + // 1. Verify NextAuth session + const session = await auth(); + if (!session?.user?.email) { + return NextResponse.json( + { error: "Not authenticated" }, + { status: 401 } + ); + } + + const { email, name, image } = session.user; + const adminAuth = getAdminAuth(); + + try { + let uid: string; + + // 2. Look up existing Firebase user by email FIRST. + // + // WHY THIS MATTERS: + // Firebase Auth originally assigned a uid when this user first signed in + // via signInWithPopup/signInWithRedirect. All their Firestore documents + // (users/{uid}/spots/...) and Storage paths are keyed to that uid. + // If we skip this lookup and always call createUser(), the new user record + // gets a DIFFERENT uid, and every existing spot/image becomes unreachable. + try { + const existingUser = await adminAuth.getUserByEmail(email); + uid = existingUser.uid; + } catch (lookupError: unknown) { + // auth/user-not-found means this is a genuinely new user. + const code = (lookupError as { code?: string })?.code; + if (code === "auth/user-not-found") { + const newUser = await adminAuth.createUser({ + email, + displayName: name ?? undefined, + photoURL: image ?? undefined, + }); + uid = newUser.uid; + } else { + // Unexpected error — re-throw. + throw lookupError; + } + } + + // 3. Mint custom token + const customToken = await adminAuth.createCustomToken(uid); + + return NextResponse.json({ customToken }); + } catch (error) { + console.error("[firebase-token] Error minting custom token:", error); + return NextResponse.json( + { error: "Failed to create Firebase token" }, + { status: 500 } + ); + } +} diff --git a/app/db.ts b/app/db.ts index ff8deb6..a402e59 100644 --- a/app/db.ts +++ b/app/db.ts @@ -13,6 +13,14 @@ export interface FoodSpotLog { createdAt: number; // epoch ms /** Base64 JPEG data-URL thumbnail for fast list rendering. */ thumbnail?: string; + /** Firestore document ID (for cloud sync). */ + firebaseId?: string; + /** Firebase Auth UID that owns this entry. */ + userId?: string; + /** True when the entry has been saved locally but not yet synced to Firestore. */ + pendingSync?: boolean; + latitude?: number; + longitude?: number; } /** Full-resolution image associated with a spot (one image per spot). */ @@ -23,6 +31,8 @@ export interface SpotImage { /** Compressed JPEG binary — stored but NOT indexed. */ blob: Blob; createdAt: number; // epoch ms + /** Firestore document ID for the image. */ + firebaseId?: string; } class KeepCheckDB extends Dexie { @@ -54,11 +64,6 @@ class KeepCheckDB extends Dexie { ); // v3 — adds `images` store + optional `thumbnail` field on spots. - // The spots index string is unchanged because `thumbnail` is NOT - // indexed (Dexie only requires indexed fields in the schema string). - // The images store indexes `spotId` for cascade-delete lookups and - // `createdAt` for chronological queries; `blob` is stored but never - // indexed to save space. this.version(3) .stores({ spots: "++id, name, category, rating, createdAt", @@ -69,14 +74,73 @@ class KeepCheckDB extends Dexie { .table("spots") .toCollection() .modify((spot) => { - // Backfill — existing rows simply get `undefined` so the - // TypeScript `?` optional field is satisfied. This is a - // no-op for the data but documents the migration intent. if (spot.thumbnail === undefined) { spot.thumbnail = undefined; } }) ); + + // v4 — adds `firebaseId` and `userId` fields for cloud sync. + // These are indexed so we can look up spots by their Firestore ID + // and filter by user. + this.version(4) + .stores({ + spots: "++id, name, category, rating, createdAt, firebaseId, userId", + images: "++id, spotId, createdAt, firebaseId", + }) + .upgrade((tx) => + tx + .table("spots") + .toCollection() + .modify((spot) => { + if (spot.firebaseId === undefined) { + spot.firebaseId = undefined; + } + if (spot.userId === undefined) { + spot.userId = undefined; + } + }) + ); + + // v5 — adds `pendingSync` index for offline-first sync tracking. + // When a spot is saved locally but hasn't been synced to Firestore + // yet, pendingSync = true. The sync manager uses this index to + // efficiently sweep all unsynced entries. + this.version(5) + .stores({ + spots: "++id, name, category, rating, createdAt, firebaseId, userId, pendingSync", + images: "++id, spotId, createdAt, firebaseId", + }) + .upgrade((tx) => + tx + .table("spots") + .toCollection() + .modify((spot) => { + if (spot.pendingSync === undefined) { + spot.pendingSync = false; + } + }) + ); + + // v6 — adds `latitude` and `longitude` fields to spots for the map view. + this.version(6) + .stores({ + spots: "++id, name, category, rating, createdAt, firebaseId, userId, pendingSync", + images: "++id, spotId, createdAt, firebaseId", + }) + .upgrade((tx) => + tx + .table("spots") + .toCollection() + .modify((spot) => { + if (spot.latitude === undefined) { + spot.latitude = undefined; + } + if (spot.longitude === undefined) { + spot.longitude = undefined; + } + }) + ); } } diff --git a/app/globals.css b/app/globals.css index 1bd5261..491270b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,48 +2,336 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* ================================================================== */ +/* NEUMORPHISM DESIGN SYSTEM */ +/* ================================================================== */ + :root { - --bg: #EFE7D8; - --surface: #FBF6EC; + /* Fonts family variables */ + --font-fraunces: "Fraunces", Georgia, serif; + --font-work-sans: "Work Sans", system-ui, -apple-system, sans-serif; + --font-space-mono: "Space Mono", Consolas, Monaco, monospace; + + /* Base palette — warm clay / parchment */ + --bg: #ddd5c8; + --surface: #ddd5c8; --text-primary: #2C2018; --text-secondary: #6B5D4F; - --accent: #A6572B; + --accent: #c06830; + --accent-glow: #d68a4c; --category-cafe: #8B6F9E; --category-restaurant: #3E6E77; + + /* Neumorphic shadow pairs (light) */ + --neu-light: #efe7d8; + --neu-dark: #c5bdb0; + --neu-shadow-raised: 6px 6px 14px var(--neu-dark), -6px -6px 14px var(--neu-light); + --neu-shadow-inset: inset 4px 4px 10px var(--neu-dark), inset -4px -4px 10px var(--neu-light); + --neu-shadow-button: 4px 4px 10px var(--neu-dark), -4px -4px 10px var(--neu-light); + --neu-shadow-button-active: inset 3px 3px 8px var(--neu-dark), inset -3px -3px 8px var(--neu-light); + --neu-shadow-subtle: 3px 3px 8px var(--neu-dark), -3px -3px 8px var(--neu-light); } .dark { - --bg: #1E1712; - --surface: #2A2119; + --bg: #1a1410; + --surface: #1a1410; --text-primary: #F0E6D6; --text-secondary: #B8A890; --accent: #D68A4C; - --category-cafe: #7C5A3E; - --category-restaurant: #3E5A5E; + --accent-glow: #e9a868; + --category-cafe: #9b7fb0; + --category-restaurant: #4e8e97; + + /* Neumorphic shadow pairs (dark) */ + --neu-light: #231c16; + --neu-dark: #110e0b; + --neu-shadow-raised: 6px 6px 14px var(--neu-dark), -6px -6px 14px var(--neu-light); + --neu-shadow-inset: inset 4px 4px 10px var(--neu-dark), inset -4px -4px 10px var(--neu-light); + --neu-shadow-button: 4px 4px 10px var(--neu-dark), -4px -4px 10px var(--neu-light); + --neu-shadow-button-active: inset 3px 3px 8px var(--neu-dark), inset -3px -3px 8px var(--neu-light); + --neu-shadow-subtle: 3px 3px 8px var(--neu-dark), -3px -3px 8px var(--neu-light); } +/* Tailwind theme bindings */ @theme inline { --color-background: var(--bg); --color-foreground: var(--text-primary); + --color-bg: var(--bg); --color-surface: var(--surface); --color-text-primary: var(--text-primary); --color-text-secondary: var(--text-secondary); --color-accent: var(--accent); + --color-accent-glow: var(--accent-glow); --color-category-cafe: var(--category-cafe); --color-category-restaurant: var(--category-restaurant); - + --color-neu-light: var(--neu-light); + --color-neu-dark: var(--neu-dark); + + --shadow-neu-raised: var(--neu-shadow-raised); + --shadow-neu-inset: var(--neu-shadow-inset); + --shadow-neu-button: var(--neu-shadow-button); + --shadow-neu-button-active: var(--neu-shadow-button-active); + --shadow-neu-subtle: var(--neu-shadow-subtle); + --font-serif: var(--font-fraunces); --font-sans: var(--font-work-sans); --font-mono: var(--font-space-mono); } +/* ================================================================== */ +/* BASE */ +/* ================================================================== */ + body { background: var(--bg); color: var(--text-primary); - font-family: var(--font-work-sans), sans-serif; + font-family: var(--font-work-sans), system-ui, sans-serif; +} + +/* ================================================================== */ +/* NEUMORPHIC UTILITY CLASSES */ +/* ================================================================== */ + +.neu-raised { + background: var(--bg); + box-shadow: var(--neu-shadow-raised); + border-radius: 20px; +} + +.neu-inset { + background: var(--bg); + box-shadow: var(--neu-shadow-inset); + border-radius: 16px; +} + +.neu-button { + background: var(--bg); + box-shadow: var(--neu-shadow-button); + border-radius: 14px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.neu-button:hover { + box-shadow: var(--neu-shadow-raised); + transform: translateY(-1px); +} + +.neu-button:active, +.neu-button.active { + box-shadow: var(--neu-shadow-button-active); + transform: translateY(0); +} + +.neu-input { + background: var(--bg); + box-shadow: var(--neu-shadow-inset); + border: none; + border-radius: 14px; + padding: 14px 16px; + color: var(--text-primary); + font-size: 0.9rem; + outline: none; + transition: box-shadow 0.3s ease, transform 0.2s ease; } -/* Hide scrollbar on horizontally-scrollable pill rows */ +.neu-input:focus { + box-shadow: inset 5px 5px 12px var(--neu-dark), inset -5px -5px 12px var(--neu-light), 0 0 0 2px var(--accent); +} + +.neu-input::placeholder { + color: var(--text-secondary); + opacity: 0.5; +} + +.neu-card { + background: var(--bg); + box-shadow: var(--neu-shadow-raised); + border-radius: 24px; + transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); +} + +.neu-card:hover { + box-shadow: 8px 8px 18px var(--neu-dark), -8px -8px 18px var(--neu-light); + transform: translateY(-3px); +} + +.neu-card:active { + box-shadow: var(--neu-shadow-button-active); + transform: translateY(0); +} + +/* ================================================================== */ +/* GOOEY ANIMATIONS */ +/* ================================================================== */ + +/* Blob floating animations for background */ +@keyframes blob-float-1 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 25% { transform: translate(60px, -40px) scale(1.15); } + 50% { transform: translate(20px, 50px) scale(0.9); } + 75% { transform: translate(-30px, 20px) scale(1.08); } +} + +@keyframes blob-float-2 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 25% { transform: translate(-50px, 30px) scale(1.1); } + 50% { transform: translate(-20px, -60px) scale(0.95); } + 75% { transform: translate(40px, -20px) scale(1.12); } +} + +@keyframes blob-float-3 { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(30px, 40px) scale(1.2); } + 66% { transform: translate(-40px, -30px) scale(0.85); } +} + +.animate-blob-1 { + animation: blob-float-1 14s ease-in-out infinite; +} + +.animate-blob-2 { + animation: blob-float-2 18s ease-in-out infinite; + animation-delay: -4s; +} + +.animate-blob-3 { + animation: blob-float-3 12s ease-in-out infinite; + animation-delay: -7s; +} + +/* Page entrance */ +@keyframes page-enter { + 0% { + opacity: 0; + transform: translateY(16px) scale(0.97); + filter: blur(4px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.animate-page-enter { + animation: page-enter 0.5s cubic-bezier(0.4, 0, 0.2, 1) both; +} + +/* Card stagger entrance */ +@keyframes card-enter { + 0% { + opacity: 0; + transform: translateY(24px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.animate-card-enter { + animation: card-enter 0.4s cubic-bezier(0.4, 0, 0.2, 1) both; +} + +/* Gooey press effect */ +@keyframes gooey-press { + 0% { transform: scale(1); filter: blur(0); } + 40% { transform: scale(0.93); filter: blur(1px); } + 60% { transform: scale(1.03); filter: blur(0.5px); } + 100% { transform: scale(1); filter: blur(0); } +} + +.animate-gooey-press { + animation: gooey-press 0.45s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Pulse glow for accent elements */ +@keyframes pulse-glow { + 0%, 100% { + box-shadow: var(--neu-shadow-raised), 0 0 0 0 rgba(var(--accent), 0); + } + 50% { + box-shadow: var(--neu-shadow-raised), 0 0 20px 4px var(--accent-glow); + } +} + +/* Soft floating */ +@keyframes float-soft { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +.animate-float { + animation: float-soft 3.5s ease-in-out infinite; +} + +/* Gooey modal entrance */ +@keyframes modal-gooey-in { + 0% { + opacity: 0; + transform: scale(0.8); + filter: blur(10px); + } + 50% { + opacity: 1; + transform: scale(1.03); + filter: blur(2px); + } + 100% { + opacity: 1; + transform: scale(1); + filter: blur(0); + } +} + +.animate-modal-enter { + animation: modal-gooey-in 0.4s cubic-bezier(0.4, 0, 0.2, 1) both; +} + +/* Backdrop fade */ +@keyframes backdrop-fade { + 0% { opacity: 0; backdrop-filter: blur(0); } + 100% { opacity: 1; backdrop-filter: blur(8px); } +} + +.animate-backdrop { + animation: backdrop-fade 0.3s ease-out both; +} + +/* Delete melt */ +@keyframes melt-away { + 0% { + opacity: 1; + transform: scale(1) translateY(0); + filter: blur(0); + } + 100% { + opacity: 0; + transform: scale(0.8) translateY(20px); + filter: blur(6px); + } +} + +.animate-melt { + animation: melt-away 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +/* Gooey ripple on tap */ +@keyframes ripple-gooey { + 0% { + transform: scale(0); + opacity: 0.5; + } + 100% { + transform: scale(2.5); + opacity: 0; + } +} + +/* ================================================================== */ +/* SCROLLBAR */ +/* ================================================================== */ + .scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; @@ -51,3 +339,402 @@ body { .scrollbar-none::-webkit-scrollbar { display: none; } + +/* Custom themed scrollbar */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: var(--bg); +} +::-webkit-scrollbar-thumb { + background: var(--text-secondary); + border-radius: 3px; + opacity: 0.3; +} +::-webkit-scrollbar-thumb:hover { + background: var(--accent); +} + +/* ================================================================== */ +/* UTILITY OVERRIDES */ +/* ================================================================== */ + +/* Smooth all transitions globally */ +*, *::before, *::after { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Selection color */ +::selection { + background: var(--accent); + color: white; +} + +/* ================================================================== */ +/* ADDITIONAL NEW STYLES FOR Restructured LAYOUT & GOOEY EFFECTS */ +/* ================================================================== */ + +/* 1. Carousel component container & behaviors */ +.carousel-container { + display: flex; + overflow-x: auto; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; +} + +.carousel-card { + scroll-snap-align: start; + flex: 0 0 280px; + max-width: 280px; +} + +@media (min-width: 640px) { + .carousel-card { + flex: 0 0 340px; + max-width: 340px; + } +} + +/* Edge fading gradient mask for carousel */ +.carousel-mask { + mask-image: linear-gradient(to right, transparent, white 20px, white calc(100% - 20px), transparent); + -webkit-mask-image: linear-gradient(to right, transparent, white 20px, white calc(100% - 20px), transparent); +} + +/* 2. Gooey tab selectors styling */ +.gooey-tab-container { + position: relative; + display: flex; + padding: 6px; + background: var(--bg); + box-shadow: var(--neu-shadow-inset); + border-radius: 20px; +} + +/* High-contrast container for gooey filter */ +.gooey-filter-layer { + position: absolute; + inset: 0; + pointer-events: none; + filter: url(#gooey-medium); + border-radius: 20px; + overflow: hidden; +} + +.gooey-filter-blob { + position: absolute; + height: calc(100% - 12px); + top: 6px; + background: var(--accent); + border-radius: 16px; + transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* Rating scale selection gooey blobs */ +.rating-bar-container { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px; + background: var(--bg); + box-shadow: var(--neu-shadow-inset); + border-radius: 24px; +} + +.rating-filter-layer { + position: absolute; + inset: 0; + pointer-events: none; + filter: url(#gooey-rating); + border-radius: 24px; +} + +.rating-blob { + position: absolute; + width: 44px; + height: 44px; + background: var(--accent); + border-radius: 50%; + transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* Photo upload dropzone states */ +.upload-dropzone { + border: 2px dashed var(--text-secondary); + border-radius: 20px; + transition: all 0.3s ease; + background: var(--bg); + box-shadow: var(--neu-shadow-subtle); +} + +.upload-dropzone:hover { + border-color: var(--accent); + box-shadow: var(--neu-shadow-raised); +} + +.upload-dropzone.active-drop { + border-color: var(--accent); + background: rgba(var(--accent-glow), 0.05); + box-shadow: var(--neu-shadow-inset); +} + +/* Liquid scale up for images */ +@keyframes liquid-pop { + 0% { transform: scale(0.6) rotate(-5deg); filter: blur(4px); opacity: 0; } + 60% { transform: scale(1.05) rotate(2deg); filter: blur(0); opacity: 0.9; } + 100% { transform: scale(1) rotate(0deg); opacity: 1; } +} + +.animate-liquid-pop { + animation: liquid-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; +} + +/* Quick tag anims */ +@keyframes tag-appear { + 0% { transform: translateY(8px); opacity: 0; } + 100% { transform: translateY(0); opacity: 1; } +} + +.tag-anim { + animation: tag-appear 0.3s cubic-bezier(0.4, 0, 0.2, 1) both; +} + +/* Pulse keyframes for active upload */ +@keyframes pulse-border { + 0%, 100% { border-color: var(--text-secondary); } + 50% { border-color: var(--accent); } +} + +.animate-pulse-border { + animation: pulse-border 2s infinite ease-in-out; +} + +/* Success save animation */ +@keyframes checkmark-pop { + 0% { transform: scale(0.5); opacity: 0; } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); opacity: 1; } +} + +.animate-success-pop { + animation: checkmark-pop 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; +} + +/* Bottom sheet slide-up animation for mobile */ +@keyframes sheet-slide-up { + 0% { + transform: translateY(100%); + } + 100% { + transform: translateY(0); + } +} + +.animate-sheet-enter { + animation: sheet-slide-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* GSAP Initial States to avoid FOUC */ +.gsap-reveal { + opacity: 0; + will-change: transform, opacity; +} + +/* ================================================================== */ +/* OFFLINE SYNC UI */ +/* ================================================================== */ + +/* Fixed offline banner at the top */ +.offline-banner { + background: linear-gradient(135deg, #b45309, #d97706); + color: #fef3c7; + backdrop-filter: blur(12px); + box-shadow: 0 4px 20px rgba(180, 83, 9, 0.35), 0 1px 3px rgba(0, 0, 0, 0.2); + animation: offline-slide-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both; + letter-spacing: 0.02em; +} + +@keyframes offline-slide-in { + 0% { transform: translateY(-100%); opacity: 0; } + 100% { transform: translateY(0); opacity: 1; } +} + +/* Sync toast (success notification when reconnected) */ +.sync-toast { + background: linear-gradient(135deg, #065f46, #047857); + color: #d1fae5; + box-shadow: 0 8px 32px rgba(4, 120, 87, 0.4), var(--neu-shadow-raised); + animation: toast-pop-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; +} + +@keyframes toast-pop-in { + 0% { transform: translate(-50%, -20px) scale(0.85); opacity: 0; } + 100% { transform: translate(-50%, 0) scale(1); opacity: 1; } +} + +/* Pending sync badge on carousel cards */ +.pending-sync-badge { + position: absolute; + top: 12px; + left: 12px; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: linear-gradient(135deg, #b45309, #d97706); + color: #fef3c7; + box-shadow: 0 2px 8px rgba(180, 83, 9, 0.4); + animation: badge-pulse 2s ease-in-out infinite; +} + +@keyframes badge-pulse { + 0%, 100% { box-shadow: 0 2px 8px rgba(180, 83, 9, 0.4); transform: scale(1); } + 50% { box-shadow: 0 2px 16px rgba(180, 83, 9, 0.7); transform: scale(1.08); } +} + +/* Pending sync pill in activity feed list */ +.pending-sync-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + background: linear-gradient(135deg, #b45309, #d97706); + color: #fef3c7; + box-shadow: 0 1px 4px rgba(180, 83, 9, 0.3); + white-space: nowrap; +} + +/* ================================================================== */ +/* UI PERFORMANCE & VIRTUALIZATION OPTIMIZATIONS */ +/* ================================================================== */ + +/* Virtualization performance helper for list feed items */ +.feed-list-item { + content-visibility: auto; + contain-intrinsic-size: auto 82px; +} + +/* Performance boundary for continuous gooey SVG blobs to avoid main-thread paint invalidations */ +.blob-container { + contain: strict; + will-change: transform; +} + +.animate-blob-1, .animate-blob-2, .animate-blob-3 { + will-change: transform; +} + +/* Disable high-cost effects if user requests reduced motion */ +@media (prefers-reduced-motion: reduce) { + .animate-blob-1, + .animate-blob-2, + .animate-blob-3, + .animate-float, + .animate-page-enter, + .animate-card-enter, + .animate-sheet-enter, + .animate-modal-enter, + .animate-backdrop, + .offline-banner, + .sync-toast, + .pending-sync-badge { + animation: none !important; + transition: none !important; + } +} + +/* ================================================================== */ +/* LEAFLET MAP INTEGRATION & POPUP THEME */ +/* ================================================================== */ + +/* Container resets and theme bindings */ +.leaflet-container { + background: var(--bg) !important; + font-family: var(--font-work-sans), sans-serif !important; +} + +/* Custom popups matching Neumorphism colors and styles */ +.leaflet-popup-content-wrapper { + background: var(--surface) !important; + color: var(--text-primary) !important; + border-radius: 1.25rem !important; /* 20px matching rounded-3xl/2xl cards */ + border: 1px solid rgba(107, 93, 79, 0.15) !important; /* using fallback border */ + box-shadow: var(--neu-shadow-raised) !important; + padding: 0 !important; +} + +.leaflet-popup-tip { + background: var(--surface) !important; + border: 1px solid rgba(107, 93, 79, 0.15) !important; +} + +.leaflet-popup-content { + margin: 12px !important; + font-family: var(--font-work-sans), sans-serif !important; + font-size: 0.875rem !important; + line-height: 1.25 !important; +} + +.leaflet-popup-close-button { + color: var(--text-secondary) !important; + padding: 8px !important; + top: 4px !important; + right: 4px !important; + font-size: 14px !important; +} + +.leaflet-popup-close-button:hover { + background: transparent !important; + color: var(--accent) !important; +} + +/* Zoom controls customization */ +.leaflet-bar { + border: none !important; + box-shadow: var(--neu-shadow-button) !important; + border-radius: 12px !important; + overflow: hidden; + background: var(--surface) !important; +} + +.leaflet-bar a { + background-color: var(--surface) !important; + color: var(--text-primary) !important; + border-bottom: 1px solid rgba(107, 93, 79, 0.15) !important; + transition: all 0.2s ease; +} + +.leaflet-bar a:hover { + color: var(--accent) !important; + background-color: var(--bg) !important; +} + +.leaflet-bar a:first-child { + border-top-left-radius: 12px !important; + border-top-right-radius: 12px !important; +} + +.leaflet-bar a:last-child { + border-bottom-left-radius: 12px !important; + border-bottom-right-radius: 12px !important; + border-bottom: none !important; +} + +/* Dark mode map tile filter */ +.dark .leaflet-tile-container { + filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%); +} + + + diff --git a/app/layout.tsx b/app/layout.tsx index be45053..28a0743 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,25 +1,7 @@ import type { Metadata } from "next"; -import { Fraunces, Work_Sans, Space_Mono } from "next/font/google"; +import { AuthProvider } from "@/lib/auth"; import "./globals.css"; -const fraunces = Fraunces({ - variable: "--font-fraunces", - subsets: ["latin"], - weight: ["600"], -}); - -const workSans = Work_Sans({ - variable: "--font-work-sans", - subsets: ["latin"], - weight: ["400", "500"], -}); - -const spaceMono = Space_Mono({ - variable: "--font-space-mono", - subsets: ["latin"], - weight: ["400", "700"], -}); - export const metadata: Metadata = { title: "KeepCheck", description: @@ -38,10 +20,15 @@ export default function RootLayout({ return ( + {/* Google Fonts Link */} + + + + {/* Theme persistence helper to avoid flash of light/dark mode */}