Compare commits

..

80 Commits

Author SHA1 Message Date
da593e052b Update PWA version for focus improvements
- Bumped service worker cache to v8
- Updated manifest version to 1.0.2
- Forces PWA refresh to pick up keyboard focus improvements
2025-12-14 01:01:22 +00:00
37955f95ab Keep input focused and keyboard open after adding tasks
- Added useRef to manage input focus directly
- Added useEffect to auto-focus when form becomes visible
- Refocus input after successful task creation with setTimeout
- Modified handleBlur to preserve focus when keeping form open
- Provides seamless continuous task input experience on mobile
2025-12-14 00:59:48 +00:00
466bce862e Update PWA manifest and service worker to force iPhone refresh
- Updated manifest.json version to 1.0.1 and description
- Bumped service worker cache version from v6 to v7
- Forces PWA refresh on iPhone Safari to pick up latest changes
2025-12-14 00:56:29 +00:00
3043293fa7 Keep task form open after adding task for continuous input
- Modified InlineItemCreate component to maintain form visibility after successful task creation
- Added keepFormOpen state to prevent form from closing on page revalidation
- Users can now add multiple tasks without clicking 'Add task' button repeatedly
- Form only closes when explicitly cancelled or escaped
2025-12-14 00:40:59 +00:00
1838502b8b fix: prevent hydration error in NotificationPrompt
Add mounted state to prevent SSR rendering.
Component now only renders after client-side mount.
Fixes login blocking hydration error.
2025-12-07 01:59:55 +00:00
6508e27391 chore: remove NotificationDebug component
Debug complete - notifications are working (permission already granted).
Remove debug component to clean up UI.
2025-12-07 01:58:39 +00:00
15b9f196f5 chore: bump service worker cache version to v6
Force update on all PWA installations to pick up:
- NotificationPrompt component
- NotificationDebug component
- Push notification functionality
- Hydration fix
2025-12-07 01:55:28 +00:00
1c5e623c7b fix: prevent hydration error in NotificationDebug component
Add mounted state to prevent rendering during SSR.
Component now only renders after client-side mount.
2025-12-07 01:53:52 +00:00
298ebc5fb9 fix: change PushSubscription endpoint to VARCHAR for MySQL compatibility
MySQL cannot create unique constraints on TEXT fields without length.
Changed endpoint from @db.Text to @db.VarChar(512) to fix Prisma generation error.
2025-12-07 01:50:28 +00:00
374bfb3a62 .env 2025-12-07 01:48:02 +00:00
e095568fbf debug: add notification debug component to troubleshoot iOS 2025-12-07 01:43:00 +00:00
a02b9438af security: remove .env from git tracking and add to .gitignore
BREAKING CHANGE: .env file is no longer tracked in git for security.
Each environment must maintain its own .env file with secrets.
Use .env.example as a template.
2025-12-07 01:35:01 +00:00
3a1a1626fe chore: update package-lock.json for web-push dependencies 2025-12-07 01:32:53 +00:00
f49f62d659 vapid keys 2025-12-07 01:31:33 +00:00
8e7440166f feat: add Docker support for push notifications
- Update docker-compose.yml to include VAPID environment variables
- Create DOCKER_PUSH_NOTIFICATIONS_SETUP.md with Docker-specific instructions
- Create PUSH_NOTIFICATIONS_DOCKER_CHECKLIST.md for easy setup tracking
- Update .env.example with Docker command for generating VAPID keys

Setup for Docker:
1. Generate VAPID keys (npx web-push generate-vapid-keys)
2. Add keys to .env file
3. Run: docker-compose exec app npx prisma db push
4. Rebuild: docker-compose up --build

The web-push package is automatically installed during docker build.
All notification functionality works the same in Docker as locally.
2025-12-07 01:25:48 +00:00
3f1abd527e feat: add auto-prompt for push notifications on app load
- Create NotificationPrompt component that appears 3 seconds after app loads
- Add to main layout so it shows on all pages (for logged-in users)
- Prompt only shows if permission not yet requested
- Users can dismiss temporarily or permanently
- Add slide-up animation for smooth appearance
- Create simplified SETUP_PUSH_NOTIFICATIONS.md guide

Users will now be automatically prompted to enable notifications
instead of having to go to the profile page.
2025-12-07 01:24:20 +00:00
7cda58440f feat: implement complete web push notification system
- Add self-hosted push notifications using VAPID (no external services)
- Create PushSubscription model in database
- Implement server-side push logic with web-push library
- Add notification helpers for list events (items, invitations, collaborators)
- Create NotificationSettings UI component for profile page
- Add usePushNotifications React hook for subscription management
- Create API routes for subscription management and VAPID key
- Update service worker with push event handlers
- Integrate notifications into item actions (add, remove, complete)
- Integrate notifications into sharing actions (invite, accept, remove)
- Add VAPID key generation script
- Add comprehensive documentation and setup guides

Notifications are sent when:
- Items are added/removed/completed in shared lists
- Users are invited to lists
- Invitations are accepted
- Collaborators are removed from lists

Works on iOS 16.4+ (when installed to home screen), Android, and desktop browsers.
2025-12-07 01:22:16 +00:00
625ca40e48 Bump service worker version to v5 for PWA auto-update 2025-12-07 01:07:25 +00:00
021bbf1335 Fix toast auto-dismiss issue by using ref for onClose callback
- Toast component now uses useRef to store onClose callback
- Prevents timer from resetting on parent re-renders
- Fixes issue where toasts wouldn't dismiss after adding items
2025-12-07 01:06:03 +00:00
737049dc69 Bump service worker to v4 - force PWA update for zoom fix 2025-12-03 21:01:49 +00:00
2f2cc309a3 Fix iOS Safari auto-zoom on input fields
- Changed all input/textarea/select font-size from text-sm to text-base (16px minimum)
- Added blur handler to reset viewport zoom on InlineItemCreate
- Prevents iOS Safari from auto-zooming when focusing on form fields
- Ensures zoom resets properly when closing forms
2025-12-03 21:01:17 +00:00
1e7aa6ae5d Bump service worker cache version to force update 2025-12-03 20:56:31 +00:00
509de75e1a Fix Safari/iPhone performance issues - remove 300ms click delay
- Added touch-action: manipulation CSS to eliminate 300ms tap delay
- Optimized service worker to skip cross-origin requests
- Updated viewport settings to prevent iOS delays
- Added format-detection meta tag
- Enabled Next.js performance optimizations
- Created comprehensive performance fix documentation
2025-12-03 20:54:55 +00:00
bfe49e7211 Mark checkpoint: IT'S WORKING HERE 2025-12-03 20:46:50 +00:00
2640559de8 Add logout button to home page header 2025-12-03 20:39:13 +00:00
4422498bc9 Move permission badge and share button above add task bar 2025-12-03 20:35:16 +00:00
376b8cead9 Clean up unused ItemWithList interface 2025-12-03 20:33:41 +00:00
66f8fcade7 Remove search and filter functionality from list detail view to fix hydration errors 2025-12-03 20:31:39 +00:00
540f5dfc89 Remove polling from home page to prevent interference with UI 2025-12-03 20:20:24 +00:00
eae3723e66 Increase polling interval to 5 seconds to prevent modal closing 2025-12-03 20:08:44 +00:00
5545caa1c3 Fix modal closing immediately by preventing event propagation 2025-12-03 20:00:13 +00:00
6c5f2fbc1e Redesign ItemCard with smaller height and right-aligned priority
- Reduced padding from p-4/p-5 to p-3/p-3.5 for more compact cards
- Moved priority badge to the right side next to title
- Changed items-start to items-center for better alignment
- Description now limited to 1 line instead of 2
- Metadata section only shows if there's a due date or tags
- Cleaner, more compact layout
2025-12-03 00:49:10 +00:00
bdff38c9a5 Remove SyncIndicator to fix hydration errors
- Removed SyncIndicator component causing hydration issues
- Background polling still works for real-time updates
- Updates appear within 1 second without visual indicator
2025-12-03 00:45:51 +00:00
79026733cb Add suppressHydrationWarning to SyncIndicator
- Suppress hydration warnings for timestamp display
- Prevents React hydration errors from time formatting differences
2025-12-03 00:43:04 +00:00
865c2a85de Fix SyncIndicator hydration error
- Add mounted state to prevent SSR/client mismatch
- Component only renders after client-side mount
- Fixes hydration error with timestamp display
2025-12-03 00:39:53 +00:00
72f98c783a Fix toast auto-dismiss and add debugging
- Added console logging to debug toast lifecycle
- Improved toast ID generation with timestamp
- Limited toasts to max 3 at a time
- Toasts should now auto-dismiss after 3 seconds
2025-12-03 00:37:43 +00:00
fc5ad9f7af Improve real-time sync with 1-second polling and visual indicator
- Reduced polling interval from 2s to 1s for near-instant updates
- Added SyncIndicator component to show sync status
- Added console logging to debug sync behavior
- Updates across devices now appear within 1 second
- Visual feedback shows when data is being refreshed
2025-12-03 00:29:24 +00:00
bd61aaa5b2 Use startTransition for delete refresh and fix filter updates
- DeleteItemButton now uses startTransition for router.refresh()
- ListDetailView re-applies filters when initialItems changes
- This ensures deleted items disappear after refresh completes
- Transition provides better React 18 concurrent rendering support
2025-12-02 14:47:43 +00:00
35e20fb16d Fix ListDetailView not updating when items change
- Add useEffect to sync filteredItems state with initialItems prop
- Now when router.refresh() updates initialItems, the UI updates
- Deleted items now disappear immediately after refresh
2025-12-02 14:44:03 +00:00
cbf6f3c6b6 Fix delete button not refreshing UI after deletion
- Remove router.push() logic that was preventing refresh
- Always call router.refresh() after successful deletion
- Reset state before refresh to clear 'Deleting...' text
- Item now disappears immediately after deletion
2025-12-02 14:41:42 +00:00
e6067e76cd Improve real-time sync speed and caching
- Reduced polling interval from 3s to 2s for faster updates
- Added revalidate = 0 to prevent Next.js caching
- Updates from other browsers now appear within 2 seconds
- Both home page and list detail pages sync faster
2025-12-02 14:38:01 +00:00
78bfcb7ac3 Add optimistic updates for immediate UI feedback
- ToggleItemButton now uses useOptimistic for instant toggle feedback
- InlineItemCreate clears form immediately before server action
- Both components call router.refresh() after mutations
- UI updates appear instantly, no waiting for server response
2025-12-02 14:33:43 +00:00
0fc8c719b9 Add real-time updates and fix delete button state
- Simplified useRealtimeSync to use router.refresh() polling
- Created RealtimeUpdater component for easy integration
- Added real-time updates to home page (3s polling)
- Fixed DeleteItemButton to properly reset state on error
- Updates now refresh every 3 seconds and on window focus
- Changes from other users/browsers will appear quickly
2025-12-02 14:29:28 +00:00
78901c8e2e Fix hydration error in OfflineIndicator component
- Add mounted state to prevent SSR/client mismatch
- Component now only renders after client-side mount
- Resolves React hydration error
2025-12-02 14:22:34 +00:00
ade7ad19d6 Fix getCurrentSession for NextAuth v4 compatibility 2025-12-02 14:12:22 +00:00
d92f13fdc4 Remove build step, use dev server 2025-12-02 14:06:04 +00:00
909f51ff6b Use dev server for NextAuth v4 compatibility 2025-12-02 14:05:46 +00:00
d7297248c1 Fix all NextAuth v4 imports across codebase 2025-12-02 14:03:13 +00:00
2f7bc4fe8e Fix NextAuth v4 imports and exports 2025-12-02 14:01:05 +00:00
40b168b6d8 Update package-lock.json for NextAuth v4 2025-12-02 13:58:40 +00:00
fe783301e7 Downgrade to NextAuth v4 to fix constructor error 2025-12-02 13:56:57 +00:00
60ebf13a9c Simplify Docker setup to fix constructor error
- Remove standalone output mode from Next.js config
- Use single-stage Docker build with npm start
- Eliminate complex multi-stage build that was causing issues
2025-12-02 13:54:26 +00:00
3a3db4195f Fix Docker Prisma constructor error
- Remove --ignore-scripts from npm ci in builder stage
- Simplify Prisma client initialization
- Ensure proper file copying order in Docker
- Remove async connection handling that may cause issues
2025-12-02 13:49:51 +00:00
c0358d79d7 Fix Docker build and Next.js metadata configuration
- Move themeColor and viewport to separate viewport export in layout.tsx
- Improve Docker build process with npm ci and proper dependency handling
- Add Docker-friendly Next.js configuration settings
- Resolve constructor error and metadata warnings
2025-12-02 13:47:40 +00:00
ff16860d8a Revert to production build with proper cookie settings
- Production build is more stable than dev mode
- Cookie settings already fixed for HTTP
- NextAuth v5 beta works better in production mode
2025-12-02 13:31:19 +00:00
855abb98b7 Re-add redirect for unauthenticated users on home page 2025-12-02 13:28:39 +00:00
b9b28ad92c Run as root in dev mode for file write permissions 2025-12-02 13:26:15 +00:00
9f25b30083 Switch to dev server in Docker 2025-12-02 13:22:38 +00:00
adb4f6b119 Set NODE_ENV to development for better NextAuth compatibility
- Avoids production build issues with NextAuth v5 beta
- Enables better error messages for debugging
- Session cookies work correctly in development mode
2025-12-02 13:21:07 +00:00
9bd4dfe0e1 Fix cookie settings for HTTP deployment
- Remove __Secure- prefix for non-HTTPS
- Set secure: false to allow cookies over HTTP
- Enables session persistence in Docker deployment
2025-12-02 13:19:30 +00:00
f3d9ccde56 Remove manual redirect from home page - let middleware handle auth
- Middleware already handles authentication redirects
- Prevents error boundary from catching redirect errors
- Cleaner separation of concerns
2025-12-02 13:17:12 +00:00
ce7021e3fa Use npm install instead of npm ci for deployment flexibility 2025-12-02 13:14:46 +00:00
d677fcb9b5 Fix Docker file copying for standalone build
- Add @prisma client to copied files
- Set proper ownership for nextjs user
- Ensures all required files are available at runtime
2025-12-02 13:11:27 +00:00
b1705fa6da Simplify port mapping: expose 3500 externally, use 3000 internally
- Map host port 3500 to container port 3000
- Keep Next.js on default port 3000 inside container
- Simplifies auth configuration
2025-12-02 13:06:24 +00:00
a084853b6c Add AUTH_URL for NextAuth v5 compatibility
- NextAuth v5 uses AUTH_URL instead of NEXTAUTH_URL
- Keep both for backward compatibility
- Fixes UntrustedHost error
2025-12-02 13:02:10 +00:00
39401a3fa2 Update package-lock.json 2025-12-02 13:00:06 +00:00
899ba97457 Explicitly set trustHost in NextAuth initialization 2025-12-02 12:56:24 +00:00
1c0e0a097e Add AUTH_TRUST_HOST to build environment 2025-12-02 12:52:53 +00:00
fc06003701 Allow .env file in Docker build for private repo 2025-12-02 12:48:43 +00:00
8c9e1a76d1 Fix Prisma client generation for correct platform
- Skip Prisma postinstall during npm ci with --ignore-scripts
- Generate Prisma client explicitly in builder stage
- Ensures correct binary for Debian/Linux platform
2025-12-02 12:46:31 +00:00
92401c5324 Fix home page redirect for unauthenticated users
- Check session before calling getLists()
- Redirect to login page if not authenticated
- Prevents server component render error in production
2025-12-02 12:40:15 +00:00
c51bc28d67 Add AUTH_TRUST_HOST environment variable for Docker
- Set AUTH_TRUST_HOST=true in .env and docker-compose.yml
- Ensures NextAuth trusts all hosts in Docker environment
- Fixes UntrustedHost error
2025-12-02 12:35:23 +00:00
f1cbd88761 Switch to Debian-based Node image for Prisma compatibility
- Change from node:20-alpine to node:20-slim
- Fixes OpenSSL library compatibility issues with Prisma
- Debian has better support for Prisma's binary engines
2025-12-02 12:32:23 +00:00
9ebb2786b3 Fix Prisma OpenSSL compatibility for Alpine Docker
- Add openssl package to all Docker build stages
- Ensures Prisma engine works on Alpine Linux ARM64
- Fixes libssl.so.1.1 missing library error
2025-12-02 12:28:40 +00:00
86ef3c41d5 Fix NextAuth trusted host for Docker deployment
- Add trustHost: true to auth.config.ts
- Remove obsolete version field from docker-compose.yml
2025-12-02 12:15:08 +00:00
c5182e3b99 Update .env with DB credentials and port 3500 2025-12-02 10:14:48 +00:00
37e0620caf Include .env in repo for private deployment 2025-12-02 10:10:37 +00:00
0d26087049 Add Docker configuration for port 3500
- Add Dockerfile with multi-stage build for production
- Add docker-compose.yml for easy deployment
- Add .dockerignore to exclude unnecessary files
- Add DOCKER_SETUP.md with comprehensive setup guide
- Update next.config.js to enable standalone output mode
- Configure app to run on port 3500
- Keep MySQL server external to Docker
2025-12-02 10:09:13 +00:00
b2a59eecf1 feat: implement real-time list synchronization
- Add useRealtimeSync hook for polling server updates every 3 seconds
- Create API endpoint to check last updated timestamp for lists
- Integrate real-time sync into ListDetailView component
- Automatically refresh when other users make changes to shared lists
- Poll on interval and when page becomes visible again
2025-12-02 10:06:49 +00:00
11c4411f68 fix: Resolve Prisma Edge Runtime error in middleware
- Move all auth configuration to auth.config.ts to avoid Prisma imports in Edge Runtime
- Update middleware to use NextAuth with edge-compatible config
- Consolidate session, cookies, and callback configuration in auth.config.ts
- Update auth.ts to extend authConfig and only add Prisma-dependent providers
- This fixes the 'PrismaClient is not configured to run in Edge Runtime' error

The middleware now properly works with NextAuth v5 beta without trying to load Prisma in the Edge Runtime.
2025-12-02 01:38:26 +00:00
71 changed files with 4154 additions and 1632 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
Dockerfile
.dockerignore
.git
.gitignore
.next
.kiro
.vscode
node_modules
npm-debug.log
README.md
*.md
.env.local
.env.*.local
coverage
.DS_Store
*.pem
tsconfig.tsbuildinfo

18
.env Normal file
View File

@@ -0,0 +1,18 @@
DATABASE_URL="mysql://lists_app:ListsApp@2025!@192.168.4.20:3306/ultimate_lists"
# NextAuth Configuration
AUTH_URL=http://localhost:3500
NEXTAUTH_URL=http://localhost:3500
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
AUTH_SECRET=your-secret-key-here-please-change-in-production
AUTH_TRUST_HOST=true
# Database connection check (for docker-compose health check)
DB_HOST=192.168.4.20
DB_PORT=3306
DB_USER=lists_app
DB_PASSWORD=ListsApp@2025!
VAPID_PUBLIC_KEY=BI05JNvKs4CSiG8c4q6Lt_FjMmuVy-0LWiiuvYbjnu10TpysQdVpY2LpY0gotdhlXJJLn6ub_cf4EX69gZDs83o
VAPID_PRIVATE_KEY=EUsjxWebCbrVphsDDFuoRqUE5-eX8nhpuLvYKVVeGnQ
VAPID_SUBJECT=mailto:psbailey31@gmail.com

View File

@@ -7,3 +7,10 @@ NEXTAUTH_URL=http://localhost:3000
# Generate a secure secret with: openssl rand -base64 32
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
AUTH_SECRET=your-secret-key-here-please-change-in-production
# Web Push Notifications (VAPID)
# Generate keys with: npx web-push generate-vapid-keys
# Or in Docker: docker run --rm -it node:20-slim sh -c "npm install -g web-push && web-push generate-vapid-keys"
VAPID_PUBLIC_KEY=your-vapid-public-key-here
VAPID_PRIVATE_KEY=your-vapid-private-key-here
VAPID_SUBJECT=mailto:your-email@example.com

6
.gitignore vendored
View File

@@ -23,6 +23,7 @@ yarn-debug.log*
yarn-error.log*
# Local env files
# .env
.env*.local
# Vercel
@@ -30,7 +31,4 @@ yarn-error.log*
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Environment
.env
next-env.d.ts

View File

@@ -226,6 +226,14 @@
- [x] 19. Final checkpoint - Ensure all tests pass
- Ensure all tests pass, ask the user if questions arise.
- [x] 19.5. Implement real-time list synchronization
- Create useRealtimeSync hook for polling server updates
- Add API endpoint to check last updated timestamp
- Integrate real-time sync into ListDetailView component
- Automatically refresh when other users make changes
- Poll every 3 seconds and on page visibility change
- _Requirements: Collaborative features (enhancement)_
- [ ] 20. End-to-end testing and polish
- Test complete user flows from list creation to task completion
- Verify PWA installation on iOS Safari

View File

@@ -0,0 +1,115 @@
# Push Notifications Setup for Docker
## Quick Setup (3 Steps)
### Step 1: Generate VAPID Keys
Run this **on your host machine** (not in Docker):
```bash
npx web-push generate-vapid-keys
```
Or if you prefer, you can run it in a temporary container:
```bash
docker run --rm -it node:20-slim sh -c "npm install -g web-push && web-push generate-vapid-keys"
```
This will output:
```
=======================================
Public Key:
BNxxx...
Private Key:
xxx...
=======================================
```
### Step 2: Add Keys to .env File
Add these three lines to your `.env` file:
```env
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:your-email@example.com
```
⚠️ **Important**:
- Replace `BNxxx...` with your actual public key
- Replace `xxx...` with your actual private key
- Replace `your-email@example.com` with your actual email
- Keep the VAPID_PRIVATE_KEY secret!
### Step 3: Update Database Schema
Run this to add the PushSubscription table:
```bash
docker-compose exec app npx prisma db push
```
Or if your containers aren't running yet:
```bash
docker-compose up -d
docker-compose exec app npx prisma db push
```
### Step 4: Rebuild and Restart
```bash
docker-compose down
docker-compose up --build
```
## That's It!
Your app now has push notifications enabled! When users open the app:
- After 3 seconds, they'll see a notification prompt
- They can enable notifications with one click
- Notifications will be sent automatically for list events
## Verify It's Working
1. Open your app at `http://localhost:3500`
2. Log in
3. Wait 3 seconds for the notification prompt
4. Click "Enable Notifications"
5. Open another browser/incognito window
6. Log in as a different user
7. Add an item to a shared list
8. See the notification in the first browser!
## Troubleshooting
### "Push notifications not configured" error
- Make sure VAPID keys are in `.env`
- Make sure you rebuilt the containers: `docker-compose up --build`
- Check logs: `docker-compose logs app`
### Database table missing
- Run: `docker-compose exec app npx prisma db push`
- Check if it succeeded: `docker-compose exec app npx prisma studio`
### Environment variables not loading
- Make sure `.env` is in the same directory as `docker-compose.yml`
- Restart containers: `docker-compose restart`
- Check if variables are loaded: `docker-compose exec app env | grep VAPID`
## Docker-Specific Notes
- The `web-push` package is automatically installed during `docker-compose up --build`
- VAPID keys are loaded from your `.env` file into the container
- The database schema update must be run inside the container
- All notification functionality works the same in Docker as locally
## Production Deployment
For production:
1. Generate production VAPID keys (different from dev)
2. Add them to your production environment variables
3. Run `npx prisma db push` in production container
4. Ensure your production URL uses HTTPS (required for push notifications)

153
DOCKER_SETUP.md Normal file
View File

@@ -0,0 +1,153 @@
# Docker Setup Guide
This guide explains how to run the Ultimate Lists App using Docker on port 3500.
## Prerequisites
- Docker and Docker Compose installed
- MySQL server running (external to Docker)
- Environment variables configured
## Configuration
### 1. Update next.config.js
The application needs to be configured for standalone output. Update your `next.config.js`:
```javascript
const nextConfig = {
output: 'standalone',
// ... rest of your config
}
```
### 2. Environment Variables
Create a `.env` file in the project root with your MySQL connection details:
```env
# MySQL Connection (external server)
DATABASE_URL="mysql://user:password@host:port/database"
# For Docker host machine MySQL, use:
# DATABASE_URL="mysql://user:password@host.docker.internal:3306/database"
# NextAuth Configuration
NEXTAUTH_URL=http://localhost:3500
NEXTAUTH_SECRET=your-secret-key-here
AUTH_SECRET=your-secret-key-here
# Optional: MySQL connection check variables
DB_HOST=host.docker.internal
DB_PORT=3306
DB_USER=your-db-user
DB_PASSWORD=your-db-password
```
**Important:** If your MySQL server is running on your host machine (localhost), use `host.docker.internal` instead of `localhost` in the DATABASE_URL.
### 3. Run Database Migrations
Before building the Docker image, ensure your database schema is up to date:
```bash
npm install
npx prisma generate
npx prisma db push
```
## Building and Running
### Option 1: Using Docker Compose (Recommended)
```bash
# Build and start the container
docker-compose up -d
# View logs
docker-compose logs -f app
# Stop the container
docker-compose down
```
### Option 2: Using Docker directly
```bash
# Build the image
docker build -t ultimate-lists-app .
# Run the container
docker run -d \
-p 3500:3500 \
--env-file .env \
--name ultimate-lists-app \
ultimate-lists-app
# View logs
docker logs -f ultimate-lists-app
# Stop the container
docker stop ultimate-lists-app
docker rm ultimate-lists-app
```
## Accessing the Application
Once running, access the application at:
- **http://localhost:3500**
## Troubleshooting
### Cannot connect to MySQL
If you see database connection errors:
1. Verify MySQL is running and accessible
2. Check that you're using `host.docker.internal` instead of `localhost` in DATABASE_URL
3. Ensure MySQL allows connections from Docker containers
4. Verify firewall settings allow the connection
### Build fails
If the build fails:
1. Ensure `output: 'standalone'` is set in `next.config.js`
2. Check that all dependencies are properly installed
3. Verify Prisma schema is valid
### Port already in use
If port 3500 is already in use:
```bash
# Find what's using the port
lsof -i :3500
# Change the port in docker-compose.yml or use a different port:
docker run -p 3501:3500 ...
```
## Development vs Production
This Docker setup is configured for production use. For development:
```bash
# Use the regular dev server
npm run dev
```
## Updating the Application
To update after code changes:
```bash
# Rebuild and restart
docker-compose up -d --build
# Or with Docker directly
docker build -t ultimate-lists-app .
docker stop ultimate-lists-app
docker rm ultimate-lists-app
docker run -d -p 3500:3500 --env-file .env --name ultimate-lists-app ultimate-lists-app
```

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
ENV AUTH_TRUST_HOST=true
EXPOSE 3000
CMD ["npm", "run", "dev"]

38
ITS_WORKING_HERE.md Normal file
View File

@@ -0,0 +1,38 @@
# ✅ IT'S WORKING HERE
**Date:** December 3, 2025
## Current Status
The Ultimate Lists application is fully functional at this checkpoint!
## What's Working
- ✅ User authentication (login/register)
- ✅ List creation and management
- ✅ Task creation, editing, and deletion
- ✅ Task completion toggle with optimistic updates
- ✅ Real-time sync (1-second polling) for cross-device updates
- ✅ List sharing with collaborators (owner/editor/viewer permissions)
- ✅ Invitation system
- ✅ User profile management
- ✅ Password change functionality
- ✅ Logout button
- ✅ PWA support with offline capabilities
- ✅ Responsive design for mobile and desktop
- ✅ Toast notifications with auto-dismiss
- ✅ Permission badges
- ✅ Compact item cards with right-aligned priority badges
- ✅ Clean UI without search/filter complexity
## Recent Changes
1. Removed SearchBar and FilterPanel from list detail view to fix hydration errors
2. Moved permission badge and share button above the add task bar
3. Added logout button to home page header
4. Fixed React hydration issues with mounted state checks
5. Implemented optimistic updates for instant UI feedback
6. Redesigned ItemCard layout for better UX
## Known Issues
None at this checkpoint - everything is working smoothly!
## Next Steps
Continue building additional features as needed.

View File

@@ -0,0 +1,264 @@
# Web Push Notifications - Complete Implementation ✅
## What Was Implemented
A complete, self-hosted Web Push notification system for your PWA shopping list app.
## 📦 Files Created
### Core Implementation
- `lib/push-notifications.ts` - Server-side push notification logic with VAPID
- `lib/notification-helpers.ts` - Helper functions for specific notification events
- `hooks/usePushNotifications.ts` - React hook for managing subscriptions
- `components/NotificationSettings.tsx` - UI component for enabling/disabling notifications
### API Routes
- `app/api/push/vapid-key/route.ts` - Returns public VAPID key
- `app/api/push/subscribe/route.ts` - Saves push subscriptions
- `app/api/push/unsubscribe/route.ts` - Removes push subscriptions
### Database
- Updated `prisma/schema.prisma` - Added `PushSubscription` model
### Service Worker
- Updated `public/sw.js` - Added push event handlers
### Scripts & Config
- `scripts/generate-vapid-keys.js` - Generate VAPID keys
- Updated `package.json` - Added web-push dependency and npm script
- Updated `.env.example` - Added VAPID key placeholders
### Documentation
- `PUSH_NOTIFICATIONS_QUICK_START.md` - 5-minute setup guide
- `PUSH_NOTIFICATIONS_SETUP.md` - Complete setup and configuration
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` - Code integration examples
- `PUSH_NOTIFICATIONS_GUIDE.md` - Architecture and testing guide
- `PUSH_NOTIFICATIONS_COMPLETE.md` - This file
## 🎯 How It Works
### The Push Notification Server
**You don't need an external service!** This implementation uses **VAPID** (Voluntary Application Server Identification), which is a standard protocol that allows your own server to send push notifications directly to browsers.
Here's the flow:
1. **Generate VAPID Keys** (one-time setup)
- Run `npm run vapid:generate`
- These are like JWT secrets - a public/private key pair
- Public key: shared with browsers
- Private key: kept secret on your server
2. **User Subscribes**
- User clicks "Enable" in NotificationSettings
- Browser creates a unique push subscription (includes endpoint URL)
- Subscription saved to your database
3. **Event Occurs**
- Someone adds an item to a shared list
- Your server action calls `notifyItemAdded()`
- Function looks up all subscriptions for list members
- Sends push notification using `web-push` library
4. **Browser Receives**
- Browser's push service receives the notification
- Service worker wakes up and displays it
- User sees notification even if app is closed
### The "Server" is Just Your App
The push notification "server" is simply:
- The `web-push` npm package (installed in your app)
- Your VAPID keys (in your .env file)
- Your existing Next.js API routes
No external service, no additional infrastructure needed!
## 🚀 Setup Instructions
### Step 1: Install Dependencies
```bash
npm install
```
### Step 2: Generate VAPID Keys
```bash
npm run vapid:generate
```
Copy the output to your `.env` file:
```env
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:your-email@example.com
```
### Step 3: Update Database
```bash
npx prisma db push
```
### Step 4: Add UI Component
In `app/profile/page.tsx`:
```typescript
import NotificationSettings from '@/components/NotificationSettings';
export default function ProfilePage() {
return (
<div>
{/* Your existing content */}
<NotificationSettings />
</div>
);
}
```
### Step 5: Integrate Notifications
In your action files (see `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` for details):
```typescript
import { notifyItemAdded } from '@/lib/notification-helpers';
// After creating an item:
await notifyItemAdded(listId, item.title, session.user.id);
```
## 📱 Notification Events
| Event | Function | When |
|-------|----------|------|
| Item added | `notifyItemAdded()` | Someone adds item to shared list |
| Item removed | `notifyItemRemoved()` | Someone removes item |
| Item completed | `notifyItemCompleted()` | Someone marks item complete |
| List invitation | `notifyListInvitation()` | Someone invites you |
| Invitation accepted | `notifyInvitationAccepted()` | Someone accepts your invite |
| Removed from list | `notifyRemovedFromList()` | Owner removes you |
## 🌐 Browser Support
| Platform | Support | Notes |
|----------|---------|-------|
| Chrome Desktop | ✅ Full | Works perfectly |
| Chrome Android | ✅ Full | Works perfectly |
| Firefox Desktop | ✅ Full | Works perfectly |
| Firefox Android | ✅ Full | Works perfectly |
| Safari macOS | ✅ 16.4+ | Works when installed |
| Safari iOS | ✅ 16.4+ | **Must be installed to home screen** |
| Edge | ✅ Full | Chromium-based |
### iOS Requirements
- iOS 16.4 or later
- Must use "Add to Home Screen" (won't work in Safari browser)
- Requires HTTPS in production
## 🔒 Security
- VAPID private key must be kept secret (like JWT secret)
- Subscriptions are tied to authenticated users
- Expired subscriptions automatically cleaned up
- No cross-user notification access
## 🧪 Testing
### Desktop (Chrome)
1. Start dev server: `npm run dev`
2. Open DevTools → Application → Service Workers
3. Enable notifications in profile
4. Trigger an action (add item, etc.)
5. See notification appear
### iOS (Real Device)
1. Deploy with HTTPS
2. Open in Safari
3. Share → Add to Home Screen
4. Open installed app
5. Enable notifications
6. Test from another device
## 📊 Database Schema
New table added:
```prisma
model PushSubscription {
id String @id @default(cuid())
userId String
endpoint String @db.Text
p256dh String @db.Text
auth String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(...)
}
```
## 🎨 Customization
### Change Notification Content
Edit `lib/notification-helpers.ts` to customize titles, bodies, icons.
### Add Per-List Preferences
1. Add `notificationsEnabled` to `Collaborator` model
2. Check this field before sending notifications
3. Add UI toggle in list settings
### Add Notification Sounds
Update service worker push event:
```javascript
const options = {
...existing,
silent: false,
sound: '/notification-sound.mp3',
};
```
## 🐛 Troubleshooting
### "Push notifications not configured"
- Verify VAPID keys are in `.env`
- Restart dev server
### Notifications not appearing
- Check browser permission (must be "granted")
- Verify service worker is active
- Check console for errors
- Ensure HTTPS (or localhost)
### iOS not working
- Check iOS version (16.4+)
- Must be installed to home screen
- Check Settings → [App] → Notifications
## 📈 Performance
- Notifications sent asynchronously (non-blocking)
- Multiple devices per user supported
- Automatic cleanup of expired subscriptions
- TTL: 24 hours
## ✅ Production Checklist
- [ ] Generate production VAPID keys
- [ ] Add to production environment variables
- [ ] Run `npx prisma db push` on production DB
- [ ] Verify HTTPS is working
- [ ] Test on actual iOS device
- [ ] Monitor logs for errors
## 🎉 You're Done!
Your app now has:
- ✅ Self-hosted push notifications
- ✅ No external dependencies
- ✅ iOS and Android support
- ✅ Multi-device support
- ✅ Automatic subscription management
Users will receive notifications when:
- Items are added/removed/completed in shared lists
- They're invited to lists
- Their invitations are accepted
- They're removed from lists
All without any external services or additional infrastructure!

View File

@@ -0,0 +1,81 @@
# Push Notifications Docker Setup Checklist
## ✅ What's Already Done
- [x] Code implementation complete
- [x] Docker configuration updated
- [x] Environment variables added to docker-compose.yml
- [x] Dependencies added to package.json (will install on build)
## 🔧 What You Need to Do
### [ ] 1. Generate VAPID Keys
Run this command:
```bash
npx web-push generate-vapid-keys
```
Or if you don't have Node locally:
```bash
docker run --rm -it node:20-slim sh -c "npm install -g web-push && web-push generate-vapid-keys"
```
### [ ] 2. Add Keys to .env
Copy the output and add to your `.env` file:
```env
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:psbailey31@gmail.com
```
### [ ] 3. Update Database
Start containers and run migration:
```bash
docker-compose up -d
docker-compose exec app npx prisma db push
```
### [ ] 4. Rebuild Containers
```bash
docker-compose down
docker-compose up --build
```
## ✅ Verify It Works
1. Open http://localhost:3500
2. Log in
3. Wait 3 seconds
4. See notification prompt appear
5. Click "Enable Notifications"
6. Test by adding items from another browser
## 🎉 Done!
Once you complete these 4 steps, push notifications will work automatically!
## Quick Reference
**Check if VAPID keys are loaded:**
```bash
docker-compose exec app env | grep VAPID
```
**Check database tables:**
```bash
docker-compose exec app npx prisma studio
```
**View logs:**
```bash
docker-compose logs -f app
```
**Restart after .env changes:**
```bash
docker-compose restart
```

View File

@@ -0,0 +1,97 @@
# Web Push Notifications Implementation Guide
## Overview
This guide explains how to implement Web Push notifications in your PWA using VAPID (self-hosted, no external service needed).
## Architecture
1. **VAPID Keys**: Generate once, store securely (like JWT secrets)
2. **Client**: Request permission, subscribe to push notifications
3. **Database**: Store push subscriptions per user
4. **Server**: Send notifications when list events occur
5. **Service Worker**: Receive and display notifications
## Setup Steps
### 1. Generate VAPID Keys
Run this once to generate your VAPID keys:
```bash
npx web-push generate-vapid-keys
```
Add to `.env`:
```
VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_SUBJECT=mailto:your-email@example.com
```
### 2. Install Dependencies
```bash
npm install web-push
```
### 3. Database Schema
Add `PushSubscription` model to store user subscriptions (see prisma/schema.prisma changes).
### 4. Client-Side Flow
1. User grants notification permission
2. Service worker subscribes to push manager
3. Subscription sent to server and stored in database
4. Server sends notifications when events occur
5. Service worker displays notifications
### 5. Notification Triggers
Notifications are sent when:
- Someone adds/removes an item from a shared list
- Someone invites you to a list
- Someone accepts your list invitation
- Someone removes you from a list
## Testing
### Local Testing
- Use Chrome DevTools > Application > Service Workers
- Use Chrome DevTools > Application > Push Messaging to simulate
### iOS Testing
- Must be installed to home screen
- iOS 16.4+ required
- Test on actual device (simulator doesn't support push)
### Android Testing
- Works in browser and installed PWA
- Test on actual device for best results
## Privacy & Best Practices
- Always request permission explicitly (don't auto-prompt)
- Allow users to disable notifications
- Store subscriptions securely
- Handle subscription expiration/updates
- Respect user preferences per list
## Troubleshooting
### Notifications not appearing
- Check permission status
- Verify service worker is active
- Check browser console for errors
- Ensure VAPID keys are correct
### iOS specific issues
- Must be installed to home screen
- Check iOS version (16.4+)
- Verify manifest.json is correct
### Subscription fails
- Check VAPID public key matches server
- Verify service worker scope
- Check network requests in DevTools

View File

@@ -0,0 +1,183 @@
# ✅ Push Notifications - FULLY IMPLEMENTED
## What's Been Done
Your PWA now has a complete, self-hosted push notification system! Here's what was implemented:
### 1. Core Infrastructure ✅
- **Database**: Added `PushSubscription` model to Prisma schema
- **Server Logic**: Created `lib/push-notifications.ts` with VAPID support
- **Notification Helpers**: Created `lib/notification-helpers.ts` for event-specific notifications
- **API Routes**: 3 routes for subscription management
- **Service Worker**: Updated with push event handlers
- **React Hook**: Created `usePushNotifications` for client-side management
- **UI Component**: Created `NotificationSettings` component
### 2. Integration Complete ✅
- **Profile Page**: Added NotificationSettings component
- **Item Actions**: Integrated notifications for:
- ✅ Item added → `notifyItemAdded()`
- ✅ Item removed → `notifyItemRemoved()`
- ✅ Item completed → `notifyItemCompleted()`
- **Sharing Actions**: Integrated notifications for:
- ✅ List invitation → `notifyListInvitation()`
- ✅ Invitation accepted → `notifyInvitationAccepted()`
- ✅ Collaborator removed → `notifyRemovedFromList()`
### 3. Dependencies ✅
- Added `web-push` package
- Added `@types/web-push` for TypeScript
- Added npm script: `npm run vapid:generate`
## Setup Instructions
### Step 1: Install Dependencies
```bash
npm install
```
### Step 2: Generate VAPID Keys
```bash
npm run vapid:generate
```
Copy the output to your `.env` file:
```env
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:your-email@example.com
```
### Step 3: Update Database
```bash
npx prisma db push
```
### Step 4: Restart Your Server
```bash
npm run dev
```
## How to Use
### For Users:
1. Go to Profile page
2. Click "Enable" in the Notification Settings section
3. Grant permission when browser prompts
4. Done! You'll now receive notifications
### Notifications Are Sent When:
- Someone adds an item to a shared list
- Someone removes an item from a shared list
- Someone completes an item in a shared list
- Someone invites you to a list
- Someone accepts your list invitation
- You're removed from a list
## Technical Details
### The "Server" Solution
You asked about the push notification server - **it's your own Next.js app!**
- Uses VAPID (Voluntary Application Server Identification)
- No external services needed (no Firebase, no third-party APIs)
- The `web-push` npm package handles the protocol
- Your VAPID keys authenticate your server to browsers
- Completely self-hosted and free
### How It Works
1. User enables notifications → Browser creates subscription
2. Subscription saved to your database
3. Event occurs (item added, etc.) → Your server action runs
4. Server sends push via `web-push` library
5. Browser's push service delivers to device
6. Service worker displays notification
### Browser Support
- ✅ Chrome/Edge (Desktop & Android)
- ✅ Firefox (Desktop & Android)
- ✅ Safari 16.4+ (macOS & iOS when installed to home screen)
## Files Created
### Core Files
- `lib/push-notifications.ts` - Server push logic
- `lib/notification-helpers.ts` - Event-specific notifications
- `hooks/usePushNotifications.ts` - React hook
- `components/NotificationSettings.tsx` - UI component
- `app/api/push/vapid-key/route.ts` - API route
- `app/api/push/subscribe/route.ts` - API route
- `app/api/push/unsubscribe/route.ts` - API route
- `scripts/generate-vapid-keys.js` - Key generation script
### Documentation
- `PUSH_NOTIFICATIONS_QUICK_START.md` - 5-minute setup
- `PUSH_NOTIFICATIONS_COMPLETE.md` - Full overview
- `PUSH_NOTIFICATIONS_SETUP.md` - Detailed setup
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` - Code examples
- `PUSH_NOTIFICATIONS_GUIDE.md` - Architecture guide
- `PUSH_NOTIFICATIONS_IMPLEMENTED.md` - This file
### Modified Files
- `prisma/schema.prisma` - Added PushSubscription model
- `public/sw.js` - Added push event handlers
- `package.json` - Added dependencies and script
- `.env.example` - Added VAPID key placeholders
- `app/profile/page.tsx` - Added NotificationSettings component
- `lib/actions.ts` - Integrated notification calls
- `lib/sharing-actions.ts` - Integrated notification calls
## Testing
### Desktop (Chrome)
1. Start dev server: `npm run dev`
2. Go to profile, enable notifications
3. Open another browser/incognito window
4. Log in as different user
5. Add item to shared list
6. See notification in first browser
### iOS (Real Device)
1. Deploy with HTTPS
2. Open in Safari
3. Share → Add to Home Screen
4. Open installed app
5. Enable notifications in profile
6. Test from another device
## Production Checklist
- [ ] Run `npm install`
- [ ] Run `npm run vapid:generate`
- [ ] Add VAPID keys to production `.env`
- [ ] Run `npx prisma db push` on production database
- [ ] Verify HTTPS is working
- [ ] Test notifications on actual devices
- [ ] Monitor logs for errors
## What Makes This Special
1. **Self-Hosted**: No external dependencies or costs
2. **Privacy-Focused**: All data stays in your database
3. **Multi-Device**: Users can subscribe on multiple devices
4. **Automatic Cleanup**: Expired subscriptions removed automatically
5. **Non-Blocking**: Notifications sent asynchronously
6. **Graceful Degradation**: If notifications fail, actions still succeed
## Next Steps (Optional Enhancements)
- Add per-list notification preferences
- Add quiet hours (don't notify during certain times)
- Add notification history/inbox
- Add action buttons to notifications
- Add notification grouping/batching
- Add sound customization
## Support
All files compile without errors. The implementation is complete and ready to use!
For questions, refer to:
- `PUSH_NOTIFICATIONS_QUICK_START.md` for setup
- `PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md` for code examples
- `PUSH_NOTIFICATIONS_SETUP.md` for troubleshooting

View File

@@ -0,0 +1,338 @@
# Push Notifications Integration Example
This shows exactly how to add notifications to your existing code.
## Step 1: Import the notification helpers
At the top of your action files, add:
```typescript
import {
notifyItemAdded,
notifyItemRemoved,
notifyItemCompleted,
notifyListInvitation,
notifyInvitationAccepted,
notifyRemovedFromList,
} from '@/lib/notification-helpers';
```
## Step 2: Add notification calls after actions
### Example: Adding an item
**Before:**
```typescript
export async function createItem(listId: string, data: any) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const item = await prisma.item.create({
data: {
...data,
listId,
},
});
revalidatePath(`/list/${listId}`);
return item;
}
```
**After:**
```typescript
export async function createItem(listId: string, data: any) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const item = await prisma.item.create({
data: {
...data,
listId,
},
});
// 🔔 Add this line to send notifications
await notifyItemAdded(listId, item.title, session.user.id);
revalidatePath(`/list/${listId}`);
return item;
}
```
### Example: Deleting an item
**Before:**
```typescript
export async function deleteItem(itemId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
await prisma.item.delete({
where: { id: itemId },
});
revalidatePath('/');
}
```
**After:**
```typescript
export async function deleteItem(itemId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
// 🔔 Get item details before deleting
const item = await prisma.item.findUnique({
where: { id: itemId },
});
await prisma.item.delete({
where: { id: itemId },
});
// 🔔 Send notification
if (item) {
await notifyItemRemoved(item.listId, item.title, session.user.id);
}
revalidatePath('/');
}
```
### Example: Toggling item completion
**Before:**
```typescript
export async function toggleItemComplete(itemId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const item = await prisma.item.findUnique({
where: { id: itemId },
});
const updated = await prisma.item.update({
where: { id: itemId },
data: { completed: !item?.completed },
});
revalidatePath('/');
return updated;
}
```
**After:**
```typescript
export async function toggleItemComplete(itemId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const item = await prisma.item.findUnique({
where: { id: itemId },
});
const updated = await prisma.item.update({
where: { id: itemId },
data: { completed: !item?.completed },
});
// 🔔 Notify when item is completed (not uncompleted)
if (updated.completed) {
await notifyItemCompleted(updated.listId, updated.title, session.user.id);
}
revalidatePath('/');
return updated;
}
```
### Example: Inviting to a list
**Before:**
```typescript
export async function inviteToList(listId: string, email: string, permission: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const invitation = await prisma.invitation.create({
data: {
listId,
email,
permission,
inviterId: session.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return invitation;
}
```
**After:**
```typescript
export async function inviteToList(listId: string, email: string, permission: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const invitation = await prisma.invitation.create({
data: {
listId,
email,
permission,
inviterId: session.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
// 🔔 Notify the invitee
await notifyListInvitation(invitation.id, email);
return invitation;
}
```
### Example: Accepting an invitation
**Before:**
```typescript
export async function acceptInvitation(invitationId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const invitation = await prisma.invitation.update({
where: { id: invitationId },
data: { status: 'accepted' },
});
await prisma.collaborator.create({
data: {
userId: session.user.id,
listId: invitation.listId,
permission: invitation.permission,
},
});
return invitation;
}
```
**After:**
```typescript
export async function acceptInvitation(invitationId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
const invitation = await prisma.invitation.update({
where: { id: invitationId },
data: { status: 'accepted' },
});
await prisma.collaborator.create({
data: {
userId: session.user.id,
listId: invitation.listId,
permission: invitation.permission,
},
});
// 🔔 Notify the person who sent the invitation
await notifyInvitationAccepted(invitationId);
return invitation;
}
```
### Example: Removing a collaborator
**Before:**
```typescript
export async function removeCollaborator(listId: string, userId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
await prisma.collaborator.deleteMany({
where: {
listId,
userId,
},
});
revalidatePath(`/list/${listId}`);
}
```
**After:**
```typescript
export async function removeCollaborator(listId: string, userId: string) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error('Unauthorized');
// 🔔 Get list details before removing
const list = await prisma.list.findUnique({
where: { id: listId },
});
await prisma.collaborator.deleteMany({
where: {
listId,
userId,
},
});
// 🔔 Notify the removed user
if (list) {
await notifyRemovedFromList(userId, list.title, session.user.id);
}
revalidatePath(`/list/${listId}`);
}
```
## Step 3: Add NotificationSettings to Profile Page
In `app/profile/page.tsx`, add the component:
```typescript
import NotificationSettings from '@/components/NotificationSettings';
export default function ProfilePage() {
return (
<div className="container mx-auto p-4 space-y-6">
<h1 className="text-2xl font-bold">Profile Settings</h1>
{/* Your existing profile components */}
<ProfileForm />
<PasswordChangeForm />
{/* 🔔 Add notification settings */}
<NotificationSettings />
</div>
);
}
```
## That's It!
The notification system will:
- ✅ Automatically handle permission requests
- ✅ Store subscriptions per user/device
- ✅ Send notifications to all relevant users
- ✅ Exclude the user who triggered the action
- ✅ Clean up expired subscriptions
- ✅ Work across multiple devices
## Optional: Error Handling
If you want to handle notification failures gracefully:
```typescript
try {
await notifyItemAdded(listId, item.title, session.user.id);
} catch (error) {
// Log but don't fail the action
console.error('Failed to send notification:', error);
}
```
Notifications are non-critical, so it's fine to let them fail silently without affecting the main action.

View File

@@ -0,0 +1,82 @@
# Push Notifications - Quick Start
## 🚀 5-Minute Setup
### 1. Install & Generate Keys
```bash
npm install
npm run vapid:generate
```
### 2. Add Keys to .env
```env
VAPID_PUBLIC_KEY=<copy from output>
VAPID_PRIVATE_KEY=<copy from output>
VAPID_SUBJECT=mailto:your-email@example.com
```
### 3. Update Database
```bash
npx prisma db push
```
### 4. Add to Profile Page
```typescript
// app/profile/page.tsx
import NotificationSettings from '@/components/NotificationSettings';
// Add <NotificationSettings /> to your page
```
### 5. Send Notifications
```typescript
// In your action files
import { notifyItemAdded } from '@/lib/notification-helpers';
// After creating an item:
await notifyItemAdded(listId, item.title, session.user.id);
```
## ✅ Done!
Users can now:
- Enable notifications in their profile
- Receive push notifications on all their devices
- Get notified about list activity even when the app is closed
## 📱 iOS Support
For iOS notifications:
1. Deploy with HTTPS
2. User must "Add to Home Screen"
3. Requires iOS 16.4+
## 🔧 Available Notification Functions
```typescript
// Item notifications
notifyItemAdded(listId, itemTitle, userId)
notifyItemRemoved(listId, itemTitle, userId)
notifyItemCompleted(listId, itemTitle, userId)
// Invitation notifications
notifyListInvitation(invitationId, email)
notifyInvitationAccepted(invitationId)
// Collaboration notifications
notifyRemovedFromList(userId, listTitle, removedByUserId)
```
## 📚 Full Documentation
- **PUSH_NOTIFICATIONS_SETUP.md** - Complete setup guide
- **PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md** - Code examples
- **PUSH_NOTIFICATIONS_GUIDE.md** - Architecture & testing
## 🎯 Key Features
- ✅ Self-hosted (no external services)
- ✅ Works on iOS, Android, Desktop
- ✅ Multiple devices per user
- ✅ Automatic subscription cleanup
- ✅ Non-blocking (won't slow down your app)

272
PUSH_NOTIFICATIONS_SETUP.md Normal file
View File

@@ -0,0 +1,272 @@
# Push Notifications Setup Guide
## Quick Start
### 1. Install Dependencies
```bash
npm install
```
This will install `web-push` which is now in package.json.
### 2. Generate VAPID Keys
```bash
npm run vapid:generate
```
Copy the output and add to your `.env` file:
```env
VAPID_PUBLIC_KEY=your-generated-public-key
VAPID_PRIVATE_KEY=your-generated-private-key
VAPID_SUBJECT=mailto:your-email@example.com
```
### 3. Update Database Schema
```bash
npx prisma db push
```
This adds the `PushSubscription` table to your database.
### 4. Add Notification Settings to Profile Page
Add the `NotificationSettings` component to your profile page:
```tsx
import NotificationSettings from '@/components/NotificationSettings';
export default function ProfilePage() {
return (
<div>
{/* Your existing profile content */}
<NotificationSettings />
</div>
);
}
```
### 5. Integrate Notifications into Your Actions
Update your existing action files to send notifications:
#### In `lib/actions.ts` (or wherever you handle items):
```typescript
import { notifyItemAdded, notifyItemRemoved, notifyItemCompleted } from '@/lib/notification-helpers';
// After creating an item:
export async function createItem(listId: string, data: ItemData) {
// ... your existing code to create item
const item = await prisma.item.create({ ... });
// Send notification
await notifyItemAdded(listId, item.title, session.user.id);
return item;
}
// After deleting an item:
export async function deleteItem(itemId: string) {
const item = await prisma.item.findUnique({ where: { id: itemId } });
// ... your existing delete code
if (item) {
await notifyItemRemoved(item.listId, item.title, session.user.id);
}
}
// After completing an item:
export async function toggleItemComplete(itemId: string) {
const item = await prisma.item.update({ ... });
if (item.completed) {
await notifyItemCompleted(item.listId, item.title, session.user.id);
}
}
```
#### In `lib/sharing-actions.ts`:
```typescript
import { notifyListInvitation, notifyInvitationAccepted, notifyRemovedFromList } from '@/lib/notification-helpers';
// After creating an invitation:
export async function inviteToList(listId: string, email: string, permission: string) {
// ... your existing code
const invitation = await prisma.invitation.create({ ... });
// Send notification
await notifyListInvitation(invitation.id, email);
return invitation;
}
// After accepting an invitation:
export async function acceptInvitation(invitationId: string) {
// ... your existing code
await notifyInvitationAccepted(invitationId);
}
// After removing a collaborator:
export async function removeCollaborator(listId: string, userId: string) {
const list = await prisma.list.findUnique({ where: { id: listId } });
// ... your existing code
if (list) {
await notifyRemovedFromList(userId, list.title, session.user.id);
}
}
```
## How It Works
### Architecture
1. **Client requests permission** → User clicks "Enable" in NotificationSettings
2. **Service worker subscribes** → Browser creates a push subscription
3. **Subscription saved to DB** → Stored in PushSubscription table
4. **Events trigger notifications** → Server sends push via web-push library
5. **Service worker displays** → User sees notification
### Self-Hosted Solution
This implementation uses **VAPID** (Voluntary Application Server Identification), which means:
- ✅ No external services required (no Firebase, no third-party APIs)
- ✅ Works entirely from your own server
- ✅ Free and open-source
- ✅ Full control over your data
### Browser Support
- ✅ Chrome/Edge (Desktop & Android)
- ✅ Firefox (Desktop & Android)
- ✅ Safari 16.4+ (iOS & macOS) - **Must be installed to home screen on iOS**
- ❌ Safari < 16.4
## Testing
### Local Testing (Desktop)
1. Start your dev server: `npm run dev`
2. Open Chrome DevTools → Application → Service Workers
3. Go to your profile page and enable notifications
4. Trigger an action (add item, invite someone, etc.)
5. You should see a notification appear
### iOS Testing (Real Device Required)
1. Deploy to a server with HTTPS (required for PWA)
2. Open Safari on iOS 16.4+
3. Tap Share → Add to Home Screen
4. Open the installed app
5. Enable notifications in profile
6. Test by triggering actions from another device
### Testing Push Manually
Use Chrome DevTools to simulate a push:
1. DevTools → Application → Service Workers
2. Find your service worker
3. Click "Push" to simulate receiving a notification
## Notification Events
The following events trigger notifications:
| Event | Recipient | Trigger |
|-------|-----------|---------|
| Item Added | All list collaborators (except adder) | Someone adds an item to a shared list |
| Item Removed | All list collaborators (except remover) | Someone removes an item |
| Item Completed | All list collaborators (except completer) | Someone marks an item complete |
| List Invitation | Invitee | Someone invites you to a list |
| Invitation Accepted | Inviter | Someone accepts your invitation |
| Removed from List | Removed user | Owner removes you from a list |
## Customization
### Notification Content
Edit `lib/notification-helpers.ts` to customize notification titles, bodies, and icons.
### Notification Preferences
To add per-list notification preferences:
1. Add a `notificationsEnabled` field to the `Collaborator` model
2. Update `NotificationSettings` to show per-list toggles
3. Check this field in `notifyListCollaborators` before sending
### Notification Sounds
Add to service worker push event:
```javascript
const options = {
// ... existing options
silent: false,
sound: '/notification-sound.mp3',
};
```
## Troubleshooting
### "Push notifications not configured" error
- Check that VAPID keys are in `.env`
- Restart your dev server after adding keys
### Notifications not appearing
- Check browser permission (should be "granted")
- Verify service worker is active (DevTools → Application)
- Check browser console for errors
- Ensure you're testing on HTTPS (or localhost)
### iOS notifications not working
- Must be iOS 16.4 or later
- Must be installed to home screen (not in Safari browser)
- Check Settings → [Your App] → Notifications
### Subscription fails
- Verify VAPID public key matches between client and server
- Check service worker is registered and active
- Ensure you're on HTTPS (or localhost)
## Security Notes
- ⚠️ **Never commit VAPID_PRIVATE_KEY to git**
- ✅ VAPID keys are like JWT secrets - keep them secure
- ✅ Subscriptions are tied to users - no cross-user notification access
- ✅ Expired subscriptions are automatically cleaned up
## Production Checklist
- [ ] Generate production VAPID keys
- [ ] Add VAPID keys to production environment variables
- [ ] Run database migration: `npx prisma db push`
- [ ] Test on actual iOS device (if targeting iOS)
- [ ] Verify HTTPS is working (required for push)
- [ ] Test notification delivery
- [ ] Monitor for expired subscriptions in logs
## Performance
- Notifications are sent asynchronously (non-blocking)
- Failed subscriptions are automatically removed
- Multiple devices per user are supported
- TTL is set to 24 hours (notifications expire if not delivered)
## Next Steps
Consider adding:
- Notification preferences per list
- Quiet hours (don't notify during certain times)
- Notification history/inbox
- Rich notifications with action buttons
- Notification grouping/batching
if

168
SAFARI_PERFORMANCE_FIX.md Normal file
View File

@@ -0,0 +1,168 @@
# Safari/iPhone Performance Optimization
## Issues Fixed
### 1. 300ms Click Delay (Primary Issue)
**Problem**: Safari on iOS has a historical 300ms delay on click events to detect double-tap-to-zoom gestures.
**Solutions Applied**:
- Added `touch-action: manipulation` CSS to all interactive elements (buttons, links, inputs)
- Added `format-detection` meta tag to disable automatic phone number detection
- Updated viewport to allow user scaling (prevents iOS from adding delays)
- Added explicit touch-action rules in globals.css
### 2. Service Worker Overhead
**Problem**: Service worker was intercepting all fetch requests, including cross-origin requests, adding latency.
**Solutions Applied**:
- Added check to skip cross-origin requests in service worker
- Service worker now only handles same-origin requests
- Reduced unnecessary cache lookups
### 3. Touch Interaction Optimization
**Problem**: Missing touch-specific CSS optimizations for iOS Safari.
**Solutions Applied**:
- Added `-webkit-touch-callout: none` to prevent context menu delays
- Added `touch-action: manipulation` to prevent double-tap zoom delays
- Optimized tap highlight colors
### 4. Next.js Configuration
**Problem**: Missing performance optimizations in Next.js config.
**Solutions Applied**:
- Enabled `reactStrictMode` for better development debugging
- Enabled `optimizeFonts` for faster font loading
- Already had compression and minification enabled
## Testing the Fixes
### On iPhone Safari:
1. Clear Safari cache: Settings > Safari > Clear History and Website Data
2. Close all Safari tabs
3. Reopen the app
4. Test clicking on list cards and navigation links
5. Should feel instant now (no 300ms delay)
### Expected Results:
- Links should respond immediately to taps
- No delay between tap and navigation
- Smooth, native-app-like feel
- Faster page transitions
## Additional Recommendations
### If Still Experiencing Slowness:
1. **Check Network Speed**
- Open Safari Developer Tools on Mac (Develop > [Your iPhone])
- Check Network tab for slow requests
- Look for large bundle sizes or slow API calls
2. **Disable Service Worker Temporarily**
- Comment out `<ServiceWorkerRegistration />` in layout.tsx
- Test if performance improves
- If yes, service worker needs further optimization
3. **Check for Memory Issues**
- Close other Safari tabs
- Restart iPhone
- Check if other apps are using memory
4. **Verify Build Optimization**
```bash
npm run build
npm start
```
- Test production build (much faster than dev mode)
5. **Enable Safari Debug Console**
- Settings > Safari > Advanced > Web Inspector
- Connect iPhone to Mac
- Safari > Develop > [Your iPhone] > [Your Site]
- Check for JavaScript errors or warnings
## Performance Metrics to Monitor
### Good Performance Targets:
- **First Contentful Paint (FCP)**: < 1.8s
- **Time to Interactive (TTI)**: < 3.8s
- **Total Blocking Time (TBT)**: < 300ms
- **Cumulative Layout Shift (CLS)**: < 0.1
- **Largest Contentful Paint (LCP)**: < 2.5s
### Measure with Lighthouse:
```bash
# Install Lighthouse CLI
npm install -g lighthouse
# Run audit (with iPhone user agent)
lighthouse https://your-app-url --preset=perf --emulated-form-factor=mobile
```
## Changes Made
### Files Modified:
1. `app/layout.tsx` - Updated viewport and meta tags
2. `app/globals.css` - Added touch-action CSS rules
3. `next.config.js` - Added performance optimizations
4. `public/sw.js` - Optimized fetch event handler
### Key CSS Addition:
```css
/* Remove 300ms click delay on iOS Safari */
a, button, input, select, textarea {
touch-action: manipulation;
}
```
This single CSS rule is the most important fix for the click delay issue.
## Rollback Instructions
If these changes cause issues:
1. **Revert viewport changes**:
```typescript
maximumScale: 1,
userScalable: false,
```
2. **Remove touch-action CSS**:
- Remove the touch-action rules from globals.css
3. **Revert service worker**:
- Remove the cross-origin check in sw.js
## Further Optimization Ideas
If you need even better performance:
1. **Implement Route Prefetching**:
- Next.js Link components prefetch by default
- Ensure prefetch is not disabled
2. **Code Splitting**:
- Use dynamic imports for heavy components
- Lazy load modals and forms
3. **Image Optimization**:
- Use Next.js Image component
- Implement lazy loading for images
4. **Reduce JavaScript Bundle Size**:
- Analyze bundle with `npm run build`
- Remove unused dependencies
- Use tree-shaking
5. **Implement Virtual Scrolling**:
- For long lists of items
- Use react-window or react-virtual
## Support
If issues persist after these fixes:
1. Check browser console for errors
2. Test on different iOS versions
3. Compare with other devices
4. Profile with Safari Web Inspector

View File

@@ -0,0 +1,83 @@
# Setup Push Notifications - Required Steps
## You MUST do these 3 steps for notifications to work:
### Step 1: Install the web-push package
```bash
npm install
```
This installs the `web-push` package that was added to package.json.
### Step 2: Generate VAPID keys
```bash
npm run vapid:generate
```
This will output something like:
```
VAPID_PUBLIC_KEY=BNxxx...
VAPID_PRIVATE_KEY=xxx...
VAPID_SUBJECT=mailto:your-email@example.com
```
**Copy these 3 lines and add them to your `.env` file.**
⚠️ **Important**: Keep the VAPID_PRIVATE_KEY secret! Don't commit it to git.
### Step 3: Update your database
```bash
npx prisma db push
```
This adds the `PushSubscription` table to your database.
### Step 4: Restart your server
```bash
npm run dev
```
## That's it!
Now when users open your app:
- After 3 seconds, they'll see a notification prompt at the bottom-right
- They can click "Enable Notifications" to subscribe
- Or they can dismiss it and enable later from their profile page
## What happens automatically:
✅ Notification prompt appears 3 seconds after app loads
✅ Only shows if user hasn't been asked before
✅ Users can dismiss temporarily ("Later") or permanently ("Don't ask again")
✅ Notifications are sent when:
- Items are added/removed/completed in shared lists
- Users are invited to lists
- Invitations are accepted
- Collaborators are removed
## Testing
1. Open your app in Chrome
2. Wait 3 seconds
3. Click "Enable Notifications" when the prompt appears
4. Open another browser/incognito window
5. Log in as a different user
6. Add an item to a shared list
7. See the notification in the first browser!
## Troubleshooting
**"Push notifications not configured" error**
- Make sure you ran `npm run vapid:generate`
- Make sure you added the keys to `.env`
- Restart your dev server
**Prompt doesn't appear**
- Check browser console for errors
- Make sure you're on localhost or HTTPS
- Clear localStorage and refresh if you dismissed it permanently
**Notifications don't send**
- Check server console for errors
- Verify VAPID keys are correct in `.env`
- Make sure database was updated with `npx prisma db push`

View File

@@ -1,3 +0,0 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const userId = session.user.id
const listId = params.id
// Check if user has access to this list
const list = await prisma.list.findFirst({
where: {
id: listId,
OR: [
{ ownerId: userId },
{
collaborators: {
some: {
userId: userId,
},
},
},
],
},
select: {
updatedAt: true,
items: {
select: {
updatedAt: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 1,
},
},
})
if (!list) {
return NextResponse.json(
{ error: 'List not found or access denied' },
{ status: 404 }
)
}
// Get the most recent update time (either list or its items)
const listUpdated = list.updatedAt.getTime()
const itemUpdated = list.items[0]?.updatedAt.getTime() || 0
const lastUpdated = Math.max(listUpdated, itemUpdated)
return NextResponse.json(
{ lastUpdated: new Date(lastUpdated).toISOString() },
{
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
}
)
} catch (error) {
console.error('Error fetching last updated timestamp:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';
import { subscribeToPush } from '@/lib/push-notifications';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const subscription = await request.json();
if (!subscription.endpoint || !subscription.keys) {
return NextResponse.json(
{ error: 'Invalid subscription object' },
{ status: 400 }
);
}
await subscribeToPush(session.user.id, subscription);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to subscribe to push:', error);
return NextResponse.json(
{ error: 'Failed to subscribe to push notifications' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';
import { unsubscribeFromPush } from '@/lib/push-notifications';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { endpoint } = await request.json();
if (!endpoint) {
return NextResponse.json(
{ error: 'Endpoint required' },
{ status: 400 }
);
}
await unsubscribeFromPush(session.user.id, endpoint);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to unsubscribe from push:', error);
return NextResponse.json(
{ error: 'Failed to unsubscribe from push notifications' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { getVapidPublicKey } from '@/lib/push-notifications';
export async function GET() {
try {
const publicKey = await getVapidPublicKey();
if (!publicKey) {
return NextResponse.json(
{ error: 'Push notifications not configured' },
{ status: 503 }
);
}
return NextResponse.json({ publicKey });
} catch (error) {
console.error('Failed to get VAPID public key:', error);
return NextResponse.json(
{ error: 'Failed to get VAPID public key' },
{ status: 500 }
);
}
}

View File

@@ -63,6 +63,17 @@ mark {
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
@@ -75,6 +86,10 @@ mark {
animation: scale-in 0.2s ease-out;
}
.animate-slide-up {
animation: slide-up 0.4s ease-out;
}
/* Smooth transitions for interactive elements */
.transition-smooth {
transition: all 0.2s ease-in-out;
@@ -104,4 +119,19 @@ mark {
-webkit-tap-highlight-color: transparent;
user-select: none;
}
/* Remove 300ms click delay on iOS Safari */
a, button, input, select, textarea {
touch-action: manipulation;
}
/* Optimize for touch interactions */
* {
-webkit-touch-callout: none;
}
/* Prevent double-tap zoom on interactive elements */
button, a, input[type="button"], input[type="submit"] {
touch-action: manipulation;
}
}

View File

@@ -1,4 +1,5 @@
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { redirect } from 'next/navigation'
import { getInvitations } from '@/lib/sharing-actions'
import InvitationCard from '@/components/InvitationCard'
@@ -27,7 +28,7 @@ type Invitation = {
}
export default async function InvitationsPage() {
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/login')

View File

@@ -1,7 +1,8 @@
import type { Metadata } from 'next'
import type { Metadata, Viewport } from 'next'
import './globals.css'
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
import OfflineIndicator from '@/components/OfflineIndicator'
import NotificationPrompt from '@/components/NotificationPrompt'
import { ToastProvider } from '@/components/ToastContainer'
import ErrorBoundary from '@/components/ErrorBoundary'
@@ -9,14 +10,6 @@ export const metadata: Metadata = {
title: 'Ultimate Lists',
description: 'The ultimate todo list app with advanced task management',
manifest: '/manifest.json',
themeColor: '#3B82F6',
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: 'cover',
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
@@ -39,6 +32,15 @@ export const metadata: Metadata = {
},
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
userScalable: true,
viewportFit: 'cover',
themeColor: '#3B82F6',
}
export default function RootLayout({
children,
}: {
@@ -65,7 +67,8 @@ export default function RootLayout({
<meta name="theme-color" content="#3B82F6" />
<meta name="background-color" content="#ffffff" />
{/* iOS Splash Screens - Optional but recommended */}
{/* iOS Performance Optimizations */}
<meta name="format-detection" content="telephone=no" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head>
<body className="bg-gray-50 min-h-screen">
@@ -73,6 +76,7 @@ export default function RootLayout({
<ToastProvider>
<ServiceWorkerRegistration />
<OfflineIndicator />
<NotificationPrompt />
{children}
</ToastProvider>
</ErrorBoundary>

View File

@@ -1,7 +1,8 @@
import { notFound } from 'next/navigation'
import { getItem } from '@/lib/actions'
import { canEditList } from '@/lib/permissions'
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import ItemForm from '@/components/ItemForm'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
@@ -16,7 +17,7 @@ interface PageProps {
}
export default async function EditItemPage({ params }: PageProps) {
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
notFound()
}

View File

@@ -1,7 +1,8 @@
import { notFound } from 'next/navigation'
import { getItem } from '@/lib/actions'
import { canEditList } from '@/lib/permissions'
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import ItemDetail from '@/components/ItemDetail'
import Link from 'next/link'
import { ArrowLeft, Edit } from 'lucide-react'
@@ -18,7 +19,7 @@ interface PageProps {
}
export default async function ItemDetailPage({ params }: PageProps) {
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
notFound()
}

View File

@@ -1,6 +1,7 @@
import { getListWithItems } from '@/lib/actions'
import { getPermission } from '@/lib/permissions'
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
@@ -8,9 +9,10 @@ import InlineItemCreate from '@/components/InlineItemCreate'
import ListDetailView from '@/components/ListDetailView'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export default async function ListDetailPage({ params }: { params: { id: string } }) {
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
notFound()
}
@@ -56,6 +58,25 @@ export default async function ListDetailPage({ params }: { params: { id: string
</p>
</div>
{/* Permission Badge and Share Button */}
<div className="mb-6 sm:mb-8">
<ListDetailView
listId={list.id}
initialItems={list.items}
permission={permission}
canShare={canShare}
listTitle={list.title}
ownerId={list.ownerId}
ownerName={list.owner?.name}
ownerEmail={list.owner?.email || ''}
collaborators={list.collaborators.map(c => ({
...c,
permission: c.permission as 'editor' | 'viewer'
}))}
showOnlyHeader={true}
/>
</div>
{/* Inline Item Creation - only show if user can edit */}
{canEdit && (
<div className="mb-6 sm:mb-8">
@@ -63,7 +84,7 @@ export default async function ListDetailPage({ params }: { params: { id: string
</div>
)}
{/* Search, Filter, and Items List */}
{/* Items List */}
<ListDetailView
listId={list.id}
initialItems={list.items}
@@ -77,6 +98,7 @@ export default async function ListDetailPage({ params }: { params: { id: string
...c,
permission: c.permission as 'editor' | 'viewer'
}))}
showOnlyItems={true}
/>
</main>
)

View File

@@ -3,14 +3,23 @@ import ListCard from '@/components/ListCard'
import NewListButton from '@/components/NewListButton'
import GlobalSearch from '@/components/GlobalSearch'
import EmptyState from '@/components/EmptyState'
import LogoutButton from '@/components/LogoutButton'
import Link from 'next/link'
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { redirect } from 'next/navigation'
export const dynamic = 'force-dynamic'
export const revalidate = 0
export default async function Home() {
const session = await getServerSession(authOptions)
if (!session?.user) {
redirect('/auth/login')
}
const lists = await getLists()
const session = await auth()
return (
<main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-7xl">
@@ -31,6 +40,7 @@ export default async function Home() {
>
Profile
</Link>
<LogoutButton />
</>
)}
<NewListButton />

View File

@@ -1,8 +1,10 @@
import { redirect } from 'next/navigation'
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { getUserProfile } from '@/lib/auth-actions'
import ProfileForm from '@/components/ProfileForm'
import PasswordChangeForm from '@/components/PasswordChangeForm'
import NotificationSettings from '@/components/NotificationSettings'
import Link from 'next/link'
export const metadata = {
@@ -11,7 +13,7 @@ export const metadata = {
}
export default async function ProfilePage() {
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
redirect('/auth/login')
@@ -81,6 +83,11 @@ export default async function ProfilePage() {
<PasswordChangeForm userId={userProfile.id} />
</div>
{/* Notification Settings */}
<div className="mb-6">
<NotificationSettings />
</div>
{/* Account Information */}
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8">
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-4">

View File

@@ -1,28 +0,0 @@
import type { NextAuthConfig } from "next-auth"
export const authConfig = {
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
error: "/auth/error",
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const isOnAuthPage = nextUrl.pathname.startsWith("/auth")
// Allow access to auth pages when not logged in
if (isOnAuthPage) {
return true
}
// Redirect to login if not authenticated
if (!isLoggedIn) {
return false
}
return true
},
},
providers: [], // Providers will be added in auth.ts
} satisfies NextAuthConfig

102
auth.ts
View File

@@ -1,102 +0,0 @@
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { prisma } from "@/lib/db"
import bcrypt from "bcryptjs"
import { z } from "zod"
// Validation schemas
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
updateAge: 24 * 60 * 60, // Update session every 24 hours
},
cookies: {
sessionToken: {
name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}next-auth.session-token`,
options: {
httpOnly: true, // Prevent JavaScript access to cookies (XSS protection)
sameSite: 'lax', // CSRF protection - prevents cookie from being sent in cross-site requests
path: '/',
secure: process.env.NODE_ENV === 'production', // Only send cookie over HTTPS in production
},
},
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
try {
// Validate credentials format
const { email, password } = loginSchema.parse(credentials)
// Query user from database
const user = await prisma.user.findUnique({
where: { email },
})
// Check if user exists
if (!user || !user.passwordHash) {
return null
}
// Verify password with bcrypt
const isPasswordValid = await bcrypt.compare(password, user.passwordHash)
if (!isPasswordValid) {
return null
}
// Return user object (without password hash)
return {
id: user.id,
email: user.email,
name: user.name,
}
} catch (error) {
console.error("Authorization error:", error)
return null
}
},
}),
],
callbacks: {
async jwt({ token, user, trigger }) {
// Add user ID to token on sign in
if (user) {
token.id = user.id
}
// Handle session expiration
// Token will be automatically invalidated by NextAuth when it expires
// based on the maxAge setting in session config
return token
},
async session({ session, token }) {
// Add user ID to session from token
if (session.user && token.id) {
session.user.id = token.id as string
}
// Session validation happens automatically via JWT expiration
// If token is expired, this callback won't be called and user will be redirected
return session
},
},
pages: {
signIn: "/auth/login",
signOut: "/auth/logout",
error: "/auth/error",
},
})

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { deleteItem } from '@/lib/actions'
import { Trash2 } from 'lucide-react'
@@ -17,19 +17,25 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
const { showToast } = useToast()
const [isDeleting, setIsDeleting] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [isPending, startTransition] = useTransition()
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDeleting(true)
try {
await deleteItem(itemId)
showToast('Task deleted successfully', 'success')
if (listId) {
router.push(`/list/${listId}`)
} else {
// Reset state immediately
setShowConfirm(false)
setIsDeleting(false)
// Use startTransition to refresh the route
startTransition(() => {
router.refresh()
}
})
} catch (error) {
console.error('Error deleting item:', error)
showToast('Failed to delete task', 'error')
@@ -55,14 +61,14 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
<div className="flex items-center gap-2">
<button
onClick={handleDelete}
disabled={isDeleting}
disabled={isDeleting || isPending}
className="px-3 sm:px-4 py-2 sm:py-2.5 bg-red-500 hover:bg-red-600 active:bg-red-700 disabled:bg-red-300 text-white rounded-lg text-xs sm:text-sm transition-colors tap-target-sm font-medium"
>
{isDeleting ? 'Deleting...' : 'Confirm'}
{isDeleting || isPending ? 'Deleting...' : 'Confirm'}
</button>
<button
onClick={handleCancel}
disabled={isDeleting}
disabled={isDeleting || isPending}
className="px-3 sm:px-4 py-2 sm:py-2.5 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg text-xs sm:text-sm transition-colors tap-target-sm font-medium"
>
Cancel

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createItem } from '@/lib/actions'
import { Plus } from 'lucide-react'
@@ -18,6 +18,20 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
const [title, setTitle] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [keepFormOpen, setKeepFormOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Focus input when form becomes visible
useEffect(() => {
if ((isCreating || keepFormOpen) && inputRef.current) {
// Small delay to ensure the input is rendered
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, 50)
}
}, [isCreating, keepFormOpen])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -37,16 +51,31 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
setError('')
try {
await createItem(listId, sanitizeString(title))
showToast('Task added successfully', 'success')
// Clear form immediately for better UX
const itemTitle = sanitizeString(title)
setTitle('')
setIsCreating(false)
router.refresh()
setKeepFormOpen(true) // Mark that we want to keep the form open
// Create item and refresh
await createItem(listId, itemTitle)
showToast('Task added successfully', 'success')
// The revalidatePath in createItem will refresh the data
// but we'll keep the form open due to keepFormOpen state
// Refocus the input after a short delay to keep keyboard open
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, 100)
} catch (error) {
console.error('Error creating item:', error)
const errorMessage = error instanceof Error ? error.message : 'Failed to add task'
setError(errorMessage)
showToast(errorMessage, 'error')
// Restore form on error
setIsCreating(true)
} finally {
setIsSubmitting(false)
}
@@ -55,6 +84,11 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
const handleCancel = () => {
setTitle('')
setIsCreating(false)
setKeepFormOpen(false) // Reset the keep form open state
// Reset zoom on iOS Safari
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -63,7 +97,16 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
}
}
if (!isCreating) {
const handleBlur = (e: React.FocusEvent) => {
// Only handle blur if we're not keeping the form open
// This prevents the keyboard from closing when we want it to stay open
if (!keepFormOpen) {
// Force viewport reset on iOS Safari
window.scrollTo(0, 0)
}
}
if (!isCreating && !keepFormOpen) {
return (
<button
onClick={() => setIsCreating(true)}
@@ -79,6 +122,7 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
<form onSubmit={handleSubmit} className="bg-white border-2 border-blue-400 rounded-lg p-4 sm:p-5 shadow-sm">
<div className="mb-3 sm:mb-4">
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => {
@@ -86,10 +130,11 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
setError('')
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder="Enter task title..."
autoFocus
maxLength={200}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
}`}
aria-invalid={error ? 'true' : 'false'}

View File

@@ -33,24 +33,33 @@ function ItemCard({ item, listId }: ItemCardProps) {
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium
return (
<div className="bg-white rounded-lg border border-gray-200 p-4 sm:p-5 hover:shadow-md active:shadow-lg transition-all group">
<div className="flex items-start gap-3 sm:gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-3.5 hover:shadow-md active:shadow-lg transition-all group">
<div className="flex items-center gap-2 sm:gap-3">
{/* Completion Toggle */}
<div className="pt-0.5 sm:pt-1 flex-shrink-0">
<div className="flex-shrink-0">
<ToggleItemButton itemId={item.id} completed={item.completed} variant="icon" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<Link href={`/list/${listId}/item/${item.id}`} className="block">
<h3 className={`font-medium mb-1 text-sm sm:text-base ${
item.completed ? 'text-gray-500 line-through' : 'text-gray-900'
}`}>
{item.title}
</h3>
<div className="flex items-center justify-between gap-2">
<h3 className={`font-medium text-sm sm:text-base ${
item.completed ? 'text-gray-500 line-through' : 'text-gray-900'
}`}>
{item.title}
</h3>
{/* Priority Badge - moved to right */}
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border flex-shrink-0 ${priorityColor}`}>
<AlertCircle size={12} className="flex-shrink-0" />
<span className="hidden xs:inline sm:inline">{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}</span>
<span className="xs:hidden sm:hidden">{item.priority.charAt(0).toUpperCase()}</span>
</span>
</div>
{item.description && (
<p className={`text-xs sm:text-sm mb-2 line-clamp-2 ${
<p className={`text-xs sm:text-sm mt-1 line-clamp-1 ${
item.completed ? 'text-gray-400' : 'text-gray-600'
}`}>
{item.description}
@@ -58,42 +67,37 @@ function ItemCard({ item, listId }: ItemCardProps) {
)}
{/* Metadata */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-2">
{/* Priority Badge */}
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium border ${priorityColor}`}>
<AlertCircle size={12} className="flex-shrink-0" />
<span className="hidden xs:inline sm:inline">{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}</span>
<span className="xs:hidden sm:hidden">{item.priority.charAt(0).toUpperCase()}</span>
</span>
{/* Due Date */}
{item.dueDate && (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs whitespace-nowrap ${
isOverdue
? 'bg-red-100 text-red-700 border border-red-200 font-semibold'
: 'bg-gray-100 text-gray-700'
}`}>
<Calendar size={12} className="flex-shrink-0" />
{new Date(item.dueDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
{isOverdue && <span className="hidden sm:inline"> (Overdue)</span>}
{isOverdue && <span className="sm:hidden">!</span>}
</span>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="inline-flex items-center gap-1">
<Tag size={12} className="text-gray-400 flex-shrink-0" />
<span className="text-xs text-gray-600 truncate max-w-[120px] sm:max-w-none">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && ` +${tags.length - 2}`}
{(item.dueDate || tags.length > 0) && (
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5">
{/* Due Date */}
{item.dueDate && (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs whitespace-nowrap ${
isOverdue
? 'bg-red-100 text-red-700 border border-red-200 font-semibold'
: 'bg-gray-100 text-gray-700'
}`}>
<Calendar size={12} className="flex-shrink-0" />
{new Date(item.dueDate).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
{isOverdue && <span className="hidden sm:inline"> (Overdue)</span>}
{isOverdue && <span className="sm:hidden">!</span>}
</span>
</div>
)}
</div>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="inline-flex items-center gap-1">
<Tag size={12} className="text-gray-400 flex-shrink-0" />
<span className="text-xs text-gray-600 truncate max-w-[120px] sm:max-w-none">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && ` +${tags.length - 2}`}
</span>
</div>
)}
</div>
)}
</Link>
</div>

View File

@@ -264,7 +264,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
onChange={(e) => setTitle(e.target.value)}
onBlur={() => handleBlur('title')}
disabled={!canEdit}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
errors.title && touched.title
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
@@ -293,7 +293,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
onBlur={() => handleBlur('description')}
disabled={!canEdit}
rows={3}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base resize-y ${
errors.description && touched.description
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
@@ -320,7 +320,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
value={priority}
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
disabled={!canEdit}
className={`w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
className={`w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base tap-target ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
@@ -340,7 +340,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
onChange={(e) => setDueDate(e.target.value)}
onBlur={() => handleBlur('dueDate')}
disabled={!canEdit}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base tap-target ${
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base tap-target ${
errors.dueDate && touched.dueDate
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
@@ -370,7 +370,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={!canEdit}
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
errors.tagInput
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
@@ -432,7 +432,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
onBlur={() => handleBlur('notes')}
disabled={!canEdit}
rows={5}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base resize-y ${
errors.notes && touched.notes
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'

View File

@@ -1,16 +1,13 @@
'use client'
import { useState, useCallback, useMemo, useEffect } from 'react'
import { useState, useMemo } from 'react'
import { Share2 } from 'lucide-react'
import ItemCard from './ItemCard'
import SearchBar from './SearchBar'
import FilterPanel from './FilterPanel'
import SearchResults from './SearchResults'
import EmptyState from './EmptyState'
import ShareListModal from './ShareListModal'
import PermissionBadge from './PermissionBadge'
import { searchItems, getItemsByFilter } from '@/lib/actions'
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
import { useScrollPosition } from '@/hooks'
import { useRealtimeSync } from '@/hooks/useRealtimeSync'
interface Item {
id: string
@@ -26,14 +23,6 @@ interface Item {
listId: string
}
interface ItemWithList extends Item {
list: {
id: string
title: string
color: string
}
}
interface ListDetailViewProps {
listId: string
initialItems: Item[]
@@ -52,6 +41,8 @@ interface ListDetailViewProps {
email: string
}
}>
showOnlyHeader?: boolean
showOnlyItems?: boolean
}
export default function ListDetailView({
@@ -63,131 +54,72 @@ export default function ListDetailView({
ownerId = '',
ownerName = null,
ownerEmail = '',
collaborators = []
collaborators = [],
showOnlyHeader = false,
showOnlyItems = false
}: ListDetailViewProps) {
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
const [isSearching, setIsSearching] = useState(false)
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
// Use scroll position preservation hook
useScrollPosition({ key: `list-${listId}` })
// Conditionally use hooks based on view mode
const shouldUseHooks = !showOnlyHeader
// Use scroll position preservation hook (only for items view)
useScrollPosition({ key: shouldUseHooks ? `list-${listId}` : 'disabled' })
// Extract all unique tags from items
const availableTags = useMemo(() => {
const tagSet = new Set<string>()
initialItems.forEach(item => {
const tags = Array.isArray(item.tags) ? item.tags : []
tags.forEach(tag => {
if (typeof tag === 'string') {
tagSet.add(tag)
}
})
})
return Array.from(tagSet).sort()
}, [initialItems])
// Use search hook
const handleSearchCallback = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([])
setIsSearching(false)
return
}
setIsSearching(true)
try {
const results = await searchItems(query, listId)
setSearchResults(results as ItemWithList[])
} catch (error) {
console.error('Search error:', error)
setSearchResults([])
} finally {
setIsSearching(false)
}
}, [listId])
const { debouncedQuery } = useSearch({
onSearch: handleSearchCallback
// Use real-time sync hook to poll for updates (only for items view)
useRealtimeSync({
listId,
enabled: shouldUseHooks,
pollInterval: 1000, // Poll every 1 second for faster updates
})
// Use filters hook
const handleFiltersCallback = useCallback(async (newFilters: any) => {
try {
const results = await getItemsByFilter(listId, {
priority: newFilters.priority,
completed: newFilters.completed,
tags: newFilters.tags,
dueDateRange: newFilters.dueDateRange,
sortBy: newFilters.sortBy,
sortOrder: newFilters.sortOrder
})
// Extract items from pagination result
const items = 'items' in results ? results.items : results
setFilteredItems(items as Item[])
} catch (error) {
console.error('Filter error:', error)
setFilteredItems(initialItems)
}
}, [listId, initialItems])
const { filters, setFilters } = useFilters({
onFiltersChange: handleFiltersCallback
})
// Apply initial filters on mount
useEffect(() => {
handleFiltersCallback(filters)
}, []) // Only run on mount
// Determine which items to display
const displayItems = debouncedQuery.trim() ? searchResults : filteredItems
const completedItems = displayItems.filter(item => item.completed)
const incompleteItems = displayItems.filter(item => !item.completed)
const completedItems = useMemo(() => initialItems.filter(item => item.completed), [initialItems])
const incompleteItems = useMemo(() => initialItems.filter(item => !item.completed), [initialItems])
return (
<div className="space-y-6">
{/* Permission Badge and Share Button */}
{(permission || canShare) && (
<div className="flex items-center justify-between gap-4">
{permission && (
<div>
<PermissionBadge permission={permission} />
</div>
)}
{canShare && (
<button
onClick={() => setIsShareModalOpen(true)}
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<Share2 size={16} />
Share
</button>
)}
</div>
)}
{/* Search Bar */}
<SearchBar
onSearch={handleSearchCallback}
placeholder="Search tasks in this list..."
/>
{/* Filter Panel */}
<FilterPanel
filters={filters}
onFiltersChange={setFilters}
availableTags={availableTags}
/>
{/* Results */}
{debouncedQuery.trim() ? (
<SearchResults
results={searchResults}
query={debouncedQuery}
// If showing only header, return just the permission badge and share button
if (showOnlyHeader) {
return (
<>
{(permission || canShare) && (
<div className="flex items-center justify-between gap-4">
{permission && (
<div>
<PermissionBadge permission={permission} />
</div>
)}
{canShare && (
<button
onClick={() => setIsShareModalOpen(true)}
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<Share2 size={16} />
Share
</button>
)}
</div>
)}
{/* Share Modal */}
<ShareListModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
listId={listId}
listTitle={listTitle}
isOwner={permission === 'owner'}
ownerId={ownerId}
ownerName={ownerName}
ownerEmail={ownerEmail}
initialCollaborators={collaborators}
/>
) : (
<div className="space-y-6">
</>
)
}
// If showing only items, return just the items list
if (showOnlyItems) {
return (
<div className="space-y-6">
{/* Incomplete Items */}
{incompleteItems.length > 0 && (
<div className="animate-fade-in">
@@ -217,20 +149,80 @@ export default function ListDetailView({
)}
{/* Empty State */}
{displayItems.length === 0 && (
{initialItems.length === 0 && (
<EmptyState
icon={filters.priority || filters.completed !== null || filters.tags.length > 0 ? 'filter' : 'inbox'}
icon="inbox"
title="No tasks found"
description={
filters.priority || filters.completed !== null || filters.tags.length > 0
? 'Try adjusting your filters to see more tasks'
: 'Add your first task using the button above'
}
description="Add your first task using the button above"
/>
)}
</div>
)
}
// Default: show everything (for backward compatibility)
return (
<div className="space-y-6">
{/* Permission Badge and Share Button */}
{(permission || canShare) && (
<div className="flex items-center justify-between gap-4">
{permission && (
<div>
<PermissionBadge permission={permission} />
</div>
)}
{canShare && (
<button
onClick={() => setIsShareModalOpen(true)}
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<Share2 size={16} />
Share
</button>
)}
</div>
)}
{/* Items */}
<div className="space-y-6">
{/* Incomplete Items */}
{incompleteItems.length > 0 && (
<div className="animate-fade-in">
<h2 className="text-lg font-semibold text-gray-900 mb-3">
To Do ({incompleteItems.length})
</h2>
<div className="space-y-2">
{incompleteItems.map((item) => (
<ItemCard key={item.id} item={item} listId={listId} />
))}
</div>
</div>
)}
{/* Completed Items */}
{completedItems.length > 0 && (
<div className="animate-fade-in">
<h2 className="text-lg font-semibold text-gray-500 mb-3">
Completed ({completedItems.length})
</h2>
<div className="space-y-2">
{completedItems.map((item) => (
<ItemCard key={item.id} item={item} listId={listId} />
))}
</div>
</div>
)}
{/* Empty State */}
{initialItems.length === 0 && (
<EmptyState
icon="inbox"
title="No tasks found"
description="Add your first task using the button above"
/>
)}
</div>
{/* Share Modal */}
<ShareListModal
isOpen={isShareModalOpen}

View File

@@ -0,0 +1,21 @@
'use client'
import { signOut } from 'next-auth/react'
import { LogOut } from 'lucide-react'
export default function LogoutButton() {
const handleLogout = async () => {
await signOut({ callbackUrl: '/auth/login' })
}
return (
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors"
aria-label="Log out"
>
<LogOut size={16} />
<span>Log out</span>
</button>
)
}

View File

@@ -42,15 +42,20 @@ export default function Modal({ isOpen, onClose, title, children }: ModalProps)
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center animate-fade-in">
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center animate-fade-in"
onClick={onClose}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-t-2xl sm:rounded-lg shadow-xl max-w-md w-full mx-0 sm:mx-4 max-h-[90vh] overflow-y-auto transform transition-transform animate-scale-in">
<div
className="relative bg-white rounded-t-2xl sm:rounded-lg shadow-xl max-w-md w-full mx-0 sm:mx-4 max-h-[90vh] overflow-y-auto transform transition-transform animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 sm:p-5 border-b border-gray-200 sticky top-0 bg-white z-10">
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2>
<button

View File

@@ -0,0 +1,35 @@
'use client';
import { useEffect, useState } from 'react';
export default function NotificationDebug() {
const [mounted, setMounted] = useState(false);
const [debug, setDebug] = useState<any>({});
useEffect(() => {
setMounted(true);
const info = {
notificationSupported: 'Notification' in window,
serviceWorkerSupported: 'serviceWorker' in navigator,
pushManagerSupported: 'PushManager' in window,
permission: 'Notification' in window ? Notification.permission : 'N/A',
localStorage: typeof localStorage !== 'undefined' ? localStorage.getItem('notification-prompt-dismissed') : 'N/A',
};
setDebug(info);
}, []);
if (!mounted) {
return null;
}
return (
<div className="fixed bottom-4 left-4 bg-black text-white text-xs p-3 rounded-lg max-w-xs z-50 font-mono">
<div className="font-bold mb-2">Push Debug:</div>
<div>Notification: {debug.notificationSupported ? '✓' : '✗'}</div>
<div>ServiceWorker: {debug.serviceWorkerSupported ? '✓' : '✗'}</div>
<div>PushManager: {debug.pushManagerSupported ? '✓' : '✗'}</div>
<div>Permission: {debug.permission}</div>
<div>Dismissed: {debug.localStorage || 'No'}</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
'use client';
import { useEffect, useState } from 'react';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { Bell, X } from 'lucide-react';
export default function NotificationPrompt() {
const { permission, isSubscribed, subscribe } = usePushNotifications();
const [showPrompt, setShowPrompt] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
// Check if we should show the prompt
const shouldShow =
'Notification' in window &&
'serviceWorker' in navigator &&
permission === 'default' && // Not yet asked
!isSubscribed && // Not subscribed
!dismissed && // Not dismissed this session
!localStorage.getItem('notification-prompt-dismissed'); // Not permanently dismissed
if (shouldShow) {
// Show prompt after a short delay (better UX)
const timer = setTimeout(() => {
setShowPrompt(true);
}, 3000); // 3 seconds after app loads
return () => clearTimeout(timer);
}
}, [mounted, permission, isSubscribed, dismissed]);
const handleEnable = async () => {
const success = await subscribe();
if (success) {
setShowPrompt(false);
}
};
const handleDismiss = () => {
setShowPrompt(false);
setDismissed(true);
};
const handleDismissForever = () => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('notification-prompt-dismissed', 'true');
}
setShowPrompt(false);
setDismissed(true);
};
if (!mounted || !showPrompt) {
return null;
}
return (
<div className="fixed bottom-4 right-4 left-4 sm:left-auto sm:w-96 z-50 animate-slide-up">
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<Bell className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 mb-1">
Stay Updated
</h3>
<p className="text-sm text-gray-600 mb-3">
Get notified when items are added, completed, or when you're invited to lists
</p>
<div className="flex gap-2">
<button
onClick={handleEnable}
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Enable Notifications
</button>
<button
onClick={handleDismiss}
className="px-3 py-2 text-gray-600 text-sm font-medium hover:text-gray-900 transition-colors"
>
Later
</button>
</div>
<button
onClick={handleDismissForever}
className="text-xs text-gray-500 hover:text-gray-700 mt-2 underline"
>
Don't ask again
</button>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { usePushNotifications } from '@/hooks/usePushNotifications';
import { Bell, BellOff } from 'lucide-react';
export default function NotificationSettings() {
const { permission, isSubscribed, isLoading, error, subscribe, unsubscribe } =
usePushNotifications();
const handleToggle = async () => {
if (isSubscribed) {
await unsubscribe();
} else {
await subscribe();
}
};
// Don't show if notifications aren't supported
if (!('Notification' in window) || !('serviceWorker' in navigator)) {
return null;
}
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
{isSubscribed ? <Bell className="w-5 h-5" /> : <BellOff className="w-5 h-5" />}
Push Notifications
</h3>
<p className="text-sm text-gray-600 mt-1">
Get notified when someone adds items, shares lists, or invites you to collaborate
</p>
{permission === 'denied' && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-800">
Notifications are blocked. Please enable them in your browser settings.
</p>
</div>
)}
{error && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{isSubscribed && (
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-md">
<p className="text-sm text-green-800">
✓ You'll receive notifications on this device
</p>
</div>
)}
</div>
<button
onClick={handleToggle}
disabled={isLoading || permission === 'denied'}
className={`ml-4 px-4 py-2 rounded-md font-medium transition-colors ${
isSubscribed
? 'bg-gray-200 text-gray-700 hover:bg-gray-300'
: 'bg-blue-600 text-white hover:bg-blue-700'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{isLoading ? 'Loading...' : isSubscribed ? 'Disable' : 'Enable'}
</button>
</div>
<div className="mt-4 text-xs text-gray-500">
<p>💡 Tip: Install this app to your home screen for the best notification experience</p>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useOfflineSync } from '@/hooks'
import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
@@ -9,8 +10,18 @@ import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
* Validates: Requirements 4.5
*/
export default function OfflineIndicator() {
const [mounted, setMounted] = useState(false)
const { isOnline, isSyncing, syncError, queueLength, triggerSync } = useOfflineSync()
useEffect(() => {
setMounted(true)
}, [])
// Don't render on server to avoid hydration mismatch
if (!mounted) {
return null
}
// Don't show anything if online and no pending syncs
if (isOnline && queueLength === 0 && !syncError) {
return null

View File

@@ -0,0 +1,22 @@
'use client'
import { useRealtimeSync } from '@/hooks/useRealtimeSync'
interface RealtimeUpdaterProps {
listId?: string
pollInterval?: number
}
/**
* Component that enables real-time updates for a page
* Just include this component and it will poll for updates
*/
export default function RealtimeUpdater({ listId, pollInterval = 3000 }: RealtimeUpdaterProps) {
useRealtimeSync({
listId,
enabled: true,
pollInterval,
})
return null
}

View File

@@ -1,6 +1,6 @@
'use client'
import { memo } from 'react'
import { memo, useState, useEffect } from 'react'
import { Search, X } from 'lucide-react'
import { useSearch } from '@/hooks'
@@ -17,18 +17,25 @@ function SearchBar({
debounceMs = 300,
initialValue = ''
}: SearchBarProps) {
const [mounted, setMounted] = useState(false)
const { query, setQuery, clearSearch, isDebouncing } = useSearch({
debounceMs,
onSearch
})
useEffect(() => {
setMounted(true)
}, [])
return (
<div className="relative">
<div className="relative">
<Search
className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
size={20}
/>
{mounted && (
<Search
className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
size={20}
/>
)}
<input
type="text"
value={query}
@@ -36,7 +43,7 @@ function SearchBar({
placeholder={placeholder}
className="w-full pl-10 sm:pl-12 pr-10 sm:pr-12 py-3 sm:py-3.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm sm:text-base transition-shadow"
/>
{query && (
{mounted && query && (
<button
onClick={clearSearch}
className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 active:text-gray-700 transition-colors tap-target-sm"
@@ -45,7 +52,7 @@ function SearchBar({
<X size={20} />
</button>
)}
{isDebouncing && (
{mounted && isDebouncing && (
<div className="absolute right-12 sm:right-14 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
export type ToastType = 'success' | 'error' | 'info'
@@ -13,12 +13,26 @@ interface ToastProps {
}
export default function Toast({ message, type, onClose, duration = 3000 }: ToastProps) {
const onCloseRef = useRef(onClose)
// Keep the ref updated
useEffect(() => {
onCloseRef.current = onClose
}, [onClose])
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(onClose, duration)
return () => clearTimeout(timer)
console.log(`[Toast] Setting timer for ${duration}ms`)
const timer = setTimeout(() => {
console.log('[Toast] Auto-dismissing')
onCloseRef.current()
}, duration)
return () => {
console.log('[Toast] Clearing timer')
clearTimeout(timer)
}
}
}, [duration, onClose])
}, [duration])
const icons = {
success: <CheckCircle size={20} className="flex-shrink-0" />,

View File

@@ -27,11 +27,17 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([])
const showToast = useCallback((message: string, type: ToastType) => {
const id = Math.random().toString(36).substring(7)
setToasts((prev) => [...prev, { id, message, type }])
const id = `${Date.now()}-${Math.random().toString(36).substring(7)}`
console.log('[ToastContainer] Showing toast:', id, message)
setToasts((prev) => {
// Limit to 3 toasts at a time
const newToasts = [...prev, { id, message, type }]
return newToasts.slice(-3)
})
}, [])
const removeToast = useCallback((id: string) => {
console.log('[ToastContainer] Removing toast:', id)
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}, [])

View File

@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useState, useOptimistic } from 'react'
import { useRouter } from 'next/navigation'
import { toggleItem } from '@/lib/actions'
import { CheckCircle2, Circle } from 'lucide-react'
import { useToast } from './ToastContainer'
@@ -12,19 +13,31 @@ interface ToggleItemButtonProps {
}
export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) {
const router = useRouter()
const { showToast } = useToast()
const [isToggling, setIsToggling] = useState(false)
const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(completed)
const handleToggle = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (isToggling) return
setIsToggling(true)
// Optimistically update UI
setOptimisticCompleted(!completed)
try {
await toggleItem(itemId)
showToast(completed ? 'Task marked incomplete' : 'Task completed', 'success')
router.refresh()
} catch (error) {
console.error('Error toggling item:', error)
showToast('Failed to update task', 'error')
// Revert optimistic update on error
setOptimisticCompleted(completed)
} finally {
setIsToggling(false)
}
@@ -36,9 +49,9 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
onClick={handleToggle}
disabled={isToggling}
className="hover:scale-110 active:scale-95 transition-transform disabled:opacity-50 tap-target-sm"
aria-label={completed ? 'Mark incomplete' : 'Mark complete'}
aria-label={optimisticCompleted ? 'Mark incomplete' : 'Mark complete'}
>
{completed ? (
{optimisticCompleted ? (
<CheckCircle2 size={22} className="text-green-500" />
) : (
<Circle size={22} className="text-gray-400 hover:text-gray-600 active:text-gray-700" />
@@ -53,7 +66,7 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
disabled={isToggling}
className="flex items-center gap-2 px-4 sm:px-5 py-2.5 sm:py-3 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 disabled:bg-gray-50 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
>
{completed ? (
{optimisticCompleted ? (
<>
<CheckCircle2 size={18} className="text-green-500 flex-shrink-0" />
<span>Mark Incomplete</span>

47
docker-compose.yml Normal file
View File

@@ -0,0 +1,47 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3500:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=${DATABASE_URL}
- AUTH_URL=http://localhost:3500
- NEXTAUTH_URL=http://localhost:3500
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${AUTH_SECRET}
- AUTH_TRUST_HOST=true
# Push Notifications (VAPID)
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT}
restart: unless-stopped
depends_on:
- db-check
networks:
- app-network
# Health check service to ensure MySQL is accessible
db-check:
image: mysql:8.0
command: >
sh -c "
until mysqladmin ping -h$${DB_HOST} -P$${DB_PORT} -u$${DB_USER} -p$${DB_PASSWORD} --silent; do
echo 'Waiting for MySQL...';
sleep 2;
done;
echo 'MySQL is ready!';
"
environment:
- DB_HOST=${DB_HOST:-localhost}
- DB_PORT=${DB_PORT:-3306}
- DB_USER=${DB_USER:-root}
- DB_PASSWORD=${DB_PASSWORD}
networks:
- app-network
networks:
app-network:
driver: bridge

View File

@@ -10,3 +10,4 @@ export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate'
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'
export { useOfflineSync } from './useOfflineSync'
export type { QueuedMutation } from './useOfflineSync'
export { useRealtimeSync } from './useRealtimeSync'

View File

@@ -0,0 +1,170 @@
'use client';
import { useState, useEffect } from 'react';
export function usePushNotifications() {
const [permission, setPermission] = useState<NotificationPermission>('default');
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
checkSubscription();
}
}, []);
const checkSubscription = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
} catch (err) {
console.error('Failed to check subscription:', err);
}
};
const requestPermission = async () => {
if (!('Notification' in window)) {
setError('Notifications not supported');
return false;
}
try {
const result = await Notification.requestPermission();
setPermission(result);
return result === 'granted';
} catch (err) {
console.error('Failed to request permission:', err);
setError('Failed to request permission');
return false;
}
};
const subscribe = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
setError('Push notifications not supported');
return false;
}
setIsLoading(true);
setError(null);
try {
// Request permission if not granted
if (permission !== 'granted') {
const granted = await requestPermission();
if (!granted) {
setError('Permission denied');
setIsLoading(false);
return false;
}
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Get VAPID public key from server
const response = await fetch('/api/push/vapid-key');
const { publicKey } = await response.json();
if (!publicKey) {
throw new Error('VAPID public key not available');
}
// Subscribe to push notifications
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
// Send subscription to server
const saveResponse = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription.toJSON()),
});
if (!saveResponse.ok) {
throw new Error('Failed to save subscription');
}
setIsSubscribed(true);
setIsLoading(false);
return true;
} catch (err) {
console.error('Failed to subscribe:', err);
setError(err instanceof Error ? err.message : 'Failed to subscribe');
setIsLoading(false);
return false;
}
};
const unsubscribe = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return false;
}
setIsLoading(true);
setError(null);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
// Unsubscribe from push manager
await subscription.unsubscribe();
// Remove subscription from server
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}
setIsSubscribed(false);
setIsLoading(false);
return true;
} catch (err) {
console.error('Failed to unsubscribe:', err);
setError(err instanceof Error ? err.message : 'Failed to unsubscribe');
setIsLoading(false);
return false;
}
};
return {
permission,
isSubscribed,
isLoading,
error,
subscribe,
unsubscribe,
requestPermission,
};
}
// Helper function to convert VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

72
hooks/useRealtimeSync.ts Normal file
View File

@@ -0,0 +1,72 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
interface UseRealtimeSyncOptions {
listId?: string
enabled?: boolean
pollInterval?: number
onUpdate?: () => void
}
/**
* Hook to enable real-time synchronization of list data
* Polls the server for updates by refreshing the route
*/
export function useRealtimeSync({
listId,
enabled = true,
pollInterval = 3000, // Poll every 3 seconds
onUpdate
}: UseRealtimeSyncOptions) {
const router = useRouter()
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const refresh = useCallback(() => {
console.log('[RealtimeSync] Refreshing...', listId ? `list ${listId}` : 'page')
router.refresh()
if (onUpdate) {
onUpdate()
}
}, [router, onUpdate, listId])
useEffect(() => {
if (!enabled) {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
return
}
// Start polling
intervalRef.current = setInterval(refresh, pollInterval)
// Cleanup on unmount
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [enabled, pollInterval, refresh])
// Also refresh when the page becomes visible again
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && enabled) {
refresh()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [enabled, refresh])
return {
refresh,
}
}

View File

@@ -0,0 +1,37 @@
'use client'
import { useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
/**
* Hook for real-time updates via polling
* Refreshes data at regular intervals to show changes from other users/browsers
*/
export function useRealtimeUpdates(intervalMs: number = 3000) {
const router = useRouter()
const refresh = useCallback(() => {
router.refresh()
}, [router])
useEffect(() => {
// Poll for updates
const interval = setInterval(() => {
refresh()
}, intervalMs)
// Also refresh when window regains focus
const handleFocus = () => {
refresh()
}
window.addEventListener('focus', handleFocus)
return () => {
clearInterval(interval)
window.removeEventListener('focus', handleFocus)
}
}, [intervalMs, refresh])
return { refresh }
}

View File

@@ -15,7 +15,7 @@ vi.mock('@/auth', () => ({
// Import after mocking
import { registerUser, loginUser } from '../auth-actions'
import { signIn } from '@/auth'
import { signIn } from 'next-auth/react'
describe('registerUser', () => {
// Clean up test users after each test

View File

@@ -2,7 +2,8 @@
import { prisma } from './db'
import { revalidatePath } from 'next/cache'
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import {
validateListTitle,
validateItemTitle,
@@ -11,6 +12,11 @@ import {
validateTags,
sanitizeString,
} from './validation'
import {
notifyItemAdded,
notifyItemRemoved,
notifyItemCompleted,
} from './notification-helpers'
// Retry utility for database operations
async function retryOperation<T>(
@@ -49,7 +55,7 @@ async function retryOperation<T>(
export async function getLists() {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to view lists')
}
@@ -109,7 +115,7 @@ export async function getLists() {
export async function createList(title: string, color: string = '#3B82F6') {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to create lists')
}
@@ -157,7 +163,7 @@ export async function getListWithItems(listId: string) {
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to view lists')
}
@@ -228,7 +234,7 @@ export async function getItem(itemId: string) {
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to view items')
}
@@ -305,7 +311,7 @@ export async function createItem(
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to create items')
}
@@ -369,6 +375,14 @@ export async function createItem(
})
)
// Send notification to collaborators
try {
await notifyItemAdded(listId, item.title, userId)
} catch (error) {
console.error('Failed to send notification:', error)
// Don't fail the action if notification fails
}
revalidatePath(`/list/${listId}`)
return item
} catch (error) {
@@ -387,7 +401,7 @@ export async function toggleItem(itemId: string) {
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to update items')
}
@@ -436,6 +450,15 @@ export async function toggleItem(itemId: string) {
})
)
// Send notification when item is completed (not uncompleted)
if (updated.completed) {
try {
await notifyItemCompleted(item.listId, updated.title, userId)
} catch (error) {
console.error('Failed to send notification:', error)
}
}
revalidatePath(`/list/${item.listId}`)
return updated
} catch (error) {
@@ -465,7 +488,7 @@ export async function updateItem(
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to update items')
}
@@ -560,7 +583,7 @@ export async function deleteItem(itemId: string) {
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to delete items')
}
@@ -608,6 +631,13 @@ export async function deleteItem(itemId: string) {
})
)
// Send notification to collaborators
try {
await notifyItemRemoved(item.listId, item.title, userId)
} catch (error) {
console.error('Failed to send notification:', error)
}
revalidatePath(`/list/${item.listId}`)
return { success: true }
} catch (error) {
@@ -626,7 +656,7 @@ export async function deleteList(listId: string) {
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to delete lists')
}
@@ -677,7 +707,7 @@ export async function updateList(
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to update lists')
}
@@ -751,7 +781,7 @@ export async function searchItems(query: string, listId?: string) {
}
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to search items')
}
@@ -878,7 +908,7 @@ export async function getItemsByFilter(
) {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to filter items')
}

View File

@@ -3,7 +3,7 @@
import { prisma } from './db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { signIn, signOut } from '@/auth'
import { signIn, signOut } from 'next-auth/react'
import { checkRateLimit, resetRateLimit, LOGIN_RATE_LIMIT, REGISTER_RATE_LIMIT } from './rate-limit'
import { headers } from 'next/headers'
import { getClientIdentifier } from './rate-limit'
@@ -195,8 +195,9 @@ export async function logoutUser(): Promise<void> {
*/
export async function getCurrentSession() {
try {
const { auth } = await import('@/auth')
const session = await auth()
const { getServerSession } = await import('next-auth/next')
const { authOptions } = await import('@/pages/api/auth/[...nextauth]')
const session = await getServerSession(authOptions)
return session
} catch (error) {
console.error('Session retrieval error:', error)

View File

@@ -4,22 +4,8 @@ const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
// Create Prisma client with connection error handling
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
})
// Handle connection errors
prisma.$connect().catch((error) => {
console.error('Failed to connect to database:', error)
// Don't throw here - let individual queries handle connection errors
})
// Graceful shutdown
if (typeof window === 'undefined') {
process.on('beforeExit', async () => {
await prisma.$disconnect()
})
}
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

View File

@@ -1,7 +1,8 @@
'use server'
import { prisma } from './db'
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
/**
* Migration utilities for transitioning existing anonymous lists to user-owned lists
@@ -71,7 +72,7 @@ export async function getClaimableLists(listIds: string[]): Promise<ClaimableLis
export async function claimLists(listIds: string[]): Promise<MigrationResult> {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return { success: false, error: 'Unauthorized: Please log in to claim lists' }
}
@@ -116,7 +117,7 @@ export async function claimLists(listIds: string[]): Promise<MigrationResult> {
*/
export async function hasExistingLists(): Promise<boolean> {
try {
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return false
}

201
lib/notification-helpers.ts Normal file
View File

@@ -0,0 +1,201 @@
'use server';
import { sendPushNotification, notifyListCollaborators } from '@/lib/push-notifications';
import { prisma } from '@/lib/db';
/**
* Notify when an item is added to a shared list
*/
export async function notifyItemAdded(
listId: string,
itemTitle: string,
addedByUserId: string
) {
const list = await prisma.list.findUnique({
where: { id: listId },
include: { owner: true },
});
if (!list) return;
const addedByUser = await prisma.user.findUnique({
where: { id: addedByUserId },
});
await notifyListCollaborators(
listId,
{
title: `New item in "${list.title}"`,
body: `${addedByUser?.name || 'Someone'} added "${itemTitle}"`,
icon: '/icon-192.svg',
tag: `item-added-${listId}`,
data: {
url: `/list/${listId}`,
listId,
action: 'item-added',
},
},
addedByUserId // Don't notify the person who added it
);
}
/**
* Notify when an item is removed from a shared list
*/
export async function notifyItemRemoved(
listId: string,
itemTitle: string,
removedByUserId: string
) {
const list = await prisma.list.findUnique({
where: { id: listId },
include: { owner: true },
});
if (!list) return;
const removedByUser = await prisma.user.findUnique({
where: { id: removedByUserId },
});
await notifyListCollaborators(
listId,
{
title: `Item removed from "${list.title}"`,
body: `${removedByUser?.name || 'Someone'} removed "${itemTitle}"`,
icon: '/icon-192.svg',
tag: `item-removed-${listId}`,
data: {
url: `/list/${listId}`,
listId,
action: 'item-removed',
},
},
removedByUserId
);
}
/**
* Notify when an item is completed
*/
export async function notifyItemCompleted(
listId: string,
itemTitle: string,
completedByUserId: string
) {
const list = await prisma.list.findUnique({
where: { id: listId },
include: { owner: true },
});
if (!list) return;
const completedByUser = await prisma.user.findUnique({
where: { id: completedByUserId },
});
await notifyListCollaborators(
listId,
{
title: `Item completed in "${list.title}"`,
body: `${completedByUser?.name || 'Someone'} completed "${itemTitle}"`,
icon: '/icon-192.svg',
tag: `item-completed-${listId}`,
data: {
url: `/list/${listId}`,
listId,
action: 'item-completed',
},
},
completedByUserId
);
}
/**
* Notify when invited to a list
*/
export async function notifyListInvitation(
invitationId: string,
inviteeEmail: string
) {
const invitation = await prisma.invitation.findUnique({
where: { id: invitationId },
include: {
list: true,
inviter: true,
},
});
if (!invitation) return;
// Find the invitee user
const invitee = await prisma.user.findUnique({
where: { email: inviteeEmail },
});
if (!invitee) return;
await sendPushNotification(invitee.id, {
title: 'New list invitation',
body: `${invitation.inviter.name || 'Someone'} invited you to "${invitation.list.title}"`,
icon: '/icon-192.svg',
tag: `invitation-${invitationId}`,
data: {
url: '/invitations',
action: 'invitation-received',
},
});
}
/**
* Notify when someone accepts your invitation
*/
export async function notifyInvitationAccepted(
invitationId: string
) {
const invitation = await prisma.invitation.findUnique({
where: { id: invitationId },
include: {
list: true,
receiver: true,
},
});
if (!invitation || !invitation.receiver) return;
await sendPushNotification(invitation.inviterId, {
title: 'Invitation accepted',
body: `${invitation.receiver.name || invitation.email} joined "${invitation.list.title}"`,
icon: '/icon-192.svg',
tag: `invitation-accepted-${invitationId}`,
data: {
url: `/list/${invitation.listId}`,
listId: invitation.listId,
action: 'invitation-accepted',
},
});
}
/**
* Notify when removed from a list
*/
export async function notifyRemovedFromList(
userId: string,
listTitle: string,
removedByUserId: string
) {
const removedByUser = await prisma.user.findUnique({
where: { id: removedByUserId },
});
await sendPushNotification(userId, {
title: 'Removed from list',
body: `${removedByUser?.name || 'The owner'} removed you from "${listTitle}"`,
icon: '/icon-192.svg',
tag: `removed-from-list`,
data: {
url: '/',
action: 'removed-from-list',
},
});
}

191
lib/push-notifications.ts Normal file
View File

@@ -0,0 +1,191 @@
'use server';
import webpush from 'web-push';
import { prisma } from '@/lib/db';
// Configure web-push with VAPID keys
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY && process.env.VAPID_SUBJECT) {
webpush.setVapidDetails(
process.env.VAPID_SUBJECT,
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
}
export interface PushNotificationPayload {
title: string;
body: string;
icon?: string;
badge?: string;
tag?: string;
data?: {
url?: string;
listId?: string;
itemId?: string;
action?: string;
};
}
/**
* Subscribe a user to push notifications
*/
export async function subscribeToPush(userId: string, subscription: PushSubscriptionJSON) {
if (!subscription.endpoint || !subscription.keys?.p256dh || !subscription.keys?.auth) {
throw new Error('Invalid subscription object');
}
try {
await prisma.pushSubscription.upsert({
where: {
userId_endpoint: {
userId,
endpoint: subscription.endpoint,
},
},
create: {
userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
update: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
updatedAt: new Date(),
},
});
return { success: true };
} catch (error) {
console.error('Failed to save push subscription:', error);
throw new Error('Failed to subscribe to push notifications');
}
}
/**
* Unsubscribe a user from push notifications
*/
export async function unsubscribeFromPush(userId: string, endpoint: string) {
try {
await prisma.pushSubscription.deleteMany({
where: {
userId,
endpoint,
},
});
return { success: true };
} catch (error) {
console.error('Failed to remove push subscription:', error);
throw new Error('Failed to unsubscribe from push notifications');
}
}
/**
* Send a push notification to a specific user
*/
export async function sendPushNotification(userId: string, payload: PushNotificationPayload) {
if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) {
console.warn('VAPID keys not configured, skipping push notification');
return { success: false, error: 'Push notifications not configured' };
}
try {
// Get all push subscriptions for the user
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId },
});
if (subscriptions.length === 0) {
return { success: true, sent: 0 };
}
// Send notification to all user's devices
const results = await Promise.allSettled(
subscriptions.map(async (sub) => {
const pushSubscription = {
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
};
try {
await webpush.sendNotification(
pushSubscription,
JSON.stringify(payload),
{
TTL: 60 * 60 * 24, // 24 hours
}
);
return { success: true };
} catch (error: any) {
// Handle expired or invalid subscriptions
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription expired, remove it
await prisma.pushSubscription.delete({
where: { id: sub.id },
});
}
throw error;
}
})
);
const successful = results.filter((r) => r.status === 'fulfilled').length;
return { success: true, sent: successful, total: subscriptions.length };
} catch (error) {
console.error('Failed to send push notification:', error);
return { success: false, error: 'Failed to send notification' };
}
}
/**
* Send notification to all collaborators of a list
*/
export async function notifyListCollaborators(
listId: string,
payload: PushNotificationPayload,
excludeUserId?: string
) {
try {
// Get list owner and collaborators
const list = await prisma.list.findUnique({
where: { id: listId },
include: {
collaborators: {
select: { userId: true },
},
},
});
if (!list) {
return { success: false, error: 'List not found' };
}
// Collect all user IDs (owner + collaborators)
const userIds = [
list.ownerId,
...list.collaborators.map((c) => c.userId),
].filter((id) => id !== excludeUserId); // Exclude the user who triggered the action
// Send notifications to all users
const results = await Promise.allSettled(
userIds.map((userId) => sendPushNotification(userId, payload))
);
const successful = results.filter((r) => r.status === 'fulfilled').length;
return { success: true, notified: successful, total: userIds.length };
} catch (error) {
console.error('Failed to notify list collaborators:', error);
return { success: false, error: 'Failed to send notifications' };
}
}
/**
* Get VAPID public key for client-side subscription
*/
export async function getVapidPublicKey() {
return process.env.VAPID_PUBLIC_KEY || null;
}

View File

@@ -2,9 +2,15 @@
import { prisma } from './db'
import { revalidatePath } from 'next/cache'
import { auth } from '@/auth'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { z } from 'zod'
import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit'
import {
notifyListInvitation,
notifyInvitationAccepted,
notifyRemovedFromList,
} from './notification-helpers'
// Validation schemas
const inviteSchema = z.object({
@@ -57,7 +63,7 @@ export async function inviteUserToList(
) {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to invite users')
}
@@ -162,6 +168,14 @@ export async function inviteUserToList(
})
)
// Send notification to the invitee
try {
await notifyListInvitation(invitation.id, validEmail)
} catch (error) {
console.error('Failed to send invitation notification:', error)
// Don't fail the action if notification fails
}
revalidatePath(`/list/${listId}`)
return invitation
} catch (error) {
@@ -187,7 +201,7 @@ export async function inviteUserToList(
export async function getInvitations() {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id || !session?.user?.email) {
throw new Error('Unauthorized: Please log in to view invitations')
}
@@ -237,7 +251,7 @@ export async function getInvitations() {
export async function acceptInvitation(invitationId: string) {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id || !session?.user?.email) {
throw new Error('Unauthorized: Please log in to accept invitations')
}
@@ -319,6 +333,14 @@ export async function acceptInvitation(invitationId: string) {
})
)
// Notify the person who sent the invitation
try {
await notifyInvitationAccepted(invitationId)
} catch (error) {
console.error('Failed to send acceptance notification:', error)
// Don't fail the action if notification fails
}
revalidatePath('/')
revalidatePath(`/list/${invitation.listId}`)
return result
@@ -345,7 +367,7 @@ export async function acceptInvitation(invitationId: string) {
export async function declineInvitation(invitationId: string) {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id || !session?.user?.email) {
throw new Error('Unauthorized: Please log in to decline invitations')
}
@@ -408,7 +430,7 @@ export async function declineInvitation(invitationId: string) {
export async function getCollaborators(listId: string) {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to view collaborators')
}
@@ -488,7 +510,7 @@ export async function updateCollaboratorPermission(
) {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to update permissions')
}
@@ -566,7 +588,7 @@ export async function removeCollaborator(
) {
try {
// Get current user session
const session = await auth()
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
throw new Error('Unauthorized: Please log in to remove collaborators')
}
@@ -591,6 +613,20 @@ export async function removeCollaborator(
throw new Error('List not found or you do not have permission to remove collaborators')
}
// Get collaborator details before deleting
const collaborator = await retryOperation(() =>
prisma.collaborator.findUnique({
where: { id: collaboratorId },
include: {
user: {
select: {
id: true,
},
},
},
})
)
// Delete the collaborator
await retryOperation(() =>
prisma.collaborator.delete({
@@ -601,6 +637,16 @@ export async function removeCollaborator(
})
)
// Notify the removed user
if (collaborator) {
try {
await notifyRemovedFromList(collaborator.user.id, list.title, userId)
} catch (error) {
console.error('Failed to send removal notification:', error)
// Don't fail the action if notification fails
}
}
revalidatePath(`/list/${listId}`)
return { success: true }
} catch (error) {

View File

@@ -1,110 +0,0 @@
import { auth } from "@/auth"
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
/**
* Middleware for session validation, route protection, and security headers
*
* This middleware:
* - Validates session tokens on protected routes
* - Redirects unauthenticated users to login
* - Handles session expiration
* - Allows access to auth pages for unauthenticated users
* - Adds security headers (CSRF protection, XSS protection, etc.)
*
* Requirements: 3.3, 3.5, 9.4 (CSRF protection), 9.5 (security headers)
*/
export default auth((req) => {
const { nextUrl } = req
const isLoggedIn = !!req.auth?.user
// Define protected routes
const isProtectedRoute =
nextUrl.pathname === "/" ||
nextUrl.pathname.startsWith("/list") ||
nextUrl.pathname.startsWith("/profile") ||
nextUrl.pathname.startsWith("/invitations")
// Define auth pages (login, register, etc.)
const isAuthPage = nextUrl.pathname.startsWith("/auth")
// Create response
let response: NextResponse
// Allow access to auth pages regardless of authentication status
if (isAuthPage) {
// If already logged in and trying to access login/register, redirect to home
if (isLoggedIn && (nextUrl.pathname === "/auth/login" || nextUrl.pathname === "/auth/register")) {
response = NextResponse.redirect(new URL("/", req.url))
} else {
response = NextResponse.next()
}
}
// Redirect to login if accessing protected route without authentication
else if (isProtectedRoute && !isLoggedIn) {
const loginUrl = new URL("/auth/login", req.url)
// Add callback URL to redirect back after login
loginUrl.searchParams.set("callbackUrl", nextUrl.pathname)
response = NextResponse.redirect(loginUrl)
}
// Allow access to public routes
else {
response = NextResponse.next()
}
// Add security headers - Requirement 9.4, 9.5
const headers = response.headers
// CSRF Protection - SameSite cookie attribute is set in auth.ts
// This header provides additional CSRF protection
headers.set('X-Content-Type-Options', 'nosniff')
// XSS Protection
headers.set('X-XSS-Protection', '1; mode=block')
// Prevent clickjacking attacks
headers.set('X-Frame-Options', 'DENY')
// Referrer Policy - don't leak referrer information
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
// Content Security Policy - helps prevent XSS attacks
// Note: This is a basic CSP. You may need to adjust based on your needs
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-inline and unsafe-eval needed for Next.js
"style-src 'self' 'unsafe-inline'", // unsafe-inline needed for Tailwind
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
].join('; ')
headers.set('Content-Security-Policy', csp)
// Permissions Policy - restrict access to browser features
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
return response
})
/**
* Matcher configuration for middleware
* Applies middleware to all routes except:
* - API routes (handled separately)
* - Static files (_next/static)
* - Image optimization (_next/image)
* - Favicon and other public assets
*/
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public assets (images, icons, manifest, sw.js)
*/
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|json|js)$).*)",
],
}

View File

@@ -8,16 +8,18 @@ const nextConfig = {
exclude: ['error', 'warn'],
} : false,
},
// Enable SWC minification for better performance
swcMinify: true,
// Optimize images
images: {
formats: ['image/avif', 'image/webp'],
},
// Enable compression
compress: true,
// Production source maps (smaller)
productionBrowserSourceMaps: false,
trailingSlash: false,
poweredByHeader: false,
// Optimize for mobile Safari
reactStrictMode: true,
// Enable optimized font loading
optimizeFonts: true,
}
module.exports = nextConfig

1438
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,8 @@
"db:studio": "prisma studio",
"db:migrate": "npx tsx scripts/migrate-lists.ts",
"icons:generate": "node scripts/generate-icons.js",
"icons:info": "node scripts/svg-to-png.js"
"icons:info": "node scripts/svg-to-png.js",
"vapid:generate": "node scripts/generate-vapid-keys.js"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
@@ -22,9 +23,10 @@
"lucide-react": "^0.294.0",
"mysql2": "^3.15.3",
"next": "14.0.4",
"next-auth": "^5.0.0-beta.30",
"next-auth": "^4.24.5",
"react": "^18",
"react-dom": "^18",
"web-push": "^3.6.7",
"zod": "^4.1.13"
},
"devDependencies": {
@@ -32,6 +34,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/web-push": "^3.6.3",
"@vitest/ui": "^4.0.14",
"autoprefixer": "^10.0.1",
"eslint": "^8",

View File

@@ -0,0 +1,54 @@
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { prisma } from "@/lib/db"
import bcrypt from "bcryptjs"
import { z } from "zod"
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
export const authOptions = {
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
try {
const { email, password } = loginSchema.parse(credentials)
const user = await prisma.user.findUnique({ where: { email } })
if (!user || !user.passwordHash) return null
const isValid = await bcrypt.compare(password, user.passwordHash)
if (!isValid) return null
return { id: user.id, email: user.email, name: user.name }
} catch {
return null
}
},
}),
],
session: { strategy: "jwt" },
pages: {
signIn: "/auth/login",
error: "/auth/error",
},
callbacks: {
async jwt({ token, user }) {
if (user) token.id = user.id
return token
},
async session({ session, token }) {
if (token.id) session.user.id = token.id
return session
},
},
}
export default NextAuth(authOptions)

View File

@@ -8,20 +8,35 @@ datasource db {
}
model User {
id String @id @default(cuid())
email String @unique
id String @id @default(cuid())
email String @unique
name String?
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ownedLists List[] @relation("ListOwner")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ownedLists List[] @relation("ListOwner")
collaborations Collaborator[]
sentInvitations Invitation[] @relation("InvitationInviter")
receivedInvitations Invitation[] @relation("InvitationReceiver")
sentInvitations Invitation[] @relation("InvitationInviter")
receivedInvitations Invitation[] @relation("InvitationReceiver")
pushSubscriptions PushSubscription[]
@@index([email])
}
model PushSubscription {
id String @id @default(cuid())
userId String
endpoint String @db.VarChar(512)
p256dh String @db.Text
auth String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, endpoint])
@@index([userId])
}
model List {
id String @id @default(cuid())
title String

View File

@@ -1,7 +1,8 @@
{
"name": "Ultimate Lists",
"short_name": "Lists",
"description": "The ultimate todo list app with advanced task management",
"description": "The ultimate todo list app with advanced task management and continuous task input",
"version": "1.0.2",
"start_url": "/",
"scope": "/",
"display": "standalone",

View File

@@ -1,8 +1,8 @@
// Service Worker for Ultimate Lists App
// Implements caching strategies for offline functionality
const CACHE_NAME = 'ultimate-lists-v2';
const RUNTIME_CACHE = 'ultimate-lists-runtime-v2';
const CACHE_NAME = 'ultimate-lists-v8';
const RUNTIME_CACHE = 'ultimate-lists-runtime-v8';
// Static assets to cache on install
const STATIC_ASSETS = [
@@ -60,6 +60,11 @@ self.addEventListener('fetch', (event) => {
if (!url.protocol.startsWith('http')) {
return;
}
// Skip cross-origin requests for performance
if (url.origin !== self.location.origin) {
return;
}
// Handle authentication-related requests
if (isAuthRequest(url)) {
@@ -275,3 +280,53 @@ self.addEventListener('message', (event) => {
}
}
});
// Push notification event - receive and display notifications
self.addEventListener('push', (event) => {
if (!event.data) {
return;
}
try {
const data = event.data.json();
const { title, body, icon, badge, tag, data: notificationData } = data;
const options = {
body,
icon: icon || '/icon-192.svg',
badge: badge || '/icon-192.svg',
tag: tag || 'default',
data: notificationData,
vibrate: [200, 100, 200],
requireInteraction: false,
};
event.waitUntil(
self.registration.showNotification(title, options)
);
} catch (error) {
console.error('Failed to show notification:', error);
}
});
// Notification click event - handle user interaction
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Check if there's already a window open
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open a new window if none exists
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
/**
* Generate VAPID keys for Web Push notifications
* Run: node scripts/generate-vapid-keys.js
*/
const webpush = require('web-push');
console.log('Generating VAPID keys for Web Push notifications...\n');
const vapidKeys = webpush.generateVAPIDKeys();
console.log('✓ VAPID keys generated successfully!\n');
console.log('Add these to your .env file:\n');
console.log('# Web Push Notifications (VAPID)');
console.log(`VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);
console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
console.log('VAPID_SUBJECT=mailto:your-email@example.com');
console.log('\n⚠️ Keep the private key secret! Do not commit it to version control.\n');