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
|
||||
NEXTAUTH_SECRET=your-secret-key-here-please-change-in-production
|
||||
AUTH_SECRET=your-secret-key-here-please-change-in-production
|
||||
|
||||
# Web Push Notifications (VAPID)
|
||||
# Generate keys with: npx web-push generate-vapid-keys
|
||||
# Or in Docker: docker run --rm -it node:20-slim sh -c "npm install -g web-push && web-push generate-vapid-keys"
|
||||
VAPID_PUBLIC_KEY=your-vapid-public-key-here
|
||||
VAPID_PRIVATE_KEY=your-vapid-private-key-here
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -23,6 +23,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local env files
|
||||
# .env
|
||||
.env*.local
|
||||
|
||||
# Vercel
|
||||
@@ -30,7 +31,4 @@ yarn-error.log*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Environment
|
||||
.env
|
||||
next-env.d.ts
|
||||
@@ -226,6 +226,14 @@
|
||||
- [x] 19. Final checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 19.5. Implement real-time list synchronization
|
||||
- Create useRealtimeSync hook for polling server updates
|
||||
- Add API endpoint to check last updated timestamp
|
||||
- Integrate real-time sync into ListDetailView component
|
||||
- Automatically refresh when other users make changes
|
||||
- Poll every 3 seconds and on page visibility change
|
||||
- _Requirements: Collaborative features (enhancement)_
|
||||
|
||||
- [ ] 20. End-to-end testing and polish
|
||||
- Test complete user flows from list creation to task completion
|
||||
- Verify PWA installation on iOS Safari
|
||||
|
||||
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 {
|
||||
animation: slide-in-right 0.3s ease-out;
|
||||
}
|
||||
@@ -75,6 +86,10 @@ mark {
|
||||
animation: scale-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Smooth transitions for interactive elements */
|
||||
.transition-smooth {
|
||||
transition: all 0.2s ease-in-out;
|
||||
@@ -104,4 +119,19 @@ mark {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Remove 300ms click delay on iOS Safari */
|
||||
a, button, input, select, textarea {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Optimize for touch interactions */
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* Prevent double-tap zoom on interactive elements */
|
||||
button, a, input[type="button"], input[type="submit"] {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getInvitations } from '@/lib/sharing-actions'
|
||||
import InvitationCard from '@/components/InvitationCard'
|
||||
@@ -27,7 +28,7 @@ type Invitation = {
|
||||
}
|
||||
|
||||
export default async function InvitationsPage() {
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/auth/login')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
||||
import OfflineIndicator from '@/components/OfflineIndicator'
|
||||
import NotificationPrompt from '@/components/NotificationPrompt'
|
||||
import { ToastProvider } from '@/components/ToastContainer'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||
|
||||
@@ -9,14 +10,6 @@ export const metadata: Metadata = {
|
||||
title: 'Ultimate Lists',
|
||||
description: 'The ultimate todo list app with advanced task management',
|
||||
manifest: '/manifest.json',
|
||||
themeColor: '#3B82F6',
|
||||
viewport: {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: 'cover',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
@@ -39,6 +32,15 @@ export const metadata: Metadata = {
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
userScalable: true,
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#3B82F6',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -65,7 +67,8 @@ export default function RootLayout({
|
||||
<meta name="theme-color" content="#3B82F6" />
|
||||
<meta name="background-color" content="#ffffff" />
|
||||
|
||||
{/* iOS Splash Screens - Optional but recommended */}
|
||||
{/* iOS Performance Optimizations */}
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
</head>
|
||||
<body className="bg-gray-50 min-h-screen">
|
||||
@@ -73,6 +76,7 @@ export default function RootLayout({
|
||||
<ToastProvider>
|
||||
<ServiceWorkerRegistration />
|
||||
<OfflineIndicator />
|
||||
<NotificationPrompt />
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getItem } from '@/lib/actions'
|
||||
import { canEditList } from '@/lib/permissions'
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import ItemForm from '@/components/ItemForm'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
@@ -16,7 +17,7 @@ interface PageProps {
|
||||
}
|
||||
|
||||
export default async function EditItemPage({ params }: PageProps) {
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getItem } from '@/lib/actions'
|
||||
import { canEditList } from '@/lib/permissions'
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import ItemDetail from '@/components/ItemDetail'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Edit } from 'lucide-react'
|
||||
@@ -18,7 +19,7 @@ interface PageProps {
|
||||
}
|
||||
|
||||
export default async function ItemDetailPage({ params }: PageProps) {
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getListWithItems } from '@/lib/actions'
|
||||
import { getPermission } from '@/lib/permissions'
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
@@ -8,9 +9,10 @@ import InlineItemCreate from '@/components/InlineItemCreate'
|
||||
import ListDetailView from '@/components/ListDetailView'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
export default async function ListDetailPage({ params }: { params: { id: string } }) {
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
notFound()
|
||||
}
|
||||
@@ -56,6 +58,25 @@ export default async function ListDetailPage({ params }: { params: { id: string
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permission Badge and Share Button */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<ListDetailView
|
||||
listId={list.id}
|
||||
initialItems={list.items}
|
||||
permission={permission}
|
||||
canShare={canShare}
|
||||
listTitle={list.title}
|
||||
ownerId={list.ownerId}
|
||||
ownerName={list.owner?.name}
|
||||
ownerEmail={list.owner?.email || ''}
|
||||
collaborators={list.collaborators.map(c => ({
|
||||
...c,
|
||||
permission: c.permission as 'editor' | 'viewer'
|
||||
}))}
|
||||
showOnlyHeader={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline Item Creation - only show if user can edit */}
|
||||
{canEdit && (
|
||||
<div className="mb-6 sm:mb-8">
|
||||
@@ -63,7 +84,7 @@ export default async function ListDetailPage({ params }: { params: { id: string
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search, Filter, and Items List */}
|
||||
{/* Items List */}
|
||||
<ListDetailView
|
||||
listId={list.id}
|
||||
initialItems={list.items}
|
||||
@@ -77,6 +98,7 @@ export default async function ListDetailPage({ params }: { params: { id: string
|
||||
...c,
|
||||
permission: c.permission as 'editor' | 'viewer'
|
||||
}))}
|
||||
showOnlyItems={true}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
|
||||
14
app/page.tsx
14
app/page.tsx
@@ -3,14 +3,23 @@ import ListCard from '@/components/ListCard'
|
||||
import NewListButton from '@/components/NewListButton'
|
||||
import GlobalSearch from '@/components/GlobalSearch'
|
||||
import EmptyState from '@/components/EmptyState'
|
||||
import LogoutButton from '@/components/LogoutButton'
|
||||
import Link from 'next/link'
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
export default async function Home() {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/auth/login')
|
||||
}
|
||||
|
||||
const lists = await getLists()
|
||||
const session = await auth()
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-7xl">
|
||||
@@ -31,6 +40,7 @@ export default async function Home() {
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</>
|
||||
)}
|
||||
<NewListButton />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { getUserProfile } from '@/lib/auth-actions'
|
||||
import ProfileForm from '@/components/ProfileForm'
|
||||
import PasswordChangeForm from '@/components/PasswordChangeForm'
|
||||
import NotificationSettings from '@/components/NotificationSettings'
|
||||
import Link from 'next/link'
|
||||
|
||||
export const metadata = {
|
||||
@@ -11,7 +13,7 @@ export const metadata = {
|
||||
}
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect('/auth/login')
|
||||
@@ -81,6 +83,11 @@ export default async function ProfilePage() {
|
||||
<PasswordChangeForm userId={userProfile.id} />
|
||||
</div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="mb-6">
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
|
||||
{/* Account Information */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 sm:p-8">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-4">
|
||||
|
||||
@@ -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'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { deleteItem } from '@/lib/actions'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
@@ -17,19 +17,25 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
|
||||
const { showToast } = useToast()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
await deleteItem(itemId)
|
||||
showToast('Task deleted successfully', 'success')
|
||||
if (listId) {
|
||||
router.push(`/list/${listId}`)
|
||||
} else {
|
||||
|
||||
// Reset state immediately
|
||||
setShowConfirm(false)
|
||||
setIsDeleting(false)
|
||||
|
||||
// Use startTransition to refresh the route
|
||||
startTransition(() => {
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting item:', error)
|
||||
showToast('Failed to delete task', 'error')
|
||||
@@ -55,14 +61,14 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
disabled={isDeleting || isPending}
|
||||
className="px-3 sm:px-4 py-2 sm:py-2.5 bg-red-500 hover:bg-red-600 active:bg-red-700 disabled:bg-red-300 text-white rounded-lg text-xs sm:text-sm transition-colors tap-target-sm font-medium"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Confirm'}
|
||||
{isDeleting || isPending ? 'Deleting...' : 'Confirm'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isDeleting}
|
||||
disabled={isDeleting || isPending}
|
||||
className="px-3 sm:px-4 py-2 sm:py-2.5 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg text-xs sm:text-sm transition-colors tap-target-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createItem } from '@/lib/actions'
|
||||
import { Plus } from 'lucide-react'
|
||||
@@ -18,6 +18,20 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [keepFormOpen, setKeepFormOpen] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus input when form becomes visible
|
||||
useEffect(() => {
|
||||
if ((isCreating || keepFormOpen) && inputRef.current) {
|
||||
// Small delay to ensure the input is rendered
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}, [isCreating, keepFormOpen])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -37,16 +51,31 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await createItem(listId, sanitizeString(title))
|
||||
showToast('Task added successfully', 'success')
|
||||
// Clear form immediately for better UX
|
||||
const itemTitle = sanitizeString(title)
|
||||
setTitle('')
|
||||
setIsCreating(false)
|
||||
router.refresh()
|
||||
setKeepFormOpen(true) // Mark that we want to keep the form open
|
||||
|
||||
// Create item and refresh
|
||||
await createItem(listId, itemTitle)
|
||||
showToast('Task added successfully', 'success')
|
||||
|
||||
// The revalidatePath in createItem will refresh the data
|
||||
// but we'll keep the form open due to keepFormOpen state
|
||||
|
||||
// Refocus the input after a short delay to keep keyboard open
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
console.error('Error creating item:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to add task'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
// Restore form on error
|
||||
setIsCreating(true)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -55,6 +84,11 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
||||
const handleCancel = () => {
|
||||
setTitle('')
|
||||
setIsCreating(false)
|
||||
setKeepFormOpen(false) // Reset the keep form open state
|
||||
// Reset zoom on iOS Safari
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -63,7 +97,16 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCreating) {
|
||||
const handleBlur = (e: React.FocusEvent) => {
|
||||
// Only handle blur if we're not keeping the form open
|
||||
// This prevents the keyboard from closing when we want it to stay open
|
||||
if (!keepFormOpen) {
|
||||
// Force viewport reset on iOS Safari
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCreating && !keepFormOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
@@ -79,6 +122,7 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
||||
<form onSubmit={handleSubmit} className="bg-white border-2 border-blue-400 rounded-lg p-4 sm:p-5 shadow-sm">
|
||||
<div className="mb-3 sm:mb-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
@@ -86,10 +130,11 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
|
||||
setError('')
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
placeholder="Enter task title..."
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
|
||||
error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
|
||||
}`}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
|
||||
@@ -33,24 +33,33 @@ function ItemCard({ item, listId }: ItemCardProps) {
|
||||
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 sm:p-5 hover:shadow-md active:shadow-lg transition-all group">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-3.5 hover:shadow-md active:shadow-lg transition-all group">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Completion Toggle */}
|
||||
<div className="pt-0.5 sm:pt-1 flex-shrink-0">
|
||||
<div className="flex-shrink-0">
|
||||
<ToggleItemButton itemId={item.id} completed={item.completed} variant="icon" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link href={`/list/${listId}/item/${item.id}`} className="block">
|
||||
<h3 className={`font-medium mb-1 text-sm sm:text-base ${
|
||||
item.completed ? 'text-gray-500 line-through' : 'text-gray-900'
|
||||
}`}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={`font-medium text-sm sm:text-base ${
|
||||
item.completed ? 'text-gray-500 line-through' : 'text-gray-900'
|
||||
}`}>
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
{/* Priority Badge - moved to right */}
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border flex-shrink-0 ${priorityColor}`}>
|
||||
<AlertCircle size={12} className="flex-shrink-0" />
|
||||
<span className="hidden xs:inline sm:inline">{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}</span>
|
||||
<span className="xs:hidden sm:hidden">{item.priority.charAt(0).toUpperCase()}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className={`text-xs sm:text-sm mb-2 line-clamp-2 ${
|
||||
<p className={`text-xs sm:text-sm mt-1 line-clamp-1 ${
|
||||
item.completed ? 'text-gray-400' : 'text-gray-600'
|
||||
}`}>
|
||||
{item.description}
|
||||
@@ -58,42 +67,37 @@ function ItemCard({ item, listId }: ItemCardProps) {
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-2">
|
||||
{/* Priority Badge */}
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium border ${priorityColor}`}>
|
||||
<AlertCircle size={12} className="flex-shrink-0" />
|
||||
<span className="hidden xs:inline sm:inline">{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}</span>
|
||||
<span className="xs:hidden sm:hidden">{item.priority.charAt(0).toUpperCase()}</span>
|
||||
</span>
|
||||
|
||||
{/* Due Date */}
|
||||
{item.dueDate && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs whitespace-nowrap ${
|
||||
isOverdue
|
||||
? 'bg-red-100 text-red-700 border border-red-200 font-semibold'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
<Calendar size={12} className="flex-shrink-0" />
|
||||
{new Date(item.dueDate).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{isOverdue && <span className="hidden sm:inline"> (Overdue)</span>}
|
||||
{isOverdue && <span className="sm:hidden">!</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<Tag size={12} className="text-gray-400 flex-shrink-0" />
|
||||
<span className="text-xs text-gray-600 truncate max-w-[120px] sm:max-w-none">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && ` +${tags.length - 2}`}
|
||||
{(item.dueDate || tags.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5">
|
||||
{/* Due Date */}
|
||||
{item.dueDate && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs whitespace-nowrap ${
|
||||
isOverdue
|
||||
? 'bg-red-100 text-red-700 border border-red-200 font-semibold'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
<Calendar size={12} className="flex-shrink-0" />
|
||||
{new Date(item.dueDate).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{isOverdue && <span className="hidden sm:inline"> (Overdue)</span>}
|
||||
{isOverdue && <span className="sm:hidden">!</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<Tag size={12} className="text-gray-400 flex-shrink-0" />
|
||||
<span className="text-xs text-gray-600 truncate max-w-[120px] sm:max-w-none">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && ` +${tags.length - 2}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={() => handleBlur('title')}
|
||||
disabled={!canEdit}
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
|
||||
errors.title && touched.title
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
@@ -293,7 +293,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
|
||||
onBlur={() => handleBlur('description')}
|
||||
disabled={!canEdit}
|
||||
rows={3}
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base resize-y ${
|
||||
errors.description && touched.description
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
@@ -320,7 +320,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
|
||||
disabled={!canEdit}
|
||||
className={`w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||
className={`w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-base tap-target ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
@@ -340,7 +340,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
onBlur={() => handleBlur('dueDate')}
|
||||
disabled={!canEdit}
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base tap-target ${
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base tap-target ${
|
||||
errors.dueDate && touched.dueDate
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
@@ -370,7 +370,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!canEdit}
|
||||
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
|
||||
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
|
||||
errors.tagInput
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
@@ -432,7 +432,7 @@ export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }
|
||||
onBlur={() => handleBlur('notes')}
|
||||
disabled={!canEdit}
|
||||
rows={5}
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${
|
||||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base resize-y ${
|
||||
errors.notes && touched.notes
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-blue-500'
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Share2 } from 'lucide-react'
|
||||
import ItemCard from './ItemCard'
|
||||
import SearchBar from './SearchBar'
|
||||
import FilterPanel from './FilterPanel'
|
||||
import SearchResults from './SearchResults'
|
||||
import EmptyState from './EmptyState'
|
||||
import ShareListModal from './ShareListModal'
|
||||
import PermissionBadge from './PermissionBadge'
|
||||
import { searchItems, getItemsByFilter } from '@/lib/actions'
|
||||
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
|
||||
import { useScrollPosition } from '@/hooks'
|
||||
import { useRealtimeSync } from '@/hooks/useRealtimeSync'
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
@@ -26,14 +23,6 @@ interface Item {
|
||||
listId: string
|
||||
}
|
||||
|
||||
interface ItemWithList extends Item {
|
||||
list: {
|
||||
id: string
|
||||
title: string
|
||||
color: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ListDetailViewProps {
|
||||
listId: string
|
||||
initialItems: Item[]
|
||||
@@ -52,6 +41,8 @@ interface ListDetailViewProps {
|
||||
email: string
|
||||
}
|
||||
}>
|
||||
showOnlyHeader?: boolean
|
||||
showOnlyItems?: boolean
|
||||
}
|
||||
|
||||
export default function ListDetailView({
|
||||
@@ -63,131 +54,72 @@ export default function ListDetailView({
|
||||
ownerId = '',
|
||||
ownerName = null,
|
||||
ownerEmail = '',
|
||||
collaborators = []
|
||||
collaborators = [],
|
||||
showOnlyHeader = false,
|
||||
showOnlyItems = false
|
||||
}: ListDetailViewProps) {
|
||||
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
||||
|
||||
// Use scroll position preservation hook
|
||||
useScrollPosition({ key: `list-${listId}` })
|
||||
// Conditionally use hooks based on view mode
|
||||
const shouldUseHooks = !showOnlyHeader
|
||||
|
||||
// Use scroll position preservation hook (only for items view)
|
||||
useScrollPosition({ key: shouldUseHooks ? `list-${listId}` : 'disabled' })
|
||||
|
||||
// Extract all unique tags from items
|
||||
const availableTags = useMemo(() => {
|
||||
const tagSet = new Set<string>()
|
||||
initialItems.forEach(item => {
|
||||
const tags = Array.isArray(item.tags) ? item.tags : []
|
||||
tags.forEach(tag => {
|
||||
if (typeof tag === 'string') {
|
||||
tagSet.add(tag)
|
||||
}
|
||||
})
|
||||
})
|
||||
return Array.from(tagSet).sort()
|
||||
}, [initialItems])
|
||||
|
||||
// Use search hook
|
||||
const handleSearchCallback = useCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setIsSearching(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
const results = await searchItems(query, listId)
|
||||
setSearchResults(results as ItemWithList[])
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [listId])
|
||||
|
||||
const { debouncedQuery } = useSearch({
|
||||
onSearch: handleSearchCallback
|
||||
// Use real-time sync hook to poll for updates (only for items view)
|
||||
useRealtimeSync({
|
||||
listId,
|
||||
enabled: shouldUseHooks,
|
||||
pollInterval: 1000, // Poll every 1 second for faster updates
|
||||
})
|
||||
|
||||
// Use filters hook
|
||||
const handleFiltersCallback = useCallback(async (newFilters: any) => {
|
||||
try {
|
||||
const results = await getItemsByFilter(listId, {
|
||||
priority: newFilters.priority,
|
||||
completed: newFilters.completed,
|
||||
tags: newFilters.tags,
|
||||
dueDateRange: newFilters.dueDateRange,
|
||||
sortBy: newFilters.sortBy,
|
||||
sortOrder: newFilters.sortOrder
|
||||
})
|
||||
// Extract items from pagination result
|
||||
const items = 'items' in results ? results.items : results
|
||||
setFilteredItems(items as Item[])
|
||||
} catch (error) {
|
||||
console.error('Filter error:', error)
|
||||
setFilteredItems(initialItems)
|
||||
}
|
||||
}, [listId, initialItems])
|
||||
|
||||
const { filters, setFilters } = useFilters({
|
||||
onFiltersChange: handleFiltersCallback
|
||||
})
|
||||
|
||||
// Apply initial filters on mount
|
||||
useEffect(() => {
|
||||
handleFiltersCallback(filters)
|
||||
}, []) // Only run on mount
|
||||
|
||||
// Determine which items to display
|
||||
const displayItems = debouncedQuery.trim() ? searchResults : filteredItems
|
||||
const completedItems = displayItems.filter(item => item.completed)
|
||||
const incompleteItems = displayItems.filter(item => !item.completed)
|
||||
const completedItems = useMemo(() => initialItems.filter(item => item.completed), [initialItems])
|
||||
const incompleteItems = useMemo(() => initialItems.filter(item => !item.completed), [initialItems])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Permission Badge and Share Button */}
|
||||
{(permission || canShare) && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{permission && (
|
||||
<div>
|
||||
<PermissionBadge permission={permission} />
|
||||
</div>
|
||||
)}
|
||||
{canShare && (
|
||||
<button
|
||||
onClick={() => setIsShareModalOpen(true)}
|
||||
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<Share2 size={16} />
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<SearchBar
|
||||
onSearch={handleSearchCallback}
|
||||
placeholder="Search tasks in this list..."
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
|
||||
{/* Results */}
|
||||
{debouncedQuery.trim() ? (
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
query={debouncedQuery}
|
||||
// If showing only header, return just the permission badge and share button
|
||||
if (showOnlyHeader) {
|
||||
return (
|
||||
<>
|
||||
{(permission || canShare) && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{permission && (
|
||||
<div>
|
||||
<PermissionBadge permission={permission} />
|
||||
</div>
|
||||
)}
|
||||
{canShare && (
|
||||
<button
|
||||
onClick={() => setIsShareModalOpen(true)}
|
||||
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<Share2 size={16} />
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Modal */}
|
||||
<ShareListModal
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={() => setIsShareModalOpen(false)}
|
||||
listId={listId}
|
||||
listTitle={listTitle}
|
||||
isOwner={permission === 'owner'}
|
||||
ownerId={ownerId}
|
||||
ownerName={ownerName}
|
||||
ownerEmail={ownerEmail}
|
||||
initialCollaborators={collaborators}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// If showing only items, return just the items list
|
||||
if (showOnlyItems) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Incomplete Items */}
|
||||
{incompleteItems.length > 0 && (
|
||||
<div className="animate-fade-in">
|
||||
@@ -217,20 +149,80 @@ export default function ListDetailView({
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{displayItems.length === 0 && (
|
||||
{initialItems.length === 0 && (
|
||||
<EmptyState
|
||||
icon={filters.priority || filters.completed !== null || filters.tags.length > 0 ? 'filter' : 'inbox'}
|
||||
icon="inbox"
|
||||
title="No tasks found"
|
||||
description={
|
||||
filters.priority || filters.completed !== null || filters.tags.length > 0
|
||||
? 'Try adjusting your filters to see more tasks'
|
||||
: 'Add your first task using the button above'
|
||||
}
|
||||
description="Add your first task using the button above"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: show everything (for backward compatibility)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Permission Badge and Share Button */}
|
||||
{(permission || canShare) && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{permission && (
|
||||
<div>
|
||||
<PermissionBadge permission={permission} />
|
||||
</div>
|
||||
)}
|
||||
{canShare && (
|
||||
<button
|
||||
onClick={() => setIsShareModalOpen(true)}
|
||||
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<Share2 size={16} />
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-6">
|
||||
{/* Incomplete Items */}
|
||||
{incompleteItems.length > 0 && (
|
||||
<div className="animate-fade-in">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
To Do ({incompleteItems.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{incompleteItems.map((item) => (
|
||||
<ItemCard key={item.id} item={item} listId={listId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed Items */}
|
||||
{completedItems.length > 0 && (
|
||||
<div className="animate-fade-in">
|
||||
<h2 className="text-lg font-semibold text-gray-500 mb-3">
|
||||
Completed ({completedItems.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{completedItems.map((item) => (
|
||||
<ItemCard key={item.id} item={item} listId={listId} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{initialItems.length === 0 && (
|
||||
<EmptyState
|
||||
icon="inbox"
|
||||
title="No tasks found"
|
||||
description="Add your first task using the button above"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share Modal */}
|
||||
<ShareListModal
|
||||
isOpen={isShareModalOpen}
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center animate-fade-in">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-t-2xl sm:rounded-lg shadow-xl max-w-md w-full mx-0 sm:mx-4 max-h-[90vh] overflow-y-auto transform transition-transform animate-scale-in">
|
||||
<div
|
||||
className="relative bg-white rounded-t-2xl sm:rounded-lg shadow-xl max-w-md w-full mx-0 sm:mx-4 max-h-[90vh] overflow-y-auto transform transition-transform animate-scale-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 sm:p-5 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
|
||||
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'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useOfflineSync } from '@/hooks'
|
||||
import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
|
||||
|
||||
@@ -9,8 +10,18 @@ import { WifiOff, Wifi, RefreshCw, AlertCircle } from 'lucide-react'
|
||||
* Validates: Requirements 4.5
|
||||
*/
|
||||
export default function OfflineIndicator() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { isOnline, isSyncing, syncError, queueLength, triggerSync } = useOfflineSync()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Don't render on server to avoid hydration mismatch
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Don't show anything if online and no pending syncs
|
||||
if (isOnline && queueLength === 0 && !syncError) {
|
||||
return null
|
||||
|
||||
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'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { memo, useState, useEffect } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useSearch } from '@/hooks'
|
||||
|
||||
@@ -17,18 +17,25 @@ function SearchBar({
|
||||
debounceMs = 300,
|
||||
initialValue = ''
|
||||
}: SearchBarProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { query, setQuery, clearSearch, isDebouncing } = useSearch({
|
||||
debounceMs,
|
||||
onSearch
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
size={20}
|
||||
/>
|
||||
{mounted && (
|
||||
<Search
|
||||
className="absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
@@ -36,7 +43,7 @@ function SearchBar({
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-10 sm:pl-12 pr-10 sm:pr-12 py-3 sm:py-3.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm sm:text-base transition-shadow"
|
||||
/>
|
||||
{query && (
|
||||
{mounted && query && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 active:text-gray-700 transition-colors tap-target-sm"
|
||||
@@ -45,7 +52,7 @@ function SearchBar({
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
{isDebouncing && (
|
||||
{mounted && isDebouncing && (
|
||||
<div className="absolute right-12 sm:right-14 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info'
|
||||
@@ -13,12 +13,26 @@ interface ToastProps {
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, onClose, duration = 3000 }: ToastProps) {
|
||||
const onCloseRef = useRef(onClose)
|
||||
|
||||
// Keep the ref updated
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(onClose, duration)
|
||||
return () => clearTimeout(timer)
|
||||
console.log(`[Toast] Setting timer for ${duration}ms`)
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[Toast] Auto-dismissing')
|
||||
onCloseRef.current()
|
||||
}, duration)
|
||||
return () => {
|
||||
console.log('[Toast] Clearing timer')
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [duration, onClose])
|
||||
}, [duration])
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle size={20} className="flex-shrink-0" />,
|
||||
|
||||
@@ -27,11 +27,17 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType) => {
|
||||
const id = Math.random().toString(36).substring(7)
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substring(7)}`
|
||||
console.log('[ToastContainer] Showing toast:', id, message)
|
||||
setToasts((prev) => {
|
||||
// Limit to 3 toasts at a time
|
||||
const newToasts = [...prev, { id, message, type }]
|
||||
return newToasts.slice(-3)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
console.log('[ToastContainer] Removing toast:', id)
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useOptimistic } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toggleItem } from '@/lib/actions'
|
||||
import { CheckCircle2, Circle } from 'lucide-react'
|
||||
import { useToast } from './ToastContainer'
|
||||
@@ -12,19 +13,31 @@ interface ToggleItemButtonProps {
|
||||
}
|
||||
|
||||
export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) {
|
||||
const router = useRouter()
|
||||
const { showToast } = useToast()
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(completed)
|
||||
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (isToggling) return
|
||||
|
||||
setIsToggling(true)
|
||||
|
||||
// Optimistically update UI
|
||||
setOptimisticCompleted(!completed)
|
||||
|
||||
try {
|
||||
await toggleItem(itemId)
|
||||
showToast(completed ? 'Task marked incomplete' : 'Task completed', 'success')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Error toggling item:', error)
|
||||
showToast('Failed to update task', 'error')
|
||||
// Revert optimistic update on error
|
||||
setOptimisticCompleted(completed)
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
@@ -36,9 +49,9 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
|
||||
onClick={handleToggle}
|
||||
disabled={isToggling}
|
||||
className="hover:scale-110 active:scale-95 transition-transform disabled:opacity-50 tap-target-sm"
|
||||
aria-label={completed ? 'Mark incomplete' : 'Mark complete'}
|
||||
aria-label={optimisticCompleted ? 'Mark incomplete' : 'Mark complete'}
|
||||
>
|
||||
{completed ? (
|
||||
{optimisticCompleted ? (
|
||||
<CheckCircle2 size={22} className="text-green-500" />
|
||||
) : (
|
||||
<Circle size={22} className="text-gray-400 hover:text-gray-600 active:text-gray-700" />
|
||||
@@ -53,7 +66,7 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
|
||||
disabled={isToggling}
|
||||
className="flex items-center gap-2 px-4 sm:px-5 py-2.5 sm:py-3 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 disabled:bg-gray-50 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
|
||||
>
|
||||
{completed ? (
|
||||
{optimisticCompleted ? (
|
||||
<>
|
||||
<CheckCircle2 size={18} className="text-green-500 flex-shrink-0" />
|
||||
<span>Mark Incomplete</span>
|
||||
|
||||
47
docker-compose.yml
Normal file
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 { useOfflineSync } 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 { registerUser, loginUser } from '../auth-actions'
|
||||
import { signIn } from '@/auth'
|
||||
import { signIn } from 'next-auth/react'
|
||||
|
||||
describe('registerUser', () => {
|
||||
// Clean up test users after each test
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { prisma } from './db'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import {
|
||||
validateListTitle,
|
||||
validateItemTitle,
|
||||
@@ -11,6 +12,11 @@ import {
|
||||
validateTags,
|
||||
sanitizeString,
|
||||
} from './validation'
|
||||
import {
|
||||
notifyItemAdded,
|
||||
notifyItemRemoved,
|
||||
notifyItemCompleted,
|
||||
} from './notification-helpers'
|
||||
|
||||
// Retry utility for database operations
|
||||
async function retryOperation<T>(
|
||||
@@ -49,7 +55,7 @@ async function retryOperation<T>(
|
||||
export async function getLists() {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to view lists')
|
||||
}
|
||||
@@ -109,7 +115,7 @@ export async function getLists() {
|
||||
export async function createList(title: string, color: string = '#3B82F6') {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to create lists')
|
||||
}
|
||||
@@ -157,7 +163,7 @@ export async function getListWithItems(listId: string) {
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to view lists')
|
||||
}
|
||||
@@ -228,7 +234,7 @@ export async function getItem(itemId: string) {
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to view items')
|
||||
}
|
||||
@@ -305,7 +311,7 @@ export async function createItem(
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to create items')
|
||||
}
|
||||
@@ -369,6 +375,14 @@ export async function createItem(
|
||||
})
|
||||
)
|
||||
|
||||
// Send notification to collaborators
|
||||
try {
|
||||
await notifyItemAdded(listId, item.title, userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error)
|
||||
// Don't fail the action if notification fails
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${listId}`)
|
||||
return item
|
||||
} catch (error) {
|
||||
@@ -387,7 +401,7 @@ export async function toggleItem(itemId: string) {
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to update items')
|
||||
}
|
||||
@@ -436,6 +450,15 @@ export async function toggleItem(itemId: string) {
|
||||
})
|
||||
)
|
||||
|
||||
// Send notification when item is completed (not uncompleted)
|
||||
if (updated.completed) {
|
||||
try {
|
||||
await notifyItemCompleted(item.listId, updated.title, userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error)
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${item.listId}`)
|
||||
return updated
|
||||
} catch (error) {
|
||||
@@ -465,7 +488,7 @@ export async function updateItem(
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to update items')
|
||||
}
|
||||
@@ -560,7 +583,7 @@ export async function deleteItem(itemId: string) {
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to delete items')
|
||||
}
|
||||
@@ -608,6 +631,13 @@ export async function deleteItem(itemId: string) {
|
||||
})
|
||||
)
|
||||
|
||||
// Send notification to collaborators
|
||||
try {
|
||||
await notifyItemRemoved(item.listId, item.title, userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send notification:', error)
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${item.listId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -626,7 +656,7 @@ export async function deleteList(listId: string) {
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to delete lists')
|
||||
}
|
||||
@@ -677,7 +707,7 @@ export async function updateList(
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to update lists')
|
||||
}
|
||||
@@ -751,7 +781,7 @@ export async function searchItems(query: string, listId?: string) {
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to search items')
|
||||
}
|
||||
@@ -878,7 +908,7 @@ export async function getItemsByFilter(
|
||||
) {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to filter items')
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { prisma } from './db'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { signIn, signOut } from '@/auth'
|
||||
import { signIn, signOut } from 'next-auth/react'
|
||||
import { checkRateLimit, resetRateLimit, LOGIN_RATE_LIMIT, REGISTER_RATE_LIMIT } from './rate-limit'
|
||||
import { headers } from 'next/headers'
|
||||
import { getClientIdentifier } from './rate-limit'
|
||||
@@ -195,8 +195,9 @@ export async function logoutUser(): Promise<void> {
|
||||
*/
|
||||
export async function getCurrentSession() {
|
||||
try {
|
||||
const { auth } = await import('@/auth')
|
||||
const session = await auth()
|
||||
const { getServerSession } = await import('next-auth/next')
|
||||
const { authOptions } = await import('@/pages/api/auth/[...nextauth]')
|
||||
const session = await getServerSession(authOptions)
|
||||
return session
|
||||
} catch (error) {
|
||||
console.error('Session retrieval error:', error)
|
||||
|
||||
14
lib/db.ts
14
lib/db.ts
@@ -4,22 +4,8 @@ const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
// Create Prisma client with connection error handling
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
// Handle connection errors
|
||||
prisma.$connect().catch((error) => {
|
||||
console.error('Failed to connect to database:', error)
|
||||
// Don't throw here - let individual queries handle connection errors
|
||||
})
|
||||
|
||||
// Graceful shutdown
|
||||
if (typeof window === 'undefined') {
|
||||
process.on('beforeExit', async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
@@ -1,7 +1,8 @@
|
||||
'use server'
|
||||
|
||||
import { prisma } from './db'
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
|
||||
/**
|
||||
* Migration utilities for transitioning existing anonymous lists to user-owned lists
|
||||
@@ -71,7 +72,7 @@ export async function getClaimableLists(listIds: string[]): Promise<ClaimableLis
|
||||
export async function claimLists(listIds: string[]): Promise<MigrationResult> {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Unauthorized: Please log in to claim lists' }
|
||||
}
|
||||
@@ -116,7 +117,7 @@ export async function claimLists(listIds: string[]): Promise<MigrationResult> {
|
||||
*/
|
||||
export async function hasExistingLists(): Promise<boolean> {
|
||||
try {
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
201
lib/notification-helpers.ts
Normal file
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 { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/auth'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit'
|
||||
import {
|
||||
notifyListInvitation,
|
||||
notifyInvitationAccepted,
|
||||
notifyRemovedFromList,
|
||||
} from './notification-helpers'
|
||||
|
||||
// Validation schemas
|
||||
const inviteSchema = z.object({
|
||||
@@ -57,7 +63,7 @@ export async function inviteUserToList(
|
||||
) {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to invite users')
|
||||
}
|
||||
@@ -162,6 +168,14 @@ export async function inviteUserToList(
|
||||
})
|
||||
)
|
||||
|
||||
// Send notification to the invitee
|
||||
try {
|
||||
await notifyListInvitation(invitation.id, validEmail)
|
||||
} catch (error) {
|
||||
console.error('Failed to send invitation notification:', error)
|
||||
// Don't fail the action if notification fails
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${listId}`)
|
||||
return invitation
|
||||
} catch (error) {
|
||||
@@ -187,7 +201,7 @@ export async function inviteUserToList(
|
||||
export async function getInvitations() {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id || !session?.user?.email) {
|
||||
throw new Error('Unauthorized: Please log in to view invitations')
|
||||
}
|
||||
@@ -237,7 +251,7 @@ export async function getInvitations() {
|
||||
export async function acceptInvitation(invitationId: string) {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id || !session?.user?.email) {
|
||||
throw new Error('Unauthorized: Please log in to accept invitations')
|
||||
}
|
||||
@@ -319,6 +333,14 @@ export async function acceptInvitation(invitationId: string) {
|
||||
})
|
||||
)
|
||||
|
||||
// Notify the person who sent the invitation
|
||||
try {
|
||||
await notifyInvitationAccepted(invitationId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send acceptance notification:', error)
|
||||
// Don't fail the action if notification fails
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath(`/list/${invitation.listId}`)
|
||||
return result
|
||||
@@ -345,7 +367,7 @@ export async function acceptInvitation(invitationId: string) {
|
||||
export async function declineInvitation(invitationId: string) {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id || !session?.user?.email) {
|
||||
throw new Error('Unauthorized: Please log in to decline invitations')
|
||||
}
|
||||
@@ -408,7 +430,7 @@ export async function declineInvitation(invitationId: string) {
|
||||
export async function getCollaborators(listId: string) {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to view collaborators')
|
||||
}
|
||||
@@ -488,7 +510,7 @@ export async function updateCollaboratorPermission(
|
||||
) {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to update permissions')
|
||||
}
|
||||
@@ -566,7 +588,7 @@ export async function removeCollaborator(
|
||||
) {
|
||||
try {
|
||||
// Get current user session
|
||||
const session = await auth()
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized: Please log in to remove collaborators')
|
||||
}
|
||||
@@ -591,6 +613,20 @@ export async function removeCollaborator(
|
||||
throw new Error('List not found or you do not have permission to remove collaborators')
|
||||
}
|
||||
|
||||
// Get collaborator details before deleting
|
||||
const collaborator = await retryOperation(() =>
|
||||
prisma.collaborator.findUnique({
|
||||
where: { id: collaboratorId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// Delete the collaborator
|
||||
await retryOperation(() =>
|
||||
prisma.collaborator.delete({
|
||||
@@ -601,6 +637,16 @@ export async function removeCollaborator(
|
||||
})
|
||||
)
|
||||
|
||||
// Notify the removed user
|
||||
if (collaborator) {
|
||||
try {
|
||||
await notifyRemovedFromList(collaborator.user.id, list.title, userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to send removal notification:', error)
|
||||
// Don't fail the action if notification fails
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/list/${listId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
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'],
|
||||
} : false,
|
||||
},
|
||||
// Enable SWC minification for better performance
|
||||
swcMinify: true,
|
||||
// Optimize images
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
// Enable compression
|
||||
compress: true,
|
||||
// Production source maps (smaller)
|
||||
productionBrowserSourceMaps: false,
|
||||
trailingSlash: false,
|
||||
poweredByHeader: false,
|
||||
// Optimize for mobile Safari
|
||||
reactStrictMode: true,
|
||||
// Enable optimized font loading
|
||||
optimizeFonts: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
1438
package-lock.json
generated
1438
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@
|
||||
"db:studio": "prisma studio",
|
||||
"db:migrate": "npx tsx scripts/migrate-lists.ts",
|
||||
"icons:generate": "node scripts/generate-icons.js",
|
||||
"icons:info": "node scripts/svg-to-png.js"
|
||||
"icons:info": "node scripts/svg-to-png.js",
|
||||
"vapid:generate": "node scripts/generate-vapid-keys.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
@@ -22,9 +23,10 @@
|
||||
"lucide-react": "^0.294.0",
|
||||
"mysql2": "^3.15.3",
|
||||
"next": "14.0.4",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-auth": "^4.24.5",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,6 +34,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/web-push": "^3.6.3",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ownedLists List[] @relation("ListOwner")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ownedLists List[] @relation("ListOwner")
|
||||
collaborations Collaborator[]
|
||||
sentInvitations Invitation[] @relation("InvitationInviter")
|
||||
receivedInvitations Invitation[] @relation("InvitationReceiver")
|
||||
sentInvitations Invitation[] @relation("InvitationInviter")
|
||||
receivedInvitations Invitation[] @relation("InvitationReceiver")
|
||||
pushSubscriptions PushSubscription[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
endpoint String @db.VarChar(512)
|
||||
p256dh String @db.Text
|
||||
auth String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, endpoint])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model List {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "Ultimate Lists",
|
||||
"short_name": "Lists",
|
||||
"description": "The ultimate todo list app with advanced task management",
|
||||
"description": "The ultimate todo list app with advanced task management and continuous task input",
|
||||
"version": "1.0.2",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
|
||||
59
public/sw.js
59
public/sw.js
@@ -1,8 +1,8 @@
|
||||
// Service Worker for Ultimate Lists App
|
||||
// Implements caching strategies for offline functionality
|
||||
|
||||
const CACHE_NAME = 'ultimate-lists-v2';
|
||||
const RUNTIME_CACHE = 'ultimate-lists-runtime-v2';
|
||||
const CACHE_NAME = 'ultimate-lists-v8';
|
||||
const RUNTIME_CACHE = 'ultimate-lists-runtime-v8';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
@@ -60,6 +60,11 @@ self.addEventListener('fetch', (event) => {
|
||||
if (!url.protocol.startsWith('http')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip cross-origin requests for performance
|
||||
if (url.origin !== self.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle authentication-related requests
|
||||
if (isAuthRequest(url)) {
|
||||
@@ -275,3 +280,53 @@ self.addEventListener('message', (event) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Push notification event - receive and display notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = event.data.json();
|
||||
const { title, body, icon, badge, tag, data: notificationData } = data;
|
||||
|
||||
const options = {
|
||||
body,
|
||||
icon: icon || '/icon-192.svg',
|
||||
badge: badge || '/icon-192.svg',
|
||||
tag: tag || 'default',
|
||||
data: notificationData,
|
||||
vibrate: [200, 100, 200],
|
||||
requireInteraction: false,
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, options)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to show notification:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Notification click event - handle user interaction
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
// Check if there's already a window open
|
||||
for (const client of clientList) {
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Open a new window if none exists
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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