Compare commits
80 Commits
0e609af924
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| da593e052b | |||
| 37955f95ab | |||
| 466bce862e | |||
| 3043293fa7 | |||
| 1838502b8b | |||
| 6508e27391 | |||
| 15b9f196f5 | |||
| 1c5e623c7b | |||
| 298ebc5fb9 | |||
| 374bfb3a62 | |||
| e095568fbf | |||
| a02b9438af | |||
| 3a1a1626fe | |||
| f49f62d659 | |||
| 8e7440166f | |||
| 3f1abd527e | |||
| 7cda58440f | |||
| 625ca40e48 | |||
| 021bbf1335 | |||
| 737049dc69 | |||
| 2f2cc309a3 | |||
| 1e7aa6ae5d | |||
| 509de75e1a | |||
| bfe49e7211 | |||
| 2640559de8 | |||
| 4422498bc9 | |||
| 376b8cead9 | |||
| 66f8fcade7 | |||
| 540f5dfc89 | |||
| eae3723e66 | |||
| 5545caa1c3 | |||
| 6c5f2fbc1e | |||
| bdff38c9a5 | |||
| 79026733cb | |||
| 865c2a85de | |||
| 72f98c783a | |||
| fc5ad9f7af | |||
| bd61aaa5b2 | |||
| 35e20fb16d | |||
| cbf6f3c6b6 | |||
| e6067e76cd | |||
| 78bfcb7ac3 | |||
| 0fc8c719b9 | |||
| 78901c8e2e | |||
| ade7ad19d6 | |||
| d92f13fdc4 | |||
| 909f51ff6b | |||
| d7297248c1 | |||
| 2f7bc4fe8e | |||
| 40b168b6d8 | |||
| fe783301e7 | |||
| 60ebf13a9c | |||
| 3a3db4195f | |||
| c0358d79d7 | |||
| ff16860d8a | |||
| 855abb98b7 | |||
| b9b28ad92c | |||
| 9f25b30083 | |||
| adb4f6b119 | |||
| 9bd4dfe0e1 | |||
| f3d9ccde56 | |||
| ce7021e3fa | |||
| d677fcb9b5 | |||
| b1705fa6da | |||
| a084853b6c | |||
| 39401a3fa2 | |||
| 899ba97457 | |||
| 1c0e0a097e | |||
| fc06003701 | |||
| 8c9e1a76d1 | |||
| 92401c5324 | |||
| c51bc28d67 | |||
| f1cbd88761 | |||
| 9ebb2786b3 | |||
| 86ef3c41d5 | |||
| c5182e3b99 | |||
| 37e0620caf | |||
| 0d26087049 | |||
| b2a59eecf1 | |||
| 11c4411f68 |
17
.dockerignore
Normal file
17
.dockerignore
Normal 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
18
.env
Normal 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
|
||||||
@@ -7,3 +7,10 @@ NEXTAUTH_URL=http://localhost:3000
|
|||||||
# Generate a secure secret with: openssl rand -base64 32
|
# Generate a secure secret with: openssl rand -base64 32
|
||||||
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
|
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
|
||||||
AUTH_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
6
.gitignore
vendored
@@ -23,6 +23,7 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
|
# .env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
# Vercel
|
# Vercel
|
||||||
@@ -30,7 +31,4 @@ yarn-error.log*
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
@@ -226,6 +226,14 @@
|
|||||||
- [x] 19. Final checkpoint - Ensure all tests pass
|
- [x] 19. Final checkpoint - Ensure all tests pass
|
||||||
- Ensure all tests pass, ask the user if questions arise.
|
- 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
|
- [ ] 20. End-to-end testing and polish
|
||||||
- Test complete user flows from list creation to task completion
|
- Test complete user flows from list creation to task completion
|
||||||
- Verify PWA installation on iOS Safari
|
- Verify PWA installation on iOS Safari
|
||||||
|
|||||||
115
DOCKER_PUSH_NOTIFICATIONS_SETUP.md
Normal file
115
DOCKER_PUSH_NOTIFICATIONS_SETUP.md
Normal 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
153
DOCKER_SETUP.md
Normal 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
18
Dockerfile
Normal 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
38
ITS_WORKING_HERE.md
Normal 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.
|
||||||
264
PUSH_NOTIFICATIONS_COMPLETE.md
Normal file
264
PUSH_NOTIFICATIONS_COMPLETE.md
Normal 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!
|
||||||
81
PUSH_NOTIFICATIONS_DOCKER_CHECKLIST.md
Normal file
81
PUSH_NOTIFICATIONS_DOCKER_CHECKLIST.md
Normal 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
|
||||||
|
```
|
||||||
97
PUSH_NOTIFICATIONS_GUIDE.md
Normal file
97
PUSH_NOTIFICATIONS_GUIDE.md
Normal 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
|
||||||
183
PUSH_NOTIFICATIONS_IMPLEMENTED.md
Normal file
183
PUSH_NOTIFICATIONS_IMPLEMENTED.md
Normal 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
|
||||||
338
PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md
Normal file
338
PUSH_NOTIFICATIONS_INTEGRATION_EXAMPLE.md
Normal 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.
|
||||||
82
PUSH_NOTIFICATIONS_QUICK_START.md
Normal file
82
PUSH_NOTIFICATIONS_QUICK_START.md
Normal 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
272
PUSH_NOTIFICATIONS_SETUP.md
Normal 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
168
SAFARI_PERFORMANCE_FIX.md
Normal 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
|
||||||
83
SETUP_PUSH_NOTIFICATIONS.md
Normal file
83
SETUP_PUSH_NOTIFICATIONS.md
Normal 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`
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { handlers } from "@/auth"
|
|
||||||
|
|
||||||
export const { GET, POST } = handlers
|
|
||||||
83
app/api/lists/[id]/last-updated/route.ts
Normal file
83
app/api/lists/[id]/last-updated/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/push/subscribe/route.ts
Normal file
33
app/api/push/subscribe/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/push/unsubscribe/route.ts
Normal file
33
app/api/push/unsubscribe/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/api/push/vapid-key/route.ts
Normal file
23
app/api/push/vapid-key/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,17 @@ mark {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.animate-slide-in-right {
|
.animate-slide-in-right {
|
||||||
animation: slide-in-right 0.3s ease-out;
|
animation: slide-in-right 0.3s ease-out;
|
||||||
}
|
}
|
||||||
@@ -75,6 +86,10 @@ mark {
|
|||||||
animation: scale-in 0.2s ease-out;
|
animation: scale-in 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* Smooth transitions for interactive elements */
|
/* Smooth transitions for interactive elements */
|
||||||
.transition-smooth {
|
.transition-smooth {
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
@@ -104,4 +119,19 @@ mark {
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
user-select: none;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 { redirect } from 'next/navigation'
|
||||||
import { getInvitations } from '@/lib/sharing-actions'
|
import { getInvitations } from '@/lib/sharing-actions'
|
||||||
import InvitationCard from '@/components/InvitationCard'
|
import InvitationCard from '@/components/InvitationCard'
|
||||||
@@ -27,7 +28,7 @@ type Invitation = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function InvitationsPage() {
|
export default async function InvitationsPage() {
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
redirect('/auth/login')
|
redirect('/auth/login')
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
||||||
import OfflineIndicator from '@/components/OfflineIndicator'
|
import OfflineIndicator from '@/components/OfflineIndicator'
|
||||||
|
import NotificationPrompt from '@/components/NotificationPrompt'
|
||||||
import { ToastProvider } from '@/components/ToastContainer'
|
import { ToastProvider } from '@/components/ToastContainer'
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||||
|
|
||||||
@@ -9,14 +10,6 @@ export const metadata: Metadata = {
|
|||||||
title: 'Ultimate Lists',
|
title: 'Ultimate Lists',
|
||||||
description: 'The ultimate todo list app with advanced task management',
|
description: 'The ultimate todo list app with advanced task management',
|
||||||
manifest: '/manifest.json',
|
manifest: '/manifest.json',
|
||||||
themeColor: '#3B82F6',
|
|
||||||
viewport: {
|
|
||||||
width: 'device-width',
|
|
||||||
initialScale: 1,
|
|
||||||
maximumScale: 1,
|
|
||||||
userScalable: false,
|
|
||||||
viewportFit: 'cover',
|
|
||||||
},
|
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: 'black-translucent',
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -65,7 +67,8 @@ export default function RootLayout({
|
|||||||
<meta name="theme-color" content="#3B82F6" />
|
<meta name="theme-color" content="#3B82F6" />
|
||||||
<meta name="background-color" content="#ffffff" />
|
<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" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-gray-50 min-h-screen">
|
<body className="bg-gray-50 min-h-screen">
|
||||||
@@ -73,6 +76,7 @@ export default function RootLayout({
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
|
<NotificationPrompt />
|
||||||
{children}
|
{children}
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { getItem } from '@/lib/actions'
|
import { getItem } from '@/lib/actions'
|
||||||
import { canEditList } from '@/lib/permissions'
|
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 ItemForm from '@/components/ItemForm'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
@@ -16,7 +17,7 @@ interface PageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function EditItemPage({ params }: PageProps) {
|
export default async function EditItemPage({ params }: PageProps) {
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { getItem } from '@/lib/actions'
|
import { getItem } from '@/lib/actions'
|
||||||
import { canEditList } from '@/lib/permissions'
|
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 ItemDetail from '@/components/ItemDetail'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft, Edit } from 'lucide-react'
|
import { ArrowLeft, Edit } from 'lucide-react'
|
||||||
@@ -18,7 +19,7 @@ interface PageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ItemDetailPage({ params }: PageProps) {
|
export default async function ItemDetailPage({ params }: PageProps) {
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getListWithItems } from '@/lib/actions'
|
import { getListWithItems } from '@/lib/actions'
|
||||||
import { getPermission } from '@/lib/permissions'
|
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 { notFound } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
@@ -8,9 +9,10 @@ import InlineItemCreate from '@/components/InlineItemCreate'
|
|||||||
import ListDetailView from '@/components/ListDetailView'
|
import ListDetailView from '@/components/ListDetailView'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const revalidate = 0
|
||||||
|
|
||||||
export default async function ListDetailPage({ params }: { params: { id: string } }) {
|
export default async function ListDetailPage({ params }: { params: { id: string } }) {
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,25 @@ export default async function ListDetailPage({ params }: { params: { id: string
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Inline Item Creation - only show if user can edit */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
@@ -63,7 +84,7 @@ export default async function ListDetailPage({ params }: { params: { id: string
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search, Filter, and Items List */}
|
{/* Items List */}
|
||||||
<ListDetailView
|
<ListDetailView
|
||||||
listId={list.id}
|
listId={list.id}
|
||||||
initialItems={list.items}
|
initialItems={list.items}
|
||||||
@@ -77,6 +98,7 @@ export default async function ListDetailPage({ params }: { params: { id: string
|
|||||||
...c,
|
...c,
|
||||||
permission: c.permission as 'editor' | 'viewer'
|
permission: c.permission as 'editor' | 'viewer'
|
||||||
}))}
|
}))}
|
||||||
|
showOnlyItems={true}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
14
app/page.tsx
14
app/page.tsx
@@ -3,14 +3,23 @@ import ListCard from '@/components/ListCard'
|
|||||||
import NewListButton from '@/components/NewListButton'
|
import NewListButton from '@/components/NewListButton'
|
||||||
import GlobalSearch from '@/components/GlobalSearch'
|
import GlobalSearch from '@/components/GlobalSearch'
|
||||||
import EmptyState from '@/components/EmptyState'
|
import EmptyState from '@/components/EmptyState'
|
||||||
|
import LogoutButton from '@/components/LogoutButton'
|
||||||
import Link from 'next/link'
|
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 dynamic = 'force-dynamic'
|
||||||
|
export const revalidate = 0
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect('/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
const lists = await getLists()
|
const lists = await getLists()
|
||||||
const session = await auth()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-7xl">
|
<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
|
Profile
|
||||||
</Link>
|
</Link>
|
||||||
|
<LogoutButton />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<NewListButton />
|
<NewListButton />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { redirect } from 'next/navigation'
|
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 { getUserProfile } from '@/lib/auth-actions'
|
||||||
import ProfileForm from '@/components/ProfileForm'
|
import ProfileForm from '@/components/ProfileForm'
|
||||||
import PasswordChangeForm from '@/components/PasswordChangeForm'
|
import PasswordChangeForm from '@/components/PasswordChangeForm'
|
||||||
|
import NotificationSettings from '@/components/NotificationSettings'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -11,7 +13,7 @@ export const metadata = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
redirect('/auth/login')
|
redirect('/auth/login')
|
||||||
@@ -81,6 +83,11 @@ export default async function ProfilePage() {
|
|||||||
<PasswordChangeForm userId={userProfile.id} />
|
<PasswordChangeForm userId={userProfile.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Notification Settings */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<NotificationSettings />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Account Information */}
|
{/* Account Information */}
|
||||||
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8">
|
<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">
|
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-4">
|
||||||
|
|||||||
@@ -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
102
auth.ts
@@ -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",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { deleteItem } from '@/lib/actions'
|
import { deleteItem } from '@/lib/actions'
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
@@ -17,19 +17,25 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
|
|||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent) => {
|
const handleDelete = async (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteItem(itemId)
|
await deleteItem(itemId)
|
||||||
showToast('Task deleted successfully', 'success')
|
showToast('Task deleted successfully', 'success')
|
||||||
if (listId) {
|
|
||||||
router.push(`/list/${listId}`)
|
// Reset state immediately
|
||||||
} else {
|
setShowConfirm(false)
|
||||||
|
setIsDeleting(false)
|
||||||
|
|
||||||
|
// Use startTransition to refresh the route
|
||||||
|
startTransition(() => {
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting item:', error)
|
console.error('Error deleting item:', error)
|
||||||
showToast('Failed to delete task', '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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
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"
|
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
|
Cancel
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { createItem } from '@/lib/actions'
|
import { createItem } from '@/lib/actions'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
@@ -18,6 +18,20 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -37,16 +51,31 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
|||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createItem(listId, sanitizeString(title))
|
// Clear form immediately for better UX
|
||||||
showToast('Task added successfully', 'success')
|
const itemTitle = sanitizeString(title)
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setIsCreating(false)
|
setKeepFormOpen(true) // Mark that we want to keep the form open
|
||||||
router.refresh()
|
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Error creating item:', error)
|
console.error('Error creating item:', error)
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to add task'
|
const errorMessage = error instanceof Error ? error.message : 'Failed to add task'
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
showToast(errorMessage, 'error')
|
showToast(errorMessage, 'error')
|
||||||
|
// Restore form on error
|
||||||
|
setIsCreating(true)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -55,6 +84,11 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setIsCreating(false)
|
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) => {
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreating(true)}
|
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">
|
<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">
|
<div className="mb-3 sm:mb-4">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -86,10 +130,11 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
|||||||
setError('')
|
setError('')
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleBlur}
|
||||||
placeholder="Enter task title..."
|
placeholder="Enter task title..."
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={200}
|
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'
|
error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
|
||||||
}`}
|
}`}
|
||||||
aria-invalid={error ? 'true' : 'false'}
|
aria-invalid={error ? 'true' : 'false'}
|
||||||
|
|||||||
@@ -33,24 +33,33 @@ function ItemCard({ item, listId }: ItemCardProps) {
|
|||||||
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium
|
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium
|
||||||
|
|
||||||
return (
|
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="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-start gap-3 sm:gap-4">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
{/* Completion Toggle */}
|
{/* 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" />
|
<ToggleItemButton itemId={item.id} completed={item.completed} variant="icon" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Link href={`/list/${listId}/item/${item.id}`} className="block">
|
<Link href={`/list/${listId}/item/${item.id}`} className="block">
|
||||||
<h3 className={`font-medium mb-1 text-sm sm:text-base ${
|
<div className="flex items-center justify-between gap-2">
|
||||||
item.completed ? 'text-gray-500 line-through' : 'text-gray-900'
|
<h3 className={`font-medium text-sm sm:text-base ${
|
||||||
}`}>
|
item.completed ? 'text-gray-500 line-through' : 'text-gray-900'
|
||||||
{item.title}
|
}`}>
|
||||||
</h3>
|
{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 && (
|
{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.completed ? 'text-gray-400' : 'text-gray-600'
|
||||||
}`}>
|
}`}>
|
||||||
{item.description}
|
{item.description}
|
||||||
@@ -58,42 +67,37 @@ function ItemCard({ item, listId }: ItemCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-2">
|
{(item.dueDate || tags.length > 0) && (
|
||||||
{/* Priority Badge */}
|
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5">
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium border ${priorityColor}`}>
|
{/* Due Date */}
|
||||||
<AlertCircle size={12} className="flex-shrink-0" />
|
{item.dueDate && (
|
||||||
<span className="hidden xs:inline sm:inline">{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}</span>
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs whitespace-nowrap ${
|
||||||
<span className="xs:hidden sm:hidden">{item.priority.charAt(0).toUpperCase()}</span>
|
isOverdue
|
||||||
</span>
|
? 'bg-red-100 text-red-700 border border-red-200 font-semibold'
|
||||||
|
: 'bg-gray-100 text-gray-700'
|
||||||
{/* Due Date */}
|
}`}>
|
||||||
{item.dueDate && (
|
<Calendar size={12} className="flex-shrink-0" />
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs whitespace-nowrap ${
|
{new Date(item.dueDate).toLocaleDateString('en-US', {
|
||||||
isOverdue
|
month: 'short',
|
||||||
? 'bg-red-100 text-red-700 border border-red-200 font-semibold'
|
day: 'numeric',
|
||||||
: 'bg-gray-100 text-gray-700'
|
})}
|
||||||
}`}>
|
{isOverdue && <span className="hidden sm:inline"> (Overdue)</span>}
|
||||||
<Calendar size={12} className="flex-shrink-0" />
|
{isOverdue && <span className="sm:hidden">!</span>}
|
||||||
{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}`}
|
|
||||||
</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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
|
|||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
onBlur={() => handleBlur('title')}
|
onBlur={() => handleBlur('title')}
|
||||||
disabled={!canEdit}
|
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
|
errors.title && touched.title
|
||||||
? 'border-red-500 focus:ring-red-500'
|
? 'border-red-500 focus:ring-red-500'
|
||||||
: 'border-gray-300 focus:ring-blue-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')}
|
onBlur={() => handleBlur('description')}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={3}
|
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
|
errors.description && touched.description
|
||||||
? 'border-red-500 focus:ring-red-500'
|
? 'border-red-500 focus:ring-red-500'
|
||||||
: 'border-gray-300 focus:ring-blue-500'
|
: 'border-gray-300 focus:ring-blue-500'
|
||||||
@@ -320,7 +320,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
|
|||||||
value={priority}
|
value={priority}
|
||||||
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
|
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
|
||||||
disabled={!canEdit}
|
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="low">Low</option>
|
||||||
<option value="medium">Medium</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)}
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
onBlur={() => handleBlur('dueDate')}
|
onBlur={() => handleBlur('dueDate')}
|
||||||
disabled={!canEdit}
|
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
|
errors.dueDate && touched.dueDate
|
||||||
? 'border-red-500 focus:ring-red-500'
|
? 'border-red-500 focus:ring-red-500'
|
||||||
: 'border-gray-300 focus:ring-blue-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)}
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
disabled={!canEdit}
|
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
|
errors.tagInput
|
||||||
? 'border-red-500 focus:ring-red-500'
|
? 'border-red-500 focus:ring-red-500'
|
||||||
: 'border-gray-300 focus:ring-blue-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')}
|
onBlur={() => handleBlur('notes')}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
rows={5}
|
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
|
errors.notes && touched.notes
|
||||||
? 'border-red-500 focus:ring-red-500'
|
? 'border-red-500 focus:ring-red-500'
|
||||||
: 'border-gray-300 focus:ring-blue-500'
|
: 'border-gray-300 focus:ring-blue-500'
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Share2 } from 'lucide-react'
|
import { Share2 } from 'lucide-react'
|
||||||
import ItemCard from './ItemCard'
|
import ItemCard from './ItemCard'
|
||||||
import SearchBar from './SearchBar'
|
|
||||||
import FilterPanel from './FilterPanel'
|
|
||||||
import SearchResults from './SearchResults'
|
|
||||||
import EmptyState from './EmptyState'
|
import EmptyState from './EmptyState'
|
||||||
import ShareListModal from './ShareListModal'
|
import ShareListModal from './ShareListModal'
|
||||||
import PermissionBadge from './PermissionBadge'
|
import PermissionBadge from './PermissionBadge'
|
||||||
import { searchItems, getItemsByFilter } from '@/lib/actions'
|
import { useScrollPosition } from '@/hooks'
|
||||||
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
|
import { useRealtimeSync } from '@/hooks/useRealtimeSync'
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
id: string
|
id: string
|
||||||
@@ -26,14 +23,6 @@ interface Item {
|
|||||||
listId: string
|
listId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ItemWithList extends Item {
|
|
||||||
list: {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ListDetailViewProps {
|
interface ListDetailViewProps {
|
||||||
listId: string
|
listId: string
|
||||||
initialItems: Item[]
|
initialItems: Item[]
|
||||||
@@ -52,6 +41,8 @@ interface ListDetailViewProps {
|
|||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
|
showOnlyHeader?: boolean
|
||||||
|
showOnlyItems?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ListDetailView({
|
export default function ListDetailView({
|
||||||
@@ -63,131 +54,72 @@ export default function ListDetailView({
|
|||||||
ownerId = '',
|
ownerId = '',
|
||||||
ownerName = null,
|
ownerName = null,
|
||||||
ownerEmail = '',
|
ownerEmail = '',
|
||||||
collaborators = []
|
collaborators = [],
|
||||||
|
showOnlyHeader = false,
|
||||||
|
showOnlyItems = false
|
||||||
}: ListDetailViewProps) {
|
}: ListDetailViewProps) {
|
||||||
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
|
||||||
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
|
|
||||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
||||||
|
|
||||||
// Use scroll position preservation hook
|
// Conditionally use hooks based on view mode
|
||||||
useScrollPosition({ key: `list-${listId}` })
|
const shouldUseHooks = !showOnlyHeader
|
||||||
|
|
||||||
|
// Use scroll position preservation hook (only for items view)
|
||||||
|
useScrollPosition({ key: shouldUseHooks ? `list-${listId}` : 'disabled' })
|
||||||
|
|
||||||
// Extract all unique tags from items
|
// Use real-time sync hook to poll for updates (only for items view)
|
||||||
const availableTags = useMemo(() => {
|
useRealtimeSync({
|
||||||
const tagSet = new Set<string>()
|
listId,
|
||||||
initialItems.forEach(item => {
|
enabled: shouldUseHooks,
|
||||||
const tags = Array.isArray(item.tags) ? item.tags : []
|
pollInterval: 1000, // Poll every 1 second for faster updates
|
||||||
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 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
|
// Determine which items to display
|
||||||
const displayItems = debouncedQuery.trim() ? searchResults : filteredItems
|
const completedItems = useMemo(() => initialItems.filter(item => item.completed), [initialItems])
|
||||||
const completedItems = displayItems.filter(item => item.completed)
|
const incompleteItems = useMemo(() => initialItems.filter(item => !item.completed), [initialItems])
|
||||||
const incompleteItems = displayItems.filter(item => !item.completed)
|
|
||||||
|
|
||||||
return (
|
// If showing only header, return just the permission badge and share button
|
||||||
<div className="space-y-6">
|
if (showOnlyHeader) {
|
||||||
{/* Permission Badge and Share Button */}
|
return (
|
||||||
{(permission || canShare) && (
|
<>
|
||||||
<div className="flex items-center justify-between gap-4">
|
{(permission || canShare) && (
|
||||||
{permission && (
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
{permission && (
|
||||||
<PermissionBadge permission={permission} />
|
<div>
|
||||||
</div>
|
<PermissionBadge permission={permission} />
|
||||||
)}
|
</div>
|
||||||
{canShare && (
|
)}
|
||||||
<button
|
{canShare && (
|
||||||
onClick={() => setIsShareModalOpen(true)}
|
<button
|
||||||
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"
|
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
|
<Share2 size={16} />
|
||||||
</button>
|
Share
|
||||||
)}
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
{/* Search Bar */}
|
|
||||||
<SearchBar
|
{/* Share Modal */}
|
||||||
onSearch={handleSearchCallback}
|
<ShareListModal
|
||||||
placeholder="Search tasks in this list..."
|
isOpen={isShareModalOpen}
|
||||||
/>
|
onClose={() => setIsShareModalOpen(false)}
|
||||||
|
listId={listId}
|
||||||
{/* Filter Panel */}
|
listTitle={listTitle}
|
||||||
<FilterPanel
|
isOwner={permission === 'owner'}
|
||||||
filters={filters}
|
ownerId={ownerId}
|
||||||
onFiltersChange={setFilters}
|
ownerName={ownerName}
|
||||||
availableTags={availableTags}
|
ownerEmail={ownerEmail}
|
||||||
/>
|
initialCollaborators={collaborators}
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{debouncedQuery.trim() ? (
|
|
||||||
<SearchResults
|
|
||||||
results={searchResults}
|
|
||||||
query={debouncedQuery}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</>
|
||||||
<div className="space-y-6">
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If showing only items, return just the items list
|
||||||
|
if (showOnlyItems) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
{/* Incomplete Items */}
|
{/* Incomplete Items */}
|
||||||
{incompleteItems.length > 0 && (
|
{incompleteItems.length > 0 && (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
@@ -217,20 +149,80 @@ export default function ListDetailView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{displayItems.length === 0 && (
|
{initialItems.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={filters.priority || filters.completed !== null || filters.tags.length > 0 ? 'filter' : 'inbox'}
|
icon="inbox"
|
||||||
title="No tasks found"
|
title="No tasks found"
|
||||||
description={
|
description="Add your first task using the button above"
|
||||||
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'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</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>
|
</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 */}
|
{/* Share Modal */}
|
||||||
<ShareListModal
|
<ShareListModal
|
||||||
isOpen={isShareModalOpen}
|
isOpen={isShareModalOpen}
|
||||||
|
|||||||
21
components/LogoutButton.tsx
Normal file
21
components/LogoutButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -42,15 +42,20 @@ export default function Modal({ isOpen, onClose, title, children }: ModalProps)
|
|||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity backdrop-blur-sm"
|
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity backdrop-blur-sm"
|
||||||
onClick={onClose}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* 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">
|
<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>
|
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2>
|
||||||
<button
|
<button
|
||||||
|
|||||||
35
components/NotificationDebug.tsx
Normal file
35
components/NotificationDebug.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
components/NotificationPrompt.tsx
Normal file
113
components/NotificationPrompt.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/NotificationSettings.tsx
Normal file
76
components/NotificationSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useOfflineSync } from '@/hooks'
|
import { useOfflineSync } from '@/hooks'
|
||||||
import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
|
import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
@@ -9,8 +10,18 @@ import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
|
|||||||
* Validates: Requirements 4.5
|
* Validates: Requirements 4.5
|
||||||
*/
|
*/
|
||||||
export default function OfflineIndicator() {
|
export default function OfflineIndicator() {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
const { isOnline, isSyncing, syncError, queueLength, triggerSync } = useOfflineSync()
|
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
|
// Don't show anything if online and no pending syncs
|
||||||
if (isOnline && queueLength === 0 && !syncError) {
|
if (isOnline && queueLength === 0 && !syncError) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
22
components/RealtimeUpdater.tsx
Normal file
22
components/RealtimeUpdater.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { memo } from 'react'
|
import { memo, useState, useEffect } from 'react'
|
||||||
import { Search, X } from 'lucide-react'
|
import { Search, X } from 'lucide-react'
|
||||||
import { useSearch } from '@/hooks'
|
import { useSearch } from '@/hooks'
|
||||||
|
|
||||||
@@ -17,18 +17,25 @@ function SearchBar({
|
|||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
initialValue = ''
|
initialValue = ''
|
||||||
}: SearchBarProps) {
|
}: SearchBarProps) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
const { query, setQuery, clearSearch, isDebouncing } = useSearch({
|
const { query, setQuery, clearSearch, isDebouncing } = useSearch({
|
||||||
debounceMs,
|
debounceMs,
|
||||||
onSearch
|
onSearch
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search
|
{mounted && (
|
||||||
className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
<Search
|
||||||
size={20}
|
className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||||
/>
|
size={20}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
@@ -36,7 +43,7 @@ function SearchBar({
|
|||||||
placeholder={placeholder}
|
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"
|
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
|
<button
|
||||||
onClick={clearSearch}
|
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"
|
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} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isDebouncing && (
|
{mounted && isDebouncing && (
|
||||||
<div className="absolute right-12 sm:right-14 top-1/2 -translate-y-1/2">
|
<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 className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
|
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
|
||||||
|
|
||||||
export type ToastType = 'success' | 'error' | 'info'
|
export type ToastType = 'success' | 'error' | 'info'
|
||||||
@@ -13,12 +13,26 @@ interface ToastProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Toast({ message, type, onClose, duration = 3000 }: 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(() => {
|
useEffect(() => {
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
const timer = setTimeout(onClose, duration)
|
console.log(`[Toast] Setting timer for ${duration}ms`)
|
||||||
return () => clearTimeout(timer)
|
const timer = setTimeout(() => {
|
||||||
|
console.log('[Toast] Auto-dismissing')
|
||||||
|
onCloseRef.current()
|
||||||
|
}, duration)
|
||||||
|
return () => {
|
||||||
|
console.log('[Toast] Clearing timer')
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [duration, onClose])
|
}, [duration])
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
success: <CheckCircle size={20} className="flex-shrink-0" />,
|
success: <CheckCircle size={20} className="flex-shrink-0" />,
|
||||||
|
|||||||
@@ -27,11 +27,17 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
||||||
|
|
||||||
const showToast = useCallback((message: string, type: ToastType) => {
|
const showToast = useCallback((message: string, type: ToastType) => {
|
||||||
const id = Math.random().toString(36).substring(7)
|
const id = `${Date.now()}-${Math.random().toString(36).substring(7)}`
|
||||||
setToasts((prev) => [...prev, { id, message, type }])
|
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) => {
|
const removeToast = useCallback((id: string) => {
|
||||||
|
console.log('[ToastContainer] Removing toast:', id)
|
||||||
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useOptimistic } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { toggleItem } from '@/lib/actions'
|
import { toggleItem } from '@/lib/actions'
|
||||||
import { CheckCircle2, Circle } from 'lucide-react'
|
import { CheckCircle2, Circle } from 'lucide-react'
|
||||||
import { useToast } from './ToastContainer'
|
import { useToast } from './ToastContainer'
|
||||||
@@ -12,19 +13,31 @@ interface ToggleItemButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) {
|
export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) {
|
||||||
|
const router = useRouter()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
const [isToggling, setIsToggling] = useState(false)
|
const [isToggling, setIsToggling] = useState(false)
|
||||||
|
const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(completed)
|
||||||
|
|
||||||
const handleToggle = async (e: React.MouseEvent) => {
|
const handleToggle = async (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (isToggling) return
|
||||||
|
|
||||||
setIsToggling(true)
|
setIsToggling(true)
|
||||||
|
|
||||||
|
// Optimistically update UI
|
||||||
|
setOptimisticCompleted(!completed)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await toggleItem(itemId)
|
await toggleItem(itemId)
|
||||||
showToast(completed ? 'Task marked incomplete' : 'Task completed', 'success')
|
showToast(completed ? 'Task marked incomplete' : 'Task completed', 'success')
|
||||||
|
router.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling item:', error)
|
console.error('Error toggling item:', error)
|
||||||
showToast('Failed to update task', 'error')
|
showToast('Failed to update task', 'error')
|
||||||
|
// Revert optimistic update on error
|
||||||
|
setOptimisticCompleted(completed)
|
||||||
} finally {
|
} finally {
|
||||||
setIsToggling(false)
|
setIsToggling(false)
|
||||||
}
|
}
|
||||||
@@ -36,9 +49,9 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
|
|||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={isToggling}
|
disabled={isToggling}
|
||||||
className="hover:scale-110 active:scale-95 transition-transform disabled:opacity-50 tap-target-sm"
|
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" />
|
<CheckCircle2 size={22} className="text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<Circle size={22} className="text-gray-400 hover:text-gray-600 active:text-gray-700" />
|
<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}
|
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"
|
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" />
|
<CheckCircle2 size={18} className="text-green-500 flex-shrink-0" />
|
||||||
<span>Mark Incomplete</span>
|
<span>Mark Incomplete</span>
|
||||||
|
|||||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal 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
|
||||||
@@ -10,3 +10,4 @@ export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate'
|
|||||||
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'
|
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'
|
||||||
export { useOfflineSync } from './useOfflineSync'
|
export { useOfflineSync } from './useOfflineSync'
|
||||||
export type { QueuedMutation } from './useOfflineSync'
|
export type { QueuedMutation } from './useOfflineSync'
|
||||||
|
export { useRealtimeSync } from './useRealtimeSync'
|
||||||
|
|||||||
170
hooks/usePushNotifications.ts
Normal file
170
hooks/usePushNotifications.ts
Normal 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
72
hooks/useRealtimeSync.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
37
hooks/useRealtimeUpdates.ts
Normal file
37
hooks/useRealtimeUpdates.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ vi.mock('@/auth', () => ({
|
|||||||
|
|
||||||
// Import after mocking
|
// Import after mocking
|
||||||
import { registerUser, loginUser } from '../auth-actions'
|
import { registerUser, loginUser } from '../auth-actions'
|
||||||
import { signIn } from '@/auth'
|
import { signIn } from 'next-auth/react'
|
||||||
|
|
||||||
describe('registerUser', () => {
|
describe('registerUser', () => {
|
||||||
// Clean up test users after each test
|
// Clean up test users after each test
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { prisma } from './db'
|
import { prisma } from './db'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { auth } from '@/auth'
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||||
import {
|
import {
|
||||||
validateListTitle,
|
validateListTitle,
|
||||||
validateItemTitle,
|
validateItemTitle,
|
||||||
@@ -11,6 +12,11 @@ import {
|
|||||||
validateTags,
|
validateTags,
|
||||||
sanitizeString,
|
sanitizeString,
|
||||||
} from './validation'
|
} from './validation'
|
||||||
|
import {
|
||||||
|
notifyItemAdded,
|
||||||
|
notifyItemRemoved,
|
||||||
|
notifyItemCompleted,
|
||||||
|
} from './notification-helpers'
|
||||||
|
|
||||||
// Retry utility for database operations
|
// Retry utility for database operations
|
||||||
async function retryOperation<T>(
|
async function retryOperation<T>(
|
||||||
@@ -49,7 +55,7 @@ async function retryOperation<T>(
|
|||||||
export async function getLists() {
|
export async function getLists() {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to view lists')
|
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') {
|
export async function createList(title: string, color: string = '#3B82F6') {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to create lists')
|
throw new Error('Unauthorized: Please log in to create lists')
|
||||||
}
|
}
|
||||||
@@ -157,7 +163,7 @@ export async function getListWithItems(listId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to view lists')
|
throw new Error('Unauthorized: Please log in to view lists')
|
||||||
}
|
}
|
||||||
@@ -228,7 +234,7 @@ export async function getItem(itemId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to view items')
|
throw new Error('Unauthorized: Please log in to view items')
|
||||||
}
|
}
|
||||||
@@ -305,7 +311,7 @@ export async function createItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to create items')
|
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}`)
|
revalidatePath(`/list/${listId}`)
|
||||||
return item
|
return item
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -387,7 +401,7 @@ export async function toggleItem(itemId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to update items')
|
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}`)
|
revalidatePath(`/list/${item.listId}`)
|
||||||
return updated
|
return updated
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -465,7 +488,7 @@ export async function updateItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to update items')
|
throw new Error('Unauthorized: Please log in to update items')
|
||||||
}
|
}
|
||||||
@@ -560,7 +583,7 @@ export async function deleteItem(itemId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to delete items')
|
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}`)
|
revalidatePath(`/list/${item.listId}`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -626,7 +656,7 @@ export async function deleteList(listId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to delete lists')
|
throw new Error('Unauthorized: Please log in to delete lists')
|
||||||
}
|
}
|
||||||
@@ -677,7 +707,7 @@ export async function updateList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to update lists')
|
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
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to search items')
|
throw new Error('Unauthorized: Please log in to search items')
|
||||||
}
|
}
|
||||||
@@ -878,7 +908,7 @@ export async function getItemsByFilter(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to filter items')
|
throw new Error('Unauthorized: Please log in to filter items')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { prisma } from './db'
|
import { prisma } from './db'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { z } from 'zod'
|
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 { checkRateLimit, resetRateLimit, LOGIN_RATE_LIMIT, REGISTER_RATE_LIMIT } from './rate-limit'
|
||||||
import { headers } from 'next/headers'
|
import { headers } from 'next/headers'
|
||||||
import { getClientIdentifier } from './rate-limit'
|
import { getClientIdentifier } from './rate-limit'
|
||||||
@@ -195,8 +195,9 @@ export async function logoutUser(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function getCurrentSession() {
|
export async function getCurrentSession() {
|
||||||
try {
|
try {
|
||||||
const { auth } = await import('@/auth')
|
const { getServerSession } = await import('next-auth/next')
|
||||||
const session = await auth()
|
const { authOptions } = await import('@/pages/api/auth/[...nextauth]')
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
return session
|
return session
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Session retrieval error:', error)
|
console.error('Session retrieval error:', error)
|
||||||
|
|||||||
14
lib/db.ts
14
lib/db.ts
@@ -4,22 +4,8 @@ const globalForPrisma = globalThis as unknown as {
|
|||||||
prisma: PrismaClient | undefined
|
prisma: PrismaClient | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Prisma client with connection error handling
|
|
||||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
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
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { prisma } from './db'
|
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
|
* 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> {
|
export async function claimLists(listIds: string[]): Promise<MigrationResult> {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'Unauthorized: Please log in to claim lists' }
|
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> {
|
export async function hasExistingLists(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
201
lib/notification-helpers.ts
Normal file
201
lib/notification-helpers.ts
Normal 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
191
lib/push-notifications.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
import { prisma } from './db'
|
import { prisma } from './db'
|
||||||
import { revalidatePath } from 'next/cache'
|
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 { z } from 'zod'
|
||||||
import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit'
|
import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit'
|
||||||
|
import {
|
||||||
|
notifyListInvitation,
|
||||||
|
notifyInvitationAccepted,
|
||||||
|
notifyRemovedFromList,
|
||||||
|
} from './notification-helpers'
|
||||||
|
|
||||||
// Validation schemas
|
// Validation schemas
|
||||||
const inviteSchema = z.object({
|
const inviteSchema = z.object({
|
||||||
@@ -57,7 +63,7 @@ export async function inviteUserToList(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to invite users')
|
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}`)
|
revalidatePath(`/list/${listId}`)
|
||||||
return invitation
|
return invitation
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -187,7 +201,7 @@ export async function inviteUserToList(
|
|||||||
export async function getInvitations() {
|
export async function getInvitations() {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id || !session?.user?.email) {
|
if (!session?.user?.id || !session?.user?.email) {
|
||||||
throw new Error('Unauthorized: Please log in to view invitations')
|
throw new Error('Unauthorized: Please log in to view invitations')
|
||||||
}
|
}
|
||||||
@@ -237,7 +251,7 @@ export async function getInvitations() {
|
|||||||
export async function acceptInvitation(invitationId: string) {
|
export async function acceptInvitation(invitationId: string) {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id || !session?.user?.email) {
|
if (!session?.user?.id || !session?.user?.email) {
|
||||||
throw new Error('Unauthorized: Please log in to accept invitations')
|
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('/')
|
||||||
revalidatePath(`/list/${invitation.listId}`)
|
revalidatePath(`/list/${invitation.listId}`)
|
||||||
return result
|
return result
|
||||||
@@ -345,7 +367,7 @@ export async function acceptInvitation(invitationId: string) {
|
|||||||
export async function declineInvitation(invitationId: string) {
|
export async function declineInvitation(invitationId: string) {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id || !session?.user?.email) {
|
if (!session?.user?.id || !session?.user?.email) {
|
||||||
throw new Error('Unauthorized: Please log in to decline invitations')
|
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) {
|
export async function getCollaborators(listId: string) {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to view collaborators')
|
throw new Error('Unauthorized: Please log in to view collaborators')
|
||||||
}
|
}
|
||||||
@@ -488,7 +510,7 @@ export async function updateCollaboratorPermission(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to update permissions')
|
throw new Error('Unauthorized: Please log in to update permissions')
|
||||||
}
|
}
|
||||||
@@ -566,7 +588,7 @@ export async function removeCollaborator(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Get current user session
|
// Get current user session
|
||||||
const session = await auth()
|
const session = await getServerSession(authOptions)
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized: Please log in to remove collaborators')
|
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')
|
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
|
// Delete the collaborator
|
||||||
await retryOperation(() =>
|
await retryOperation(() =>
|
||||||
prisma.collaborator.delete({
|
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}`)
|
revalidatePath(`/list/${listId}`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
110
middleware.ts
110
middleware.ts
@@ -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)$).*)",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -8,16 +8,18 @@ const nextConfig = {
|
|||||||
exclude: ['error', 'warn'],
|
exclude: ['error', 'warn'],
|
||||||
} : false,
|
} : false,
|
||||||
},
|
},
|
||||||
// Enable SWC minification for better performance
|
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
// Optimize images
|
|
||||||
images: {
|
images: {
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
},
|
},
|
||||||
// Enable compression
|
|
||||||
compress: true,
|
compress: true,
|
||||||
// Production source maps (smaller)
|
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
trailingSlash: false,
|
||||||
|
poweredByHeader: false,
|
||||||
|
// Optimize for mobile Safari
|
||||||
|
reactStrictMode: true,
|
||||||
|
// Enable optimized font loading
|
||||||
|
optimizeFonts: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
1438
package-lock.json
generated
1438
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@
|
|||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:migrate": "npx tsx scripts/migrate-lists.ts",
|
"db:migrate": "npx tsx scripts/migrate-lists.ts",
|
||||||
"icons:generate": "node scripts/generate-icons.js",
|
"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": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
@@ -22,9 +23,10 @@
|
|||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
"next": "14.0.4",
|
"next": "14.0.4",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^4.24.5",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/web-push": "^3.6.3",
|
||||||
"@vitest/ui": "^4.0.14",
|
"@vitest/ui": "^4.0.14",
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
|
|||||||
54
pages/api/auth/[...nextauth].ts
Normal file
54
pages/api/auth/[...nextauth].ts
Normal 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)
|
||||||
@@ -8,20 +8,35 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
passwordHash String
|
passwordHash String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
ownedLists List[] @relation("ListOwner")
|
ownedLists List[] @relation("ListOwner")
|
||||||
collaborations Collaborator[]
|
collaborations Collaborator[]
|
||||||
sentInvitations Invitation[] @relation("InvitationInviter")
|
sentInvitations Invitation[] @relation("InvitationInviter")
|
||||||
receivedInvitations Invitation[] @relation("InvitationReceiver")
|
receivedInvitations Invitation[] @relation("InvitationReceiver")
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
|
|
||||||
@@index([email])
|
@@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 {
|
model List {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "Ultimate Lists",
|
"name": "Ultimate Lists",
|
||||||
"short_name": "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": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|||||||
59
public/sw.js
59
public/sw.js
@@ -1,8 +1,8 @@
|
|||||||
// Service Worker for Ultimate Lists App
|
// Service Worker for Ultimate Lists App
|
||||||
// Implements caching strategies for offline functionality
|
// Implements caching strategies for offline functionality
|
||||||
|
|
||||||
const CACHE_NAME = 'ultimate-lists-v2';
|
const CACHE_NAME = 'ultimate-lists-v8';
|
||||||
const RUNTIME_CACHE = 'ultimate-lists-runtime-v2';
|
const RUNTIME_CACHE = 'ultimate-lists-runtime-v8';
|
||||||
|
|
||||||
// Static assets to cache on install
|
// Static assets to cache on install
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -60,6 +60,11 @@ self.addEventListener('fetch', (event) => {
|
|||||||
if (!url.protocol.startsWith('http')) {
|
if (!url.protocol.startsWith('http')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip cross-origin requests for performance
|
||||||
|
if (url.origin !== self.location.origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle authentication-related requests
|
// Handle authentication-related requests
|
||||||
if (isAuthRequest(url)) {
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
20
scripts/generate-vapid-keys.js
Normal file
20
scripts/generate-vapid-keys.js
Normal 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');
|
||||||
Reference in New Issue
Block a user