Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"css.customData": [],
"css.lint.unknownAtRules": "ignore"
"css.lint.unknownAtRules": "ignore",
"typescript.tsdk": "node_modules/typescript/lib"
}
159 changes: 101 additions & 58 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const runtime = "nodejs";

import { handlers } from "@/auth";

export const { GET, POST } = handlers;
81 changes: 81 additions & 0 deletions app/api/firebase-token/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
80 changes: 72 additions & 8 deletions app/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -69,14 +74,73 @@ class KeepCheckDB extends Dexie {
.table<FoodSpotLog>("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<FoodSpotLog>("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<FoodSpotLog>("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<FoodSpotLog>("spots")
.toCollection()
.modify((spot) => {
if (spot.latitude === undefined) {
spot.latitude = undefined;
}
if (spot.longitude === undefined) {
spot.longitude = undefined;
}
})
);
}
}

Expand Down
Loading