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/README.md b/README.md index 678e5b3..080812e 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,114 @@ # KeepCheck -An offline-first PWA for logging and rating food and drink spots. Built with Next.js, Dexie.js, and Tailwind CSS. Your data stays on your device—no cloud, no accounts, fully private. +A Progressive Web App for logging and rating food and drink spots. Built offline-first with local IndexedDB storage and optional real-time cloud sync via Firebase. Works on desktop and mobile, with or without an internet connection. +## Features -## Project Structure - - keepcheck/ - app/ - Main UI, search/sort logic, components - public/ - Static assets (logo, icons) - manifest.ts - PWA configuration - sw.js - Service worker for offline mode - package.json - Dependencies and scripts - - -## Requirements - -- Node.js (v18 or later recommended) -- A modern web browser (Chrome, Edge, Safari, etc.) -- npm, yarn, pnpm, or bun - - -## Setup Guide for New Cloners - -### Step 1 - Clone or Download - - git clone https://github.com/yourusername/keepcheck.git - cd keepcheck - - -### Step 2 - Install Dependencies - -Run this in your terminal: - - npm install - - -### Step 3 - Start Development - - npm run dev - -Open http://localhost:3000 in your browser. +- **Offline-First Storage** — All data is written instantly to IndexedDB (via Dexie.js) so the app works without a network. A service worker caches the app shell for full offline reliability. +- **Cloud Sync** — Sign in with Google to sync spots and images across devices in real-time through Firestore. Offline edits queue automatically and sync when connectivity returns. +- **Interactive Maps** — View logged spots on a Leaflet/OpenStreetMap view. Pick a location by tapping the map or use device GPS to auto-detect coordinates. +- **Image Support** — Attach photos to spots. Images are compressed locally for thumbnails and cached in IndexedDB at full resolution. +- **Search, Filter, and Sort** — Text search with debounce, category filters (Cafe, Restaurant), sort by date or rating, and toggle between list and grid views. +- **Toast Notifications** — All user actions (logging, deleting, signing in/out, syncing, errors) surface as brief toast notifications instead of alerts or silent failures. +- **PWA Installable** — Can be installed to the home screen on mobile or as a desktop app through the browser. +## Tech Stack -## Running KeepCheck +| Technology | Role | +| --- | --- | +| Next.js 16 (App Router) | Framework and routing | +| Dexie.js | IndexedDB wrapper for local storage | +| Firebase Auth + Firestore | Authentication and cloud database | +| NextAuth.js v5 | OAuth session management | +| Tailwind CSS v4 | Styling and theming | +| GSAP | Animations | +| Leaflet.js | Map rendering | +| Serwist | Service worker and PWA support | -Just open the app in your browser. Since it is a PWA, you can "Install" it via your browser menu to use it as a standalone app on your desktop or phone home screen. +## Project Structure +``` +app/ + api/ Server routes (auth callback, token exchange) + login/ Sign-in page + map/ Full-page map view + offline/ Offline fallback page + db.ts IndexedDB schema + globals.css Theme tokens and animations + layout.tsx Root layout (providers) + page.tsx Main application page + +components/ + AuthGuard.tsx Auth-protected wrapper + LocationPickerMap.tsx Map pin selector + LocationPickerModal.tsx Modal for picking a location + SpotDetailModal.tsx Full-res photo viewer + SpotsMap.tsx Map with spot markers + +lib/ + auth.tsx Auth context (NextAuth + Firebase bridge) + firebase.ts Firebase client setup + firestore.ts Firestore read/write helpers + offlineSync.ts Offline queue and reconnection sync + toast.tsx Global toast notification provider + +utils/ + image.ts Image compression and resizing +``` + +## Setup + +### Prerequisites + +- Node.js 18 or later +- npm (or yarn / pnpm / bun) + +### 1. Clone and install + +```bash +git clone https://github.com/yourusername/keepcheck.git +cd keepcheck +npm install +``` + +### 2. Configure environment + +Create a `.env.local` file in the project root: + +```env +# NextAuth +AUTH_SECRET= +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= + +# Firebase (client) +NEXT_PUBLIC_FIREBASE_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= + +# Firebase Admin (server-side token exchange) +FIREBASE_CLIENT_EMAIL= +FIREBASE_PRIVATE_KEY= +``` + +### 3. Run + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). ## How It Works - 1. Your data is managed by Dexie.js (IndexedDB wrapper) stored locally in your browser. - 2. Next.js App Router handles the UI state reactively using useLiveQuery. - 3. Tailwind CSS handles the theme toggling via class-based dark mode. - 4. Service Worker (sw.js) caches the app shell for offline reliability. - - -## Tech Stack - - Next.js - React framework for the App Router - Dexie.js - Local IndexedDB storage for offline-first data - Tailwind CSS - Utility-first styling - Lucide React - Clean, consistent iconography - Vercel - Deployment platform - +1. When you log a spot, the data is written to IndexedDB immediately. If you are signed in and online, it also writes to Firestore in the background. +2. If you are offline, spots are flagged as pending. When the app detects connectivity, it runs a sync sweep to push pending records to Firestore. +3. Firestore snapshot listeners keep your local database in sync with cloud changes from other devices. +4. Images are stored as blobs in IndexedDB and as compressed base64 strings in Firestore. -## Roadmap & Future Scope +## License -- Direct JSON restoration (Import functionality) -- Advanced categorization (Food Truck, Bakery, Bar, etc.) -- Data visualization (Charts for your ratings over time) +MIT 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..3335455 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,48 +2,348 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* ================================================================== */ +/* NEUMORPHISM DESIGN SYSTEM */ +/* ================================================================== */ + :root { - --bg: #EFE7D8; - --surface: #FBF6EC; - --text-primary: #2C2018; - --text-secondary: #6B5D4F; - --accent: #A6572B; - --category-cafe: #8B6F9E; - --category-restaurant: #3E6E77; + /* 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 — cream / caramel / sage */ + --bg: #F8F1E7; + --surface: #FCF8F3; + --text-primary: #4E342E; + --text-secondary: #7D6558; + --accent: #D4A373; + --accent-glow: #F4C542; + --category-cafe: #A3B18A; + --category-restaurant: #4E342E; + --gold: #F4C542; + + /* Neumorphic shadow pairs (light) */ + --neu-light: #ffffff; + --neu-dark: #e2d6c7; + --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; - --text-primary: #F0E6D6; + --bg: #1C1410; + --surface: #261D17; + --text-primary: #F8F1E7; --text-secondary: #B8A890; - --accent: #D68A4C; - --category-cafe: #7C5A3E; - --category-restaurant: #3E5A5E; + --accent: #D4A373; + --accent-glow: #F4C542; + --category-cafe: #A3B18A; + --category-restaurant: #A4897A; + --gold: #F4C542; + + /* Neumorphic shadow pairs (dark) */ + --neu-light: #261e18; + --neu-dark: #120e0b; + --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); + --color-gold: var(--gold); + + --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); + background-image: + radial-gradient(at 0% 0%, rgba(212, 163, 115, 0.12) 0px, transparent 50%), + radial-gradient(at 100% 0%, rgba(163, 177, 138, 0.15) 0px, transparent 50%), + radial-gradient(at 50% 100%, rgba(244, 197, 66, 0.08) 0px, transparent 50%); + background-attachment: fixed; 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(--surface); + box-shadow: var(--neu-shadow-raised); + border: 1px solid rgba(125, 101, 88, 0.12); + border-radius: 20px; +} + +.neu-inset { + background: var(--bg); + box-shadow: var(--neu-shadow-inset); + border-radius: 16px; +} + +.neu-button { + background: var(--surface); + box-shadow: var(--neu-shadow-button); + border: 1px solid rgba(125, 101, 88, 0.1); + 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(--surface); + box-shadow: var(--neu-shadow-inset); + border: 1px solid rgba(125, 101, 88, 0.15); + 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, border-color 0.2s ease; +} + +.neu-input:focus { + border-color: var(--accent); + 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(--surface); + box-shadow: var(--neu-shadow-raised); + border: 1px solid rgba(125, 101, 88, 0.12); + 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; } -/* Hide scrollbar on horizontally-scrollable pill rows */ +/* 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 +351,403 @@ 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(--gold); + 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, #4E342E, #7D6558); + color: #F8F1E7; + backdrop-filter: blur(12px); + box-shadow: 0 4px 20px rgba(78, 52, 46, 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, #A3B18A, #8F9E75); + color: #1C1410; + font-weight: 600; + box-shadow: 0 8px 32px rgba(163, 177, 138, 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, #D4A373, #F4C542); + color: #4E342E; + box-shadow: 0 2px 8px rgba(212, 163, 115, 0.4); + animation: badge-pulse 2s ease-in-out infinite; +} + +@keyframes badge-pulse { + 0%, 100% { box-shadow: 0 2px 8px rgba(212, 163, 115, 0.4); transform: scale(1); } + 50% { box-shadow: 0 2px 16px rgba(244, 197, 66, 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, #D4A373, #F4C542); + color: #4E342E; + box-shadow: 0 1px 4px rgba(212, 163, 115, 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..18b6a64 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,25 +1,8 @@ import type { Metadata } from "next"; -import { Fraunces, Work_Sans, Space_Mono } from "next/font/google"; +import { AuthProvider } from "@/lib/auth"; +import { ToastProvider } from "@/lib/toast"; 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 +21,15 @@ export default function RootLayout({ return ( + {/* Google Fonts Link */} + + + + {/* Theme persistence helper to avoid flash of light/dark mode */}