Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d0bfbca2c | |||
| d159cadacc | |||
| c65cce9de7 | |||
| 013d5692bf | |||
| 9f781d784d | |||
| f2674c379c | |||
| 653e4f0ea0 | |||
| 3270788902 | |||
| 70f8ce1a6b | |||
| 1d04352ed7 | |||
| 2fcbcaa302 | |||
| a78053474b | |||
| ae9349ca7e | |||
| 0afe014947 | |||
|
|
1da5dc0a30 | ||
| ba7d0c9121 | |||
|
|
b70411e1f1 | ||
| 01369a743d | |||
| 7c6703354e | |||
| 080977cdb7 | |||
| df7510da2e | |||
| e1515442f4 | |||
|
|
6d5b8f2314 | ||
|
|
5841e7b9d4 | ||
|
|
b2877877dd | ||
|
|
0f3aa43b89 | ||
|
|
e0b3102007 | ||
|
|
a5d87b8e25 | ||
|
|
22639a489a | ||
| 8d81d16682 | |||
| fa5ba578bb | |||
| 53ca792988 | |||
| e160357256 | |||
| c912663c3d | |||
| 66d2aa0a66 | |||
| 1d33e52100 | |||
| b35100c92f | |||
| 81acc68aaa |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
my-recipes/
|
||||||
|
my-recipes-chart/
|
||||||
@ -63,7 +63,7 @@ steps:
|
|||||||
- |
|
- |
|
||||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||||
echo "💡 Setting frontend tag to: $TAG"
|
echo "💡 Setting frontend tag to: $TAG"
|
||||||
yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
yq -i ".frontend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git commit -m "frontend: update tag to $TAG" || echo "No changes"
|
git commit -m "frontend: update tag to $TAG" || echo "No changes"
|
||||||
git push origin HEAD
|
git push origin HEAD
|
||||||
@ -93,7 +93,7 @@ steps:
|
|||||||
- |
|
- |
|
||||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||||
echo "💡 Setting backend tag to: $TAG"
|
echo "💡 Setting backend tag to: $TAG"
|
||||||
yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
yq -i ".backend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git add manifests/${CI_REPO_NAME}/values.yaml
|
git add manifests/${CI_REPO_NAME}/values.yaml
|
||||||
git commit -m "backend: update tag to $TAG" || echo "No changes"
|
git commit -m "backend: update tag to $TAG" || echo "No changes"
|
||||||
git push origin HEAD
|
git push origin HEAD
|
||||||
|
|||||||
25
backend/.env
25
backend/.env
@ -4,3 +4,28 @@ DB_USER=recipes_user
|
|||||||
DB_NAME=recipes_db
|
DB_NAME=recipes_db
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=dvirlabs@gmail.com
|
||||||
|
SMTP_PASSWORD=agaanrhbbazbdytv
|
||||||
|
SMTP_FROM=dvirlabs@gmail.com
|
||||||
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=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
|
||||||
|
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
|
||||||
|
FRONTEND_URL=http://localhost:5174
|
||||||
|
|
||||||
|
# Microsoft Entra ID (Azure AD) OAuth
|
||||||
|
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
|
||||||
|
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
|
||||||
|
AZURE_TENANT_ID=consumers
|
||||||
|
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback
|
||||||
|
|
||||||
|
# Cloudflare R2 Backup Configuration
|
||||||
|
R2_ENDPOINT=https://d4704b8c40b2f95b2c7bf7ee4ecc52f8.r2.cloudflarestorage.com
|
||||||
|
R2_ACCESS_KEY=1997b1e48a337c0dbe1f7552a08631b5
|
||||||
|
R2_SECRET_KEY=369694e39fedfedb254158c147171f5760de84fa2346d5d5d5a961f1f517dbc6
|
||||||
|
R2_BUCKET=my-recipes-db-bkp
|
||||||
28
backend/.env.local
Normal file
28
backend/.env.local
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db
|
||||||
|
DB_PASSWORD=Aa123456
|
||||||
|
DB_USER=recipes_user
|
||||||
|
DB_NAME=recipes_db
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=dvirlabs@gmail.com
|
||||||
|
SMTP_PASSWORD=agaanrhbbazbdytv
|
||||||
|
SMTP_FROM=dvirlabs@gmail.com
|
||||||
|
|
||||||
|
# Secret Key for sessions (OAuth state token)
|
||||||
|
SECRET_KEY=your-super-secret-key-min-32-chars-dev-only-change-in-prod
|
||||||
|
|
||||||
|
# Google OAuth (LOCAL - localhost redirect)
|
||||||
|
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=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
|
||||||
|
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
|
||||||
|
FRONTEND_URL=http://localhost:5174
|
||||||
|
|
||||||
|
# Microsoft Entra ID (Azure AD) OAuth
|
||||||
|
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
|
||||||
|
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
|
||||||
|
AZURE_TENANT_ID=consumers
|
||||||
|
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback
|
||||||
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
93
backend/BACKUP_README.md
Normal file
93
backend/BACKUP_README.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Database Backup & Restore Scripts
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Automated database backup system that exports PostgreSQL database, compresses it with gzip, and uploads to Cloudflare R2 storage.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
```bash
|
||||||
|
pip install boto3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
All configuration is stored in `.env` file:
|
||||||
|
- `R2_ENDPOINT`: Cloudflare R2 endpoint URL
|
||||||
|
- `R2_ACCESS_KEY`: R2 API access key
|
||||||
|
- `R2_SECRET_KEY`: R2 API secret key
|
||||||
|
- `R2_BUCKET`: R2 bucket name
|
||||||
|
- Database credentials (DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Create Backup
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python backup_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Export the database using `pg_dump`
|
||||||
|
2. Compress the dump with gzip (typically 80-90% reduction)
|
||||||
|
3. Upload to R2 with timestamp
|
||||||
|
4. List all backups in R2
|
||||||
|
5. Clean up old local backups (keeps last 3)
|
||||||
|
|
||||||
|
### Restore from Backup
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python restore_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. List all available backups in R2
|
||||||
|
2. Let you select which backup to restore
|
||||||
|
3. Download and decompress the backup
|
||||||
|
4. Restore to the database (with confirmation prompt)
|
||||||
|
|
||||||
|
**WARNING**: Restore will drop all existing tables and recreate them from backup!
|
||||||
|
|
||||||
|
## Automated Backups
|
||||||
|
|
||||||
|
### Linux/Mac (Cron)
|
||||||
|
Add to crontab:
|
||||||
|
```bash
|
||||||
|
# Daily backup at 2 AM
|
||||||
|
0 2 * * * cd /path/to/backend && python backup_db.py >> backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (Task Scheduler)
|
||||||
|
Create a scheduled task:
|
||||||
|
1. Open Task Scheduler
|
||||||
|
2. Create Basic Task
|
||||||
|
3. Name: "Recipe DB Backup"
|
||||||
|
4. Trigger: Daily at 2:00 AM
|
||||||
|
5. Action: Start a program
|
||||||
|
- Program: `python`
|
||||||
|
- Arguments: `backup_db.py`
|
||||||
|
- Start in: `C:\path\to\backend`
|
||||||
|
|
||||||
|
## Backup File Format
|
||||||
|
Files are named: `recipes_db_YYYYMMDD_HHMMSS.sql.gz`
|
||||||
|
|
||||||
|
Example: `recipes_db_20251221_140530.sql.gz`
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
- Local backups stored in: `backend/backups/`
|
||||||
|
- R2 backups stored in: `my-recipes-db-bkp` bucket
|
||||||
|
- Local backups auto-cleanup (keeps last 3)
|
||||||
|
- R2 backups are never auto-deleted (manual cleanup if needed)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### pg_dump not found
|
||||||
|
Install PostgreSQL client tools:
|
||||||
|
- **Windows**: Install PostgreSQL and add to PATH
|
||||||
|
- **Linux**: `sudo apt install postgresql-client`
|
||||||
|
- **Mac**: `brew install postgresql`
|
||||||
|
|
||||||
|
### Connection errors
|
||||||
|
Verify database credentials in `.env` file
|
||||||
|
|
||||||
|
### R2 upload errors
|
||||||
|
- Check R2 credentials
|
||||||
|
- Verify bucket exists
|
||||||
|
- Ensure API token has "Edit" permissions
|
||||||
164
backend/BACKUP_SYSTEM_COMPLETE.md
Normal file
164
backend/BACKUP_SYSTEM_COMPLETE.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# Database Backup System - Complete Setup
|
||||||
|
|
||||||
|
## ✅ What's Been Implemented
|
||||||
|
|
||||||
|
### 1. **Backend API Endpoints** (Admin Only)
|
||||||
|
- `POST /admin/backup` - Trigger manual backup
|
||||||
|
- `GET /admin/backups` - List all available backups
|
||||||
|
- `POST /admin/restore?filename=<name>` - Restore from backup
|
||||||
|
|
||||||
|
### 2. **Frontend Admin Panel**
|
||||||
|
- New "ניהול" (Management) tab in navigation (visible to admin users only)
|
||||||
|
- 🛡️ Admin button in top bar
|
||||||
|
- Full backup management interface:
|
||||||
|
- Create new backups instantly
|
||||||
|
- View all backups with dates and sizes
|
||||||
|
- Restore from any backup with confirmation
|
||||||
|
|
||||||
|
### 3. **Automated Weekly Backups**
|
||||||
|
- Batch script: `run_backup.bat`
|
||||||
|
- Full setup guide: `WEEKLY_BACKUP_SETUP.md`
|
||||||
|
- Configured for Windows Task Scheduler
|
||||||
|
|
||||||
|
## 🚀 How to Use
|
||||||
|
|
||||||
|
### **Manual Backup (Admin User)**
|
||||||
|
|
||||||
|
1. Login with admin account
|
||||||
|
2. Click 🛡️ "ניהול" button in top bar (or use the ניהול tab)
|
||||||
|
3. Click "צור גיבוי חדש" (Create New Backup)
|
||||||
|
4. Backup is created, compressed, and uploaded to R2
|
||||||
|
5. See confirmation toast: "גיבוי נוצר בהצלחה! 📦"
|
||||||
|
|
||||||
|
### **Restore from Backup (Admin User)**
|
||||||
|
|
||||||
|
1. Go to Admin Panel (🛡️ ניהול)
|
||||||
|
2. View all available backups in the table
|
||||||
|
3. Click "שחזר" (Restore) button for desired backup
|
||||||
|
4. Confirm the warning (this will delete current data!)
|
||||||
|
5. Page will refresh automatically after restore
|
||||||
|
|
||||||
|
### **Setup Weekly Automatic Backups**
|
||||||
|
|
||||||
|
Follow the instructions in `WEEKLY_BACKUP_SETUP.md`:
|
||||||
|
|
||||||
|
**Quick Steps:**
|
||||||
|
1. Open Task Scheduler (`Win + R` → `taskschd.msc`)
|
||||||
|
2. Create Task → "Recipe DB Weekly Backup"
|
||||||
|
3. Set trigger: Weekly, Sunday, 2:00 AM
|
||||||
|
4. Set action: Run `C:\Path\To\backend\run_backup.bat`
|
||||||
|
5. Configure to run even when not logged in
|
||||||
|
|
||||||
|
## 📁 Files Created/Modified
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- ✅ `backup_restore_api.py` - Core backup/restore functions
|
||||||
|
- ✅ `main.py` - Added admin endpoints
|
||||||
|
- ✅ `requirements.txt` - Added boto3 dependency
|
||||||
|
- ✅ `.env` - Added R2 credentials
|
||||||
|
- ✅ `run_backup.bat` - Windows batch script for scheduled tasks
|
||||||
|
- ✅ `BACKUP_README.md` - Complete documentation
|
||||||
|
- ✅ `WEEKLY_BACKUP_SETUP.md` - Task Scheduler setup guide
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- ✅ `backupApi.js` - API calls for backup operations
|
||||||
|
- ✅ `components/AdminPanel.jsx` - Admin UI component
|
||||||
|
- ✅ `components/TopBar.jsx` - Added admin button
|
||||||
|
- ✅ `App.jsx` - Added admin view and navigation
|
||||||
|
- ✅ `App.css` - Added admin panel styles
|
||||||
|
|
||||||
|
## 🔐 Security
|
||||||
|
|
||||||
|
- **Admin-only access**: All backup endpoints check `is_admin` flag
|
||||||
|
- **Non-admin users**: Cannot see the admin button or access backup endpoints
|
||||||
|
- **403 Forbidden**: Returned if non-admin tries to access admin endpoints
|
||||||
|
|
||||||
|
## 💾 Backup Details
|
||||||
|
|
||||||
|
### What's Backed Up
|
||||||
|
- Complete PostgreSQL database (recipes_db)
|
||||||
|
- All tables: users, recipes, grocery lists, shares, notifications
|
||||||
|
|
||||||
|
### Backup Process
|
||||||
|
1. Uses `pg_dump` to export database
|
||||||
|
2. Compresses with gzip (typically 80-90% size reduction)
|
||||||
|
3. Uploads to Cloudflare R2 with timestamp
|
||||||
|
4. Filename format: `recipes_db_YYYYMMDD_HHMMSS.sql.gz`
|
||||||
|
5. Local backups auto-cleanup (keeps last 3)
|
||||||
|
|
||||||
|
### Restore Process
|
||||||
|
1. Downloads from R2
|
||||||
|
2. Decompresses file
|
||||||
|
3. **Drops all existing tables** (CASCADE)
|
||||||
|
4. Restores from SQL file
|
||||||
|
5. Cleans up temporary files
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Test Manual Backup
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python backup_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Manual Restore
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python restore_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test via Web UI
|
||||||
|
1. Login as admin
|
||||||
|
2. Navigate to Admin Panel
|
||||||
|
3. Click "צור גיבוי חדש"
|
||||||
|
4. Check R2 bucket for new file
|
||||||
|
|
||||||
|
## ⚠️ Important Notes
|
||||||
|
|
||||||
|
1. **Restore is destructive**: It deletes ALL current data
|
||||||
|
2. **Admin access required**: Set user's `is_admin = true` in database
|
||||||
|
3. **R2 credentials**: Already configured in `.env`
|
||||||
|
4. **Weekly backups**: Manual setup required (follow WEEKLY_BACKUP_SETUP.md)
|
||||||
|
5. **PostgreSQL tools**: Must have `pg_dump` and `psql` in system PATH
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### "Admin access required" error
|
||||||
|
- Check if user has `is_admin = true` in database
|
||||||
|
- Run: `SELECT username, is_admin FROM users;` in psql
|
||||||
|
|
||||||
|
### Backup fails
|
||||||
|
- Check `backend/backup.log` for errors
|
||||||
|
- Verify R2 credentials in `.env`
|
||||||
|
- Verify database credentials in `.env`
|
||||||
|
- Test: `python backup_db.py` manually
|
||||||
|
|
||||||
|
### Can't see admin button
|
||||||
|
- Verify user's `is_admin` flag in database
|
||||||
|
- Refresh page after changing admin status
|
||||||
|
- Check browser console for errors
|
||||||
|
|
||||||
|
### Scheduled backup doesn't run
|
||||||
|
- Check Task Scheduler → Task History
|
||||||
|
- Verify `run_backup.bat` path is correct
|
||||||
|
- Check `backend/backup.log` for errors
|
||||||
|
- Test batch file manually first
|
||||||
|
|
||||||
|
## 📊 What Admins Can Do
|
||||||
|
|
||||||
|
✅ Create manual backups anytime
|
||||||
|
✅ View all backups with dates and sizes
|
||||||
|
✅ Restore from any backup point
|
||||||
|
✅ See backup history in table format
|
||||||
|
✅ All regular user features (recipes, grocery lists, etc.)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **✅ Test the system**: Create a manual backup from Admin Panel
|
||||||
|
2. **📅 Setup weekly backups**: Follow WEEKLY_BACKUP_SETUP.md
|
||||||
|
3. **🔒 Secure admin access**: Only give admin rights to trusted users
|
||||||
|
4. **📝 Document your backup strategy**: When/how often you back up
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Your database is now protected with automated backups! 🎉**
|
||||||
21
backend/MIGRATION_INSTRUCTIONS.md
Normal file
21
backend/MIGRATION_INSTRUCTIONS.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Database Migration Instructions
|
||||||
|
|
||||||
|
## Add auth_provider column to users table
|
||||||
|
|
||||||
|
Run this command in your backend directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows (PowerShell)
|
||||||
|
$env:PGPASSWORD="recipes_password"; psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
|
||||||
|
|
||||||
|
# Or using psql directly
|
||||||
|
psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Add the `auth_provider` column to the users table (default: 'local')
|
||||||
|
2. Update all existing users to have 'local' as their auth_provider
|
||||||
|
3. Create an index for faster lookups
|
||||||
|
4. Display the updated table structure
|
||||||
|
|
||||||
|
After running the migration, restart your backend server.
|
||||||
131
backend/WEEKLY_BACKUP_SETUP.md
Normal file
131
backend/WEEKLY_BACKUP_SETUP.md
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# Weekly Backup Setup - Windows Task Scheduler
|
||||||
|
|
||||||
|
This guide will help you set up automatic weekly backups of your database.
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Create Batch Script
|
||||||
|
|
||||||
|
Create a file `run_backup.bat` in the `backend` folder:
|
||||||
|
|
||||||
|
```batch
|
||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
python backup_db.py >> backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Open Task Scheduler
|
||||||
|
|
||||||
|
1. Press `Win + R`
|
||||||
|
2. Type `taskschd.msc`
|
||||||
|
3. Press Enter
|
||||||
|
|
||||||
|
### 3. Create New Task
|
||||||
|
|
||||||
|
1. Click "Create Task" (not "Create Basic Task")
|
||||||
|
2. In **General** tab:
|
||||||
|
- Name: `Recipe DB Weekly Backup`
|
||||||
|
- Description: `Automatic weekly database backup to Cloudflare R2`
|
||||||
|
- Select "Run whether user is logged on or not"
|
||||||
|
- Check "Run with highest privileges"
|
||||||
|
|
||||||
|
### 4. Configure Trigger
|
||||||
|
|
||||||
|
1. Go to **Triggers** tab
|
||||||
|
2. Click "New..."
|
||||||
|
3. Configure:
|
||||||
|
- Begin the task: `On a schedule`
|
||||||
|
- Settings: `Weekly`
|
||||||
|
- Recur every: `1 weeks`
|
||||||
|
- Days: Select `Sunday` (or your preferred day)
|
||||||
|
- Time: `02:00:00` (2 AM)
|
||||||
|
- Check "Enabled"
|
||||||
|
4. Click OK
|
||||||
|
|
||||||
|
### 5. Configure Action
|
||||||
|
|
||||||
|
1. Go to **Actions** tab
|
||||||
|
2. Click "New..."
|
||||||
|
3. Configure:
|
||||||
|
- Action: `Start a program`
|
||||||
|
- Program/script: `C:\Path\To\backend\run_backup.bat`
|
||||||
|
*(Replace with your actual path)*
|
||||||
|
- Start in: `C:\Path\To\backend\`
|
||||||
|
*(Replace with your actual path)*
|
||||||
|
4. Click OK
|
||||||
|
|
||||||
|
### 6. Additional Settings
|
||||||
|
|
||||||
|
1. Go to **Conditions** tab:
|
||||||
|
- Uncheck "Start the task only if the computer is on AC power"
|
||||||
|
|
||||||
|
2. Go to **Settings** tab:
|
||||||
|
- Check "Run task as soon as possible after a scheduled start is missed"
|
||||||
|
- If the task fails, restart every: `10 minutes`
|
||||||
|
- Attempt to restart up to: `3 times`
|
||||||
|
|
||||||
|
3. Click OK
|
||||||
|
|
||||||
|
### 7. Enter Password
|
||||||
|
|
||||||
|
- You'll be prompted to enter your Windows password
|
||||||
|
- This allows the task to run even when you're not logged in
|
||||||
|
|
||||||
|
## Verify Setup
|
||||||
|
|
||||||
|
### Test the Task
|
||||||
|
|
||||||
|
1. In Task Scheduler, find your task
|
||||||
|
2. Right-click → "Run"
|
||||||
|
3. Check `backend/backup.log` for results
|
||||||
|
|
||||||
|
### View Scheduled Runs
|
||||||
|
|
||||||
|
- In Task Scheduler, select your task
|
||||||
|
- Check the "History" tab to see past runs
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Task doesn't run
|
||||||
|
|
||||||
|
- Check Task Scheduler → Task History for errors
|
||||||
|
- Verify Python is in system PATH
|
||||||
|
- Try running `run_backup.bat` manually first
|
||||||
|
|
||||||
|
### No log file created
|
||||||
|
|
||||||
|
- Check file permissions in backend folder
|
||||||
|
- Verify the "Start in" path is correct
|
||||||
|
|
||||||
|
### Backup fails
|
||||||
|
|
||||||
|
- Check `backend/backup.log` for error messages
|
||||||
|
- Verify database credentials in `.env`
|
||||||
|
- Verify R2 credentials in `.env`
|
||||||
|
- Test by running `python backup_db.py` manually
|
||||||
|
|
||||||
|
## Change Backup Schedule
|
||||||
|
|
||||||
|
1. Open Task Scheduler
|
||||||
|
2. Find "Recipe DB Weekly Backup"
|
||||||
|
3. Right-click → Properties
|
||||||
|
4. Go to Triggers tab
|
||||||
|
5. Edit the trigger to change day/time
|
||||||
|
6. Click OK
|
||||||
|
|
||||||
|
## Disable Automatic Backups
|
||||||
|
|
||||||
|
1. Open Task Scheduler
|
||||||
|
2. Find "Recipe DB Weekly Backup"
|
||||||
|
3. Right-click → Disable
|
||||||
|
|
||||||
|
## View Backup Log
|
||||||
|
|
||||||
|
Check `backend/backup.log` to see backup history:
|
||||||
|
|
||||||
|
```batch
|
||||||
|
cd backend
|
||||||
|
type backup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Or open it in Notepad.
|
||||||
BIN
backend/__pycache__/auth_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/auth_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/auth_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/auth_utils.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/email_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/email_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/grocery_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/grocery_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/grocery_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/grocery_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/__pycache__/notification_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/notification_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/notification_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/notification_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/oauth_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/oauth_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/user_db_utils.cpython-312.pyc
Normal file
BIN
backend/__pycache__/user_db_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/user_db_utils.cpython-313.pyc
Normal file
BIN
backend/__pycache__/user_db_utils.cpython-313.pyc
Normal file
Binary file not shown.
30
backend/add_auth_provider_column.sql
Normal file
30
backend/add_auth_provider_column.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- Add auth_provider column to users table
|
||||||
|
-- This tracks whether the user is local or uses OAuth (google, microsoft, etc.)
|
||||||
|
|
||||||
|
-- Add the column if it doesn't exist
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name='users' AND column_name='auth_provider'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE users ADD COLUMN auth_provider VARCHAR(50) DEFAULT 'local' NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Update existing users to have 'local' as their auth_provider
|
||||||
|
UPDATE users SET auth_provider = 'local' WHERE auth_provider IS NULL;
|
||||||
|
|
||||||
|
-- Create index for faster lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_auth_provider ON users(auth_provider);
|
||||||
|
|
||||||
|
-- Display the updated users table structure
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
is_nullable,
|
||||||
|
column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users'
|
||||||
|
ORDER BY ordinal_position;
|
||||||
5
backend/add_is_pinned_column.sql
Normal file
5
backend/add_is_pinned_column.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- Add is_pinned column to grocery_lists table
|
||||||
|
ALTER TABLE grocery_lists ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Verify the column was added
|
||||||
|
\d grocery_lists
|
||||||
96
backend/auth_utils.py
Normal file
96
backend/auth_utils.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
import bcrypt
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Secret key for JWT (use environment variable in production)
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password for storing."""
|
||||||
|
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||||
|
password_bytes = password.encode('utf-8')[:72]
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||||
|
return hashed.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a stored password against one provided by user"""
|
||||||
|
# Bcrypt has a 72 byte limit, truncate if necessary
|
||||||
|
password_bytes = plain_password.encode('utf-8')[:72]
|
||||||
|
hashed_bytes = hashed_password.encode('utf-8')
|
||||||
|
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
"""Create JWT access token"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode and verify JWT token"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
||||||
|
"""Get current user from JWT token (for protected routes)"""
|
||||||
|
from user_db_utils import get_user_by_id
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_token(token)
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get full user info from database to include is_admin
|
||||||
|
user = get_user_by_id(int(user_id))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": user["id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"display_name": user["display_name"],
|
||||||
|
"is_admin": user.get("is_admin", False)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Optional dependency - returns None if no token provided
|
||||||
|
def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[dict]:
|
||||||
|
"""Get current user if authenticated, otherwise None"""
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return get_current_user(credentials)
|
||||||
|
except HTTPException:
|
||||||
|
return None
|
||||||
209
backend/backup_db.py
Normal file
209
backend/backup_db.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Database backup script for R2 storage
|
||||||
|
Exports PostgreSQL database, compresses it, and uploads to Cloudflare R2
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import gzip
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# R2 Configuration
|
||||||
|
R2_ENDPOINT = os.getenv("R2_ENDPOINT")
|
||||||
|
R2_ACCESS_KEY = os.getenv("R2_ACCESS_KEY")
|
||||||
|
R2_SECRET_KEY = os.getenv("R2_SECRET_KEY")
|
||||||
|
R2_BUCKET = os.getenv("R2_BUCKET")
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||||
|
DB_PORT = os.getenv("DB_PORT", "5432")
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "recipes_db")
|
||||||
|
DB_USER = os.getenv("DB_USER", "recipes_user")
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "recipes_password")
|
||||||
|
|
||||||
|
# Backup directory
|
||||||
|
BACKUP_DIR = Path(__file__).parent / "backups"
|
||||||
|
BACKUP_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_db_dump():
|
||||||
|
"""Create PostgreSQL database dump"""
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
dump_file = BACKUP_DIR / f"recipes_db_{timestamp}.sql"
|
||||||
|
|
||||||
|
print(f"Creating database dump: {dump_file}")
|
||||||
|
|
||||||
|
# Set PGPASSWORD environment variable for pg_dump
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PGPASSWORD'] = DB_PASSWORD
|
||||||
|
|
||||||
|
# Run pg_dump
|
||||||
|
cmd = [
|
||||||
|
"pg_dump",
|
||||||
|
"-h", DB_HOST,
|
||||||
|
"-p", DB_PORT,
|
||||||
|
"-U", DB_USER,
|
||||||
|
"-d", DB_NAME,
|
||||||
|
"-f", str(dump_file),
|
||||||
|
"--no-owner", # Don't include ownership commands
|
||||||
|
"--no-acl", # Don't include access privileges
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, env=env, check=True, capture_output=True, text=True)
|
||||||
|
print(f"✓ Database dump created: {dump_file}")
|
||||||
|
return dump_file
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"✗ Error creating database dump: {e.stderr}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def compress_file(file_path):
|
||||||
|
"""Compress file using gzip"""
|
||||||
|
compressed_file = Path(str(file_path) + ".gz")
|
||||||
|
|
||||||
|
print(f"Compressing {file_path.name}...")
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f_in:
|
||||||
|
with gzip.open(compressed_file, 'wb', compresslevel=9) as f_out:
|
||||||
|
shutil.copyfileobj(f_in, f_out)
|
||||||
|
|
||||||
|
# Remove uncompressed file
|
||||||
|
file_path.unlink()
|
||||||
|
|
||||||
|
# Get compression ratio
|
||||||
|
original_size = file_path.stat().st_size if file_path.exists() else 0
|
||||||
|
compressed_size = compressed_file.stat().st_size
|
||||||
|
ratio = (1 - compressed_size / max(original_size, 1)) * 100 if original_size > 0 else 0
|
||||||
|
|
||||||
|
print(f"✓ Compressed to {compressed_file.name}")
|
||||||
|
print(f" Original: {original_size / 1024:.2f} KB")
|
||||||
|
print(f" Compressed: {compressed_size / 1024:.2f} KB")
|
||||||
|
print(f" Ratio: {ratio:.1f}% reduction")
|
||||||
|
|
||||||
|
return compressed_file
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_r2(file_path):
|
||||||
|
"""Upload file to Cloudflare R2"""
|
||||||
|
print(f"Uploading {file_path.name} to R2...")
|
||||||
|
|
||||||
|
# Configure S3 client for R2
|
||||||
|
s3_client = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=R2_ENDPOINT,
|
||||||
|
aws_access_key_id=R2_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=R2_SECRET_KEY,
|
||||||
|
config=Config(signature_version='s3v4'),
|
||||||
|
region_name='auto'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload file
|
||||||
|
try:
|
||||||
|
s3_client.upload_file(
|
||||||
|
str(file_path),
|
||||||
|
R2_BUCKET,
|
||||||
|
file_path.name,
|
||||||
|
ExtraArgs={
|
||||||
|
'Metadata': {
|
||||||
|
'backup-date': datetime.now().isoformat(),
|
||||||
|
'db-name': DB_NAME,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f"✓ Uploaded to R2: s3://{R2_BUCKET}/{file_path.name}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error uploading to R2: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def list_r2_backups():
|
||||||
|
"""List all backups in R2 bucket"""
|
||||||
|
print(f"\nListing backups in R2 bucket: {R2_BUCKET}")
|
||||||
|
|
||||||
|
s3_client = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=R2_ENDPOINT,
|
||||||
|
aws_access_key_id=R2_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=R2_SECRET_KEY,
|
||||||
|
config=Config(signature_version='s3v4'),
|
||||||
|
region_name='auto'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = s3_client.list_objects_v2(Bucket=R2_BUCKET)
|
||||||
|
|
||||||
|
if 'Contents' not in response:
|
||||||
|
print("No backups found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\nFound {len(response['Contents'])} backup(s):")
|
||||||
|
for obj in sorted(response['Contents'], key=lambda x: x['LastModified'], reverse=True):
|
||||||
|
size_mb = obj['Size'] / (1024 * 1024)
|
||||||
|
print(f" - {obj['Key']}")
|
||||||
|
print(f" Size: {size_mb:.2f} MB")
|
||||||
|
print(f" Date: {obj['LastModified']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error listing backups: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_local_backups(keep_last=3):
|
||||||
|
"""Keep only the last N local backups"""
|
||||||
|
backups = sorted(BACKUP_DIR.glob("*.sql.gz"), key=lambda x: x.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
if len(backups) > keep_last:
|
||||||
|
print(f"\nCleaning up old local backups (keeping last {keep_last})...")
|
||||||
|
for backup in backups[keep_last:]:
|
||||||
|
print(f" Removing: {backup.name}")
|
||||||
|
backup.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main backup process"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Database Backup to Cloudflare R2")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify R2 credentials
|
||||||
|
if not all([R2_ENDPOINT, R2_ACCESS_KEY, R2_SECRET_KEY, R2_BUCKET]):
|
||||||
|
raise ValueError("Missing R2 credentials in environment variables")
|
||||||
|
|
||||||
|
# Create database dump
|
||||||
|
dump_file = create_db_dump()
|
||||||
|
|
||||||
|
# Compress the dump
|
||||||
|
compressed_file = compress_file(dump_file)
|
||||||
|
|
||||||
|
# Upload to R2
|
||||||
|
upload_to_r2(compressed_file)
|
||||||
|
|
||||||
|
# List all backups
|
||||||
|
list_r2_backups()
|
||||||
|
|
||||||
|
# Cleanup old local backups
|
||||||
|
cleanup_old_local_backups(keep_last=3)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✓ Backup completed successfully!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"✗ Backup failed: {e}")
|
||||||
|
print("=" * 60)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
245
backend/backup_restore_api.py
Normal file
245
backend/backup_restore_api.py
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Backup and Restore API endpoints for database management.
|
||||||
|
Admin-only access required.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import gzip
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def get_r2_client():
|
||||||
|
"""Get configured R2 client"""
|
||||||
|
return boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=os.getenv('R2_ENDPOINT'),
|
||||||
|
aws_access_key_id=os.getenv('R2_ACCESS_KEY'),
|
||||||
|
aws_secret_access_key=os.getenv('R2_SECRET_KEY'),
|
||||||
|
region_name='auto'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_db_dump() -> str:
|
||||||
|
"""Create a database dump file"""
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
backup_dir = os.path.join(os.path.dirname(__file__), 'backups')
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
|
||||||
|
dump_file = os.path.join(backup_dir, f'recipes_db_{timestamp}.sql')
|
||||||
|
|
||||||
|
db_host = os.getenv('DB_HOST', 'localhost')
|
||||||
|
db_port = os.getenv('DB_PORT', '5432')
|
||||||
|
db_name = os.getenv('DB_NAME', 'recipes_db')
|
||||||
|
db_user = os.getenv('DB_USER', 'postgres')
|
||||||
|
db_password = os.getenv('DB_PASSWORD', 'postgres')
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PGPASSWORD'] = db_password
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'pg_dump',
|
||||||
|
'-h', db_host,
|
||||||
|
'-p', db_port,
|
||||||
|
'-U', db_user,
|
||||||
|
'-d', db_name,
|
||||||
|
'--no-owner',
|
||||||
|
'--no-acl',
|
||||||
|
'-f', dump_file
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise Exception(f"pg_dump failed: {result.stderr}")
|
||||||
|
|
||||||
|
return dump_file
|
||||||
|
|
||||||
|
|
||||||
|
def compress_file(file_path: str) -> str:
|
||||||
|
"""Compress a file with gzip"""
|
||||||
|
compressed_path = f"{file_path}.gz"
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as f_in:
|
||||||
|
with gzip.open(compressed_path, 'wb', compresslevel=9) as f_out:
|
||||||
|
shutil.copyfileobj(f_in, f_out)
|
||||||
|
|
||||||
|
os.remove(file_path)
|
||||||
|
return compressed_path
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_r2(file_path: str) -> str:
|
||||||
|
"""Upload file to R2"""
|
||||||
|
s3_client = get_r2_client()
|
||||||
|
bucket_name = os.getenv('R2_BUCKET')
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
s3_client.upload_file(file_path, bucket_name, file_name)
|
||||||
|
return file_name
|
||||||
|
except ClientError as e:
|
||||||
|
raise Exception(f"R2 upload failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def list_r2_backups() -> List[dict]:
|
||||||
|
"""List all backups in R2"""
|
||||||
|
s3_client = get_r2_client()
|
||||||
|
bucket_name = os.getenv('R2_BUCKET')
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = s3_client.list_objects_v2(Bucket=bucket_name)
|
||||||
|
|
||||||
|
if 'Contents' not in response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
backups = []
|
||||||
|
for obj in response['Contents']:
|
||||||
|
backups.append({
|
||||||
|
'filename': obj['Key'],
|
||||||
|
'size': obj['Size'],
|
||||||
|
'last_modified': obj['LastModified'].isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
backups.sort(key=lambda x: x['last_modified'], reverse=True)
|
||||||
|
return backups
|
||||||
|
|
||||||
|
except ClientError as e:
|
||||||
|
raise Exception(f"Failed to list R2 backups: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def download_from_r2(filename: str) -> str:
|
||||||
|
"""Download a backup from R2"""
|
||||||
|
s3_client = get_r2_client()
|
||||||
|
bucket_name = os.getenv('R2_BUCKET')
|
||||||
|
|
||||||
|
backup_dir = os.path.join(os.path.dirname(__file__), 'backups')
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
|
||||||
|
local_path = os.path.join(backup_dir, filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
s3_client.download_file(bucket_name, filename, local_path)
|
||||||
|
return local_path
|
||||||
|
except ClientError as e:
|
||||||
|
raise Exception(f"R2 download failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def decompress_file(compressed_path: str) -> str:
|
||||||
|
"""Decompress a gzipped file"""
|
||||||
|
if not compressed_path.endswith('.gz'):
|
||||||
|
raise ValueError("File must be gzipped (.gz)")
|
||||||
|
|
||||||
|
decompressed_path = compressed_path[:-3]
|
||||||
|
|
||||||
|
with gzip.open(compressed_path, 'rb') as f_in:
|
||||||
|
with open(decompressed_path, 'wb') as f_out:
|
||||||
|
shutil.copyfileobj(f_in, f_out)
|
||||||
|
|
||||||
|
return decompressed_path
|
||||||
|
|
||||||
|
|
||||||
|
def restore_database(sql_file: str) -> None:
|
||||||
|
"""Restore database from SQL file"""
|
||||||
|
db_host = os.getenv('DB_HOST', 'localhost')
|
||||||
|
db_port = os.getenv('DB_PORT', '5432')
|
||||||
|
db_name = os.getenv('DB_NAME', 'recipes_db')
|
||||||
|
db_user = os.getenv('DB_USER', 'postgres')
|
||||||
|
db_password = os.getenv('DB_PASSWORD', 'postgres')
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PGPASSWORD'] = db_password
|
||||||
|
|
||||||
|
# Drop all tables first
|
||||||
|
drop_cmd = [
|
||||||
|
'psql',
|
||||||
|
'-h', db_host,
|
||||||
|
'-p', db_port,
|
||||||
|
'-U', db_user,
|
||||||
|
'-d', db_name,
|
||||||
|
'-c', 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
||||||
|
]
|
||||||
|
|
||||||
|
drop_result = subprocess.run(drop_cmd, env=env, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if drop_result.returncode != 0:
|
||||||
|
raise Exception(f"Failed to drop schema: {drop_result.stderr}")
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
restore_cmd = [
|
||||||
|
'psql',
|
||||||
|
'-h', db_host,
|
||||||
|
'-p', db_port,
|
||||||
|
'-U', db_user,
|
||||||
|
'-d', db_name,
|
||||||
|
'-f', sql_file
|
||||||
|
]
|
||||||
|
|
||||||
|
restore_result = subprocess.run(restore_cmd, env=env, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if restore_result.returncode != 0:
|
||||||
|
raise Exception(f"Database restore failed: {restore_result.stderr}")
|
||||||
|
|
||||||
|
|
||||||
|
def perform_backup() -> dict:
|
||||||
|
"""Perform complete backup process"""
|
||||||
|
try:
|
||||||
|
# Create dump
|
||||||
|
dump_file = create_db_dump()
|
||||||
|
|
||||||
|
# Compress
|
||||||
|
compressed_file = compress_file(dump_file)
|
||||||
|
|
||||||
|
# Upload to R2
|
||||||
|
r2_filename = upload_to_r2(compressed_file)
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
file_size = os.path.getsize(compressed_file)
|
||||||
|
|
||||||
|
# Clean up local file
|
||||||
|
os.remove(compressed_file)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'filename': r2_filename,
|
||||||
|
'size': file_size,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def perform_restore(filename: str) -> dict:
|
||||||
|
"""Perform complete restore process"""
|
||||||
|
try:
|
||||||
|
# Download from R2
|
||||||
|
compressed_file = download_from_r2(filename)
|
||||||
|
|
||||||
|
# Decompress
|
||||||
|
sql_file = decompress_file(compressed_file)
|
||||||
|
|
||||||
|
# Restore database
|
||||||
|
restore_database(sql_file)
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
os.remove(compressed_file)
|
||||||
|
os.remove(sql_file)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'filename': filename,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
@ -54,10 +54,12 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, meal_type, time_minutes,
|
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||||
tags, ingredients, steps, image, made_by
|
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||||
FROM recipes
|
u.display_name as owner_display_name
|
||||||
ORDER BY id
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON r.user_id = u.id
|
||||||
|
ORDER BY r.id
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
@ -85,7 +87,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
|||||||
image = %s,
|
image = %s,
|
||||||
made_by = %s
|
made_by = %s
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
recipe_data["name"],
|
recipe_data["name"],
|
||||||
@ -133,9 +135,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by)
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
recipe_data["name"],
|
recipe_data["name"],
|
||||||
@ -146,6 +148,7 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
json.dumps(recipe_data.get("steps", [])),
|
json.dumps(recipe_data.get("steps", [])),
|
||||||
recipe_data.get("image"),
|
recipe_data.get("image"),
|
||||||
recipe_data.get("made_by"),
|
recipe_data.get("made_by"),
|
||||||
|
recipe_data.get("user_id"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@ -162,19 +165,21 @@ def get_recipes_by_filters_db(
|
|||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT id, name, meal_type, time_minutes,
|
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||||
tags, ingredients, steps, image, made_by
|
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||||
FROM recipes
|
u.display_name as owner_display_name
|
||||||
|
FROM recipes r
|
||||||
|
LEFT JOIN users u ON r.user_id = u.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
"""
|
"""
|
||||||
params: List = []
|
params: List = []
|
||||||
|
|
||||||
if meal_type:
|
if meal_type:
|
||||||
query += " AND meal_type = %s"
|
query += " AND r.meal_type = %s"
|
||||||
params.append(meal_type.lower())
|
params.append(meal_type.lower())
|
||||||
|
|
||||||
if max_time:
|
if max_time:
|
||||||
query += " AND time_minutes <= %s"
|
query += " AND r.time_minutes <= %s"
|
||||||
params.append(max_time)
|
params.append(max_time)
|
||||||
|
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|||||||
211
backend/email_utils.py
Normal file
211
backend/email_utils.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import os
|
||||||
|
import random
|
||||||
|
import aiosmtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# In-memory storage for verification codes (in production, use Redis or database)
|
||||||
|
verification_codes = {}
|
||||||
|
password_reset_tokens = {}
|
||||||
|
|
||||||
|
def generate_verification_code():
|
||||||
|
"""Generate a 6-digit verification code"""
|
||||||
|
return str(random.randint(100000, 999999))
|
||||||
|
|
||||||
|
async def send_verification_email(email: str, code: str, purpose: str = "password_change"):
|
||||||
|
"""Send verification code via email"""
|
||||||
|
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||||
|
smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||||
|
smtp_user = os.getenv("SMTP_USER")
|
||||||
|
smtp_password = os.getenv("SMTP_PASSWORD")
|
||||||
|
smtp_from = os.getenv("SMTP_FROM", smtp_user)
|
||||||
|
|
||||||
|
if not smtp_user or not smtp_password:
|
||||||
|
raise Exception("SMTP credentials not configured")
|
||||||
|
|
||||||
|
# Create message
|
||||||
|
message = MIMEMultipart("alternative")
|
||||||
|
message["Subject"] = "קוד אימות - מתכונים שלי"
|
||||||
|
message["From"] = smtp_from
|
||||||
|
message["To"] = email
|
||||||
|
|
||||||
|
# Email content
|
||||||
|
if purpose == "password_change":
|
||||||
|
text = f"""
|
||||||
|
שלום,
|
||||||
|
|
||||||
|
קוד האימות שלך לשינוי סיסמה הוא: {code}
|
||||||
|
|
||||||
|
הקוד תקף ל-10 דקות.
|
||||||
|
|
||||||
|
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
|
||||||
|
|
||||||
|
בברכה,
|
||||||
|
צוות מתכונים שלי
|
||||||
|
"""
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<html dir="rtl">
|
||||||
|
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
|
||||||
|
<h2>שינוי סיסמה</h2>
|
||||||
|
<p>קוד האימות שלך הוא:</p>
|
||||||
|
<h1 style="color: #22c55e; font-size: 32px; letter-spacing: 5px;">{code}</h1>
|
||||||
|
<p>הקוד תקף ל-<strong>10 דקות</strong>.</p>
|
||||||
|
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
part1 = MIMEText(text, "plain")
|
||||||
|
part2 = MIMEText(html, "html")
|
||||||
|
message.attach(part1)
|
||||||
|
message.attach(part2)
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
await aiosmtplib.send(
|
||||||
|
message,
|
||||||
|
hostname=smtp_host,
|
||||||
|
port=smtp_port,
|
||||||
|
username=smtp_user,
|
||||||
|
password=smtp_password,
|
||||||
|
start_tls=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def store_verification_code(user_id: int, code: str):
|
||||||
|
"""Store verification code with expiry"""
|
||||||
|
expiry = datetime.now() + timedelta(minutes=10)
|
||||||
|
verification_codes[user_id] = {
|
||||||
|
"code": code,
|
||||||
|
"expiry": expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_code(user_id: int, code: str) -> bool:
|
||||||
|
"""Verify if code is correct and not expired"""
|
||||||
|
if user_id not in verification_codes:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stored = verification_codes[user_id]
|
||||||
|
|
||||||
|
# Check if expired
|
||||||
|
if datetime.now() > stored["expiry"]:
|
||||||
|
del verification_codes[user_id]
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if code matches
|
||||||
|
if stored["code"] != code:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Code is valid, remove it
|
||||||
|
del verification_codes[user_id]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def send_password_reset_email(email: str, token: str, frontend_url: str):
|
||||||
|
"""Send password reset link via email"""
|
||||||
|
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
|
||||||
|
smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
||||||
|
smtp_user = os.getenv("SMTP_USER")
|
||||||
|
smtp_password = os.getenv("SMTP_PASSWORD")
|
||||||
|
smtp_from = os.getenv("SMTP_FROM", smtp_user)
|
||||||
|
|
||||||
|
if not smtp_user or not smtp_password:
|
||||||
|
raise Exception("SMTP credentials not configured")
|
||||||
|
|
||||||
|
reset_link = f"{frontend_url}?reset_token={token}"
|
||||||
|
|
||||||
|
# Create message
|
||||||
|
message = MIMEMultipart("alternative")
|
||||||
|
message["Subject"] = "איפוס סיסמה - מתכונים שלי"
|
||||||
|
message["From"] = smtp_from
|
||||||
|
message["To"] = email
|
||||||
|
|
||||||
|
text = f"""
|
||||||
|
שלום,
|
||||||
|
|
||||||
|
קיבלנו בקשה לאיפוס הסיסמה שלך.
|
||||||
|
|
||||||
|
לחץ על הקישור הבא כדי לאפס את הסיסמה (תקף ל-30 דקות):
|
||||||
|
{reset_link}
|
||||||
|
|
||||||
|
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
|
||||||
|
|
||||||
|
בברכה,
|
||||||
|
צוות מתכונים שלי
|
||||||
|
"""
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<html dir="rtl">
|
||||||
|
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
|
||||||
|
<h2>איפוס סיסמה</h2>
|
||||||
|
<p>קיבלנו בקשה לאיפוס הסיסמה שלך.</p>
|
||||||
|
<p>לחץ על הכפתור למטה כדי לאפס את הסיסמה:</p>
|
||||||
|
<div style="margin: 30px 0; text-align: center;">
|
||||||
|
<a href="{reset_link}"
|
||||||
|
style="background-color: #22c55e; color: white; padding: 12px 30px;
|
||||||
|
text-decoration: none; border-radius: 6px; display: inline-block;
|
||||||
|
font-weight: bold;">
|
||||||
|
איפוס סיסמה
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
הקישור תקף ל-<strong>30 דקות</strong>.
|
||||||
|
</p>
|
||||||
|
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
part1 = MIMEText(text, "plain")
|
||||||
|
part2 = MIMEText(html, "html")
|
||||||
|
message.attach(part1)
|
||||||
|
message.attach(part2)
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
await aiosmtplib.send(
|
||||||
|
message,
|
||||||
|
hostname=smtp_host,
|
||||||
|
port=smtp_port,
|
||||||
|
username=smtp_user,
|
||||||
|
password=smtp_password,
|
||||||
|
start_tls=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def store_password_reset_token(email: str, token: str):
|
||||||
|
"""Store password reset token with expiry"""
|
||||||
|
expiry = datetime.now() + timedelta(minutes=30)
|
||||||
|
password_reset_tokens[token] = {
|
||||||
|
"email": email,
|
||||||
|
"expiry": expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_reset_token(token: str) -> str:
|
||||||
|
"""Verify reset token and return email if valid"""
|
||||||
|
if token not in password_reset_tokens:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stored = password_reset_tokens[token]
|
||||||
|
|
||||||
|
# Check if expired
|
||||||
|
if datetime.now() > stored["expiry"]:
|
||||||
|
del password_reset_tokens[token]
|
||||||
|
return None
|
||||||
|
|
||||||
|
return stored["email"]
|
||||||
|
|
||||||
|
|
||||||
|
def consume_reset_token(token: str):
|
||||||
|
"""Remove token after use"""
|
||||||
|
if token in password_reset_tokens:
|
||||||
|
del password_reset_tokens[token]
|
||||||
275
backend/grocery_db_utils.py
Normal file
275
backend/grocery_db_utils.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import os
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Get database connection"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.getenv("DB_HOST", "localhost"),
|
||||||
|
port=int(os.getenv("DB_PORT", "5432")),
|
||||||
|
database=os.getenv("DB_NAME", "recipes_db"),
|
||||||
|
user=os.getenv("DB_USER", "recipes_user"),
|
||||||
|
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_grocery_list(owner_id: int, name: str, items: List[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Create a new grocery list"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
items = items or []
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO grocery_lists (owner_id, name, items)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
RETURNING id, name, items, owner_id, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(owner_id, name, items)
|
||||||
|
)
|
||||||
|
grocery_list = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(grocery_list)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_grocery_lists(user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all grocery lists owned by or shared with a user"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
|
||||||
|
u.display_name as owner_display_name,
|
||||||
|
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
|
||||||
|
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
|
||||||
|
FROM grocery_lists gl
|
||||||
|
LEFT JOIN users u ON gl.owner_id = u.id
|
||||||
|
LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s
|
||||||
|
WHERE gl.owner_id = %s OR gls.shared_with_user_id = %s
|
||||||
|
ORDER BY gl.updated_at DESC
|
||||||
|
""",
|
||||||
|
(user_id, user_id, user_id, user_id, user_id)
|
||||||
|
)
|
||||||
|
lists = cur.fetchall()
|
||||||
|
return [dict(row) for row in lists]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_grocery_list_by_id(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get a specific grocery list if user has access"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
|
||||||
|
u.display_name as owner_display_name,
|
||||||
|
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
|
||||||
|
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
|
||||||
|
FROM grocery_lists gl
|
||||||
|
LEFT JOIN users u ON gl.owner_id = u.id
|
||||||
|
LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s
|
||||||
|
WHERE gl.id = %s AND (gl.owner_id = %s OR gls.shared_with_user_id = %s)
|
||||||
|
""",
|
||||||
|
(user_id, user_id, user_id, list_id, user_id, user_id)
|
||||||
|
)
|
||||||
|
grocery_list = cur.fetchone()
|
||||||
|
return dict(grocery_list) if grocery_list else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_grocery_list(list_id: int, name: str = None, items: List[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Update a grocery list"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
updates.append("name = %s")
|
||||||
|
params.append(name)
|
||||||
|
|
||||||
|
if items is not None:
|
||||||
|
updates.append("items = %s")
|
||||||
|
params.append(items)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||||
|
params.append(list_id)
|
||||||
|
|
||||||
|
query = f"UPDATE grocery_lists SET {', '.join(updates)} WHERE id = %s RETURNING id, name, items, owner_id, created_at, updated_at"
|
||||||
|
|
||||||
|
cur.execute(query, params)
|
||||||
|
grocery_list = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(grocery_list) if grocery_list else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_grocery_list(list_id: int) -> bool:
|
||||||
|
"""Delete a grocery list"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("DELETE FROM grocery_lists WHERE id = %s", (list_id,))
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
return deleted
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def share_grocery_list(list_id: int, shared_with_user_id: int, can_edit: bool = False) -> Dict[str, Any]:
|
||||||
|
"""Share a grocery list with another user or update existing share permissions"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO grocery_list_shares (list_id, shared_with_user_id, can_edit)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (list_id, shared_with_user_id)
|
||||||
|
DO UPDATE SET can_edit = EXCLUDED.can_edit, shared_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, list_id, shared_with_user_id, can_edit, shared_at
|
||||||
|
""",
|
||||||
|
(list_id, shared_with_user_id, can_edit)
|
||||||
|
)
|
||||||
|
share = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(share)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_share_permission(list_id: int, shared_with_user_id: int, can_edit: bool) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Update edit permission for an existing share"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE grocery_list_shares
|
||||||
|
SET can_edit = %s
|
||||||
|
WHERE list_id = %s AND shared_with_user_id = %s
|
||||||
|
RETURNING id, list_id, shared_with_user_id, can_edit, shared_at
|
||||||
|
""",
|
||||||
|
(can_edit, list_id, shared_with_user_id)
|
||||||
|
)
|
||||||
|
share = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(share) if share else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def unshare_grocery_list(list_id: int, user_id: int) -> bool:
|
||||||
|
"""Remove sharing access for a user"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM grocery_list_shares WHERE list_id = %s AND shared_with_user_id = %s",
|
||||||
|
(list_id, user_id)
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
return deleted
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_grocery_list_shares(list_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all users a grocery list is shared with"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT gls.id, gls.list_id, gls.shared_with_user_id, gls.can_edit, gls.shared_at,
|
||||||
|
u.username, u.display_name, u.email
|
||||||
|
FROM grocery_list_shares gls
|
||||||
|
JOIN users u ON gls.shared_with_user_id = u.id
|
||||||
|
WHERE gls.list_id = %s
|
||||||
|
ORDER BY gls.shared_at DESC
|
||||||
|
""",
|
||||||
|
(list_id,)
|
||||||
|
)
|
||||||
|
shares = cur.fetchall()
|
||||||
|
return [dict(row) for row in shares]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def search_users(query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||||
|
"""Search users by username or display_name for autocomplete"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, display_name, email
|
||||||
|
FROM users
|
||||||
|
WHERE username ILIKE %s OR display_name ILIKE %s
|
||||||
|
ORDER BY username
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(f"%{query}%", f"%{query}%", limit)
|
||||||
|
)
|
||||||
|
users = cur.fetchall()
|
||||||
|
return [dict(row) for row in users]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_grocery_list_pin(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Toggle pin status for a grocery list (owner only)"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Check if user is owner
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, is_pinned FROM grocery_lists WHERE id = %s AND owner_id = %s",
|
||||||
|
(list_id, user_id)
|
||||||
|
)
|
||||||
|
result = cur.fetchone()
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Toggle pin status
|
||||||
|
new_pin_status = not result["is_pinned"]
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE grocery_lists
|
||||||
|
SET is_pinned = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, name, items, owner_id, is_pinned, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(new_pin_status, list_id)
|
||||||
|
)
|
||||||
|
updated = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(updated) if updated else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
1041
backend/main.py
1041
backend/main.py
File diff suppressed because it is too large
Load Diff
124
backend/notification_db_utils.py
Normal file
124
backend/notification_db_utils.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
Database utilities for managing notifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from db_utils import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
def create_notification(user_id: int, type: str, message: str, related_id: int = None):
|
||||||
|
"""Create a new notification for a user."""
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO notifications (user_id, type, message, related_id)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id, user_id, type, message, related_id, is_read, created_at
|
||||||
|
""",
|
||||||
|
(user_id, type, message, related_id)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"type": row["type"],
|
||||||
|
"message": row["message"],
|
||||||
|
"related_id": row["related_id"],
|
||||||
|
"is_read": row["is_read"],
|
||||||
|
"created_at": row["created_at"]
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_notifications(user_id: int, unread_only: bool = False):
|
||||||
|
"""Get all notifications for a user."""
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, user_id, type, message, related_id, is_read, created_at
|
||||||
|
FROM notifications
|
||||||
|
WHERE user_id = %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
if unread_only:
|
||||||
|
query += " AND is_read = FALSE"
|
||||||
|
|
||||||
|
query += " ORDER BY created_at DESC"
|
||||||
|
|
||||||
|
cur.execute(query, (user_id,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
notifications = []
|
||||||
|
for row in rows:
|
||||||
|
notifications.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"type": row["type"],
|
||||||
|
"message": row["message"],
|
||||||
|
"related_id": row["related_id"],
|
||||||
|
"is_read": row["is_read"],
|
||||||
|
"created_at": row["created_at"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
|
||||||
|
def mark_notification_as_read(notification_id: int, user_id: int):
|
||||||
|
"""Mark a notification as read."""
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE notifications
|
||||||
|
SET is_read = TRUE
|
||||||
|
WHERE id = %s AND user_id = %s
|
||||||
|
""",
|
||||||
|
(notification_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def mark_all_notifications_as_read(user_id: int):
|
||||||
|
"""Mark all notifications for a user as read."""
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE notifications
|
||||||
|
SET is_read = TRUE
|
||||||
|
WHERE user_id = %s AND is_read = FALSE
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_notification(notification_id: int, user_id: int):
|
||||||
|
"""Delete a notification."""
|
||||||
|
conn = get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM notifications
|
||||||
|
WHERE id = %s AND user_id = %s
|
||||||
|
""",
|
||||||
|
(notification_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
33
backend/oauth_utils.py
Normal file
33
backend/oauth_utils.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import os
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
from starlette.config import Config
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config = Config('.env')
|
||||||
|
|
||||||
|
# Initialize OAuth
|
||||||
|
oauth = OAuth(config)
|
||||||
|
|
||||||
|
# Register Google OAuth
|
||||||
|
oauth.register(
|
||||||
|
name='google',
|
||||||
|
client_id=os.getenv('GOOGLE_CLIENT_ID'),
|
||||||
|
client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
|
||||||
|
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
||||||
|
client_kwargs={
|
||||||
|
'scope': 'openid email profile'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register Microsoft Entra ID (Azure AD) OAuth
|
||||||
|
# Use 'common' for multi-tenant + personal accounts, or 'consumers' for personal accounts only
|
||||||
|
tenant_id = os.getenv('AZURE_TENANT_ID', 'common')
|
||||||
|
oauth.register(
|
||||||
|
name='azure',
|
||||||
|
client_id=os.getenv('AZURE_CLIENT_ID'),
|
||||||
|
client_secret=os.getenv('AZURE_CLIENT_SECRET'),
|
||||||
|
server_metadata_url=f'https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration',
|
||||||
|
client_kwargs={
|
||||||
|
'scope': 'openid email profile'
|
||||||
|
}
|
||||||
|
)
|
||||||
@ -2,6 +2,24 @@ fastapi==0.115.0
|
|||||||
uvicorn[standard]==0.30.1
|
uvicorn[standard]==0.30.1
|
||||||
|
|
||||||
pydantic==2.7.4
|
pydantic==2.7.4
|
||||||
|
pydantic[email]==2.7.4
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.9
|
||||||
|
bcrypt==4.1.2
|
||||||
|
|
||||||
|
# Email
|
||||||
|
aiosmtplib==3.0.2
|
||||||
|
|
||||||
|
# OAuth
|
||||||
|
authlib==1.3.0
|
||||||
|
httpx==0.27.0
|
||||||
|
itsdangerous==2.1.2
|
||||||
|
|
||||||
|
# Backup to R2
|
||||||
|
boto3==1.34.17
|
||||||
|
|||||||
41
backend/reset_admin_password.py
Normal file
41
backend/reset_admin_password.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import psycopg2
|
||||||
|
import bcrypt
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# New password for admin
|
||||||
|
new_password = "admin123" # Change this to whatever you want
|
||||||
|
|
||||||
|
# Hash the password
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
password_hash = bcrypt.hashpw(new_password.encode('utf-8'), salt).decode('utf-8')
|
||||||
|
|
||||||
|
# Update in database
|
||||||
|
conn = psycopg2.connect(os.getenv('DATABASE_URL'))
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Update admin password
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET password_hash = %s WHERE username = %s",
|
||||||
|
(password_hash, 'admin')
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
cur.execute("SELECT username, email, is_admin FROM users WHERE username = 'admin'")
|
||||||
|
user = cur.fetchone()
|
||||||
|
if user:
|
||||||
|
print(f"✓ Admin password updated successfully!")
|
||||||
|
print(f" Username: {user[0]}")
|
||||||
|
print(f" Email: {user[1]}")
|
||||||
|
print(f" Is Admin: {user[2]}")
|
||||||
|
print(f"\nYou can now login with:")
|
||||||
|
print(f" Username: admin")
|
||||||
|
print(f" Password: {new_password}")
|
||||||
|
else:
|
||||||
|
print("✗ Admin user not found!")
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
218
backend/restore_db.py
Normal file
218
backend/restore_db.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Database restore script from R2 storage
|
||||||
|
Downloads compressed backup from R2 and restores to PostgreSQL
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import gzip
|
||||||
|
from pathlib import Path
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# R2 Configuration
|
||||||
|
R2_ENDPOINT = os.getenv("R2_ENDPOINT")
|
||||||
|
R2_ACCESS_KEY = os.getenv("R2_ACCESS_KEY")
|
||||||
|
R2_SECRET_KEY = os.getenv("R2_SECRET_KEY")
|
||||||
|
R2_BUCKET = os.getenv("R2_BUCKET")
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||||
|
DB_PORT = os.getenv("DB_PORT", "5432")
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "recipes_db")
|
||||||
|
DB_USER = os.getenv("DB_USER", "recipes_user")
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD", "recipes_password")
|
||||||
|
|
||||||
|
# Restore directory
|
||||||
|
RESTORE_DIR = Path(__file__).parent / "restores"
|
||||||
|
RESTORE_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def list_r2_backups():
|
||||||
|
"""List all available backups in R2"""
|
||||||
|
s3_client = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=R2_ENDPOINT,
|
||||||
|
aws_access_key_id=R2_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=R2_SECRET_KEY,
|
||||||
|
config=Config(signature_version='s3v4'),
|
||||||
|
region_name='auto'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = s3_client.list_objects_v2(Bucket=R2_BUCKET)
|
||||||
|
|
||||||
|
if 'Contents' not in response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
backups = sorted(response['Contents'], key=lambda x: x['LastModified'], reverse=True)
|
||||||
|
return backups
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error listing backups: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def download_from_r2(backup_name):
|
||||||
|
"""Download backup file from R2"""
|
||||||
|
local_file = RESTORE_DIR / backup_name
|
||||||
|
|
||||||
|
print(f"Downloading {backup_name} from R2...")
|
||||||
|
|
||||||
|
s3_client = boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=R2_ENDPOINT,
|
||||||
|
aws_access_key_id=R2_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=R2_SECRET_KEY,
|
||||||
|
config=Config(signature_version='s3v4'),
|
||||||
|
region_name='auto'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
s3_client.download_file(R2_BUCKET, backup_name, str(local_file))
|
||||||
|
size_mb = local_file.stat().st_size / (1024 * 1024)
|
||||||
|
print(f"✓ Downloaded: {local_file.name} ({size_mb:.2f} MB)")
|
||||||
|
return local_file
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error downloading from R2: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def decompress_file(compressed_file):
|
||||||
|
"""Decompress gzip file"""
|
||||||
|
decompressed_file = Path(str(compressed_file).replace('.gz', ''))
|
||||||
|
|
||||||
|
print(f"Decompressing {compressed_file.name}...")
|
||||||
|
|
||||||
|
with gzip.open(compressed_file, 'rb') as f_in:
|
||||||
|
with open(decompressed_file, 'wb') as f_out:
|
||||||
|
f_out.write(f_in.read())
|
||||||
|
|
||||||
|
compressed_size = compressed_file.stat().st_size
|
||||||
|
decompressed_size = decompressed_file.stat().st_size
|
||||||
|
|
||||||
|
print(f"✓ Decompressed to {decompressed_file.name}")
|
||||||
|
print(f" Compressed: {compressed_size / 1024:.2f} KB")
|
||||||
|
print(f" Decompressed: {decompressed_size / 1024:.2f} KB")
|
||||||
|
|
||||||
|
return decompressed_file
|
||||||
|
|
||||||
|
|
||||||
|
def restore_database(sql_file):
|
||||||
|
"""Restore PostgreSQL database from SQL file"""
|
||||||
|
print(f"\nRestoring database from {sql_file.name}...")
|
||||||
|
print("WARNING: This will overwrite the current database!")
|
||||||
|
|
||||||
|
response = input("Are you sure you want to continue? (yes/no): ")
|
||||||
|
if response.lower() != 'yes':
|
||||||
|
print("Restore cancelled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Set PGPASSWORD environment variable
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PGPASSWORD'] = DB_PASSWORD
|
||||||
|
|
||||||
|
# Drop and recreate database (optional, comment out if you want to merge)
|
||||||
|
print("Dropping existing tables...")
|
||||||
|
drop_cmd = [
|
||||||
|
"psql",
|
||||||
|
"-h", DB_HOST,
|
||||||
|
"-p", DB_PORT,
|
||||||
|
"-U", DB_USER,
|
||||||
|
"-d", DB_NAME,
|
||||||
|
"-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(drop_cmd, env=env, check=True, capture_output=True, text=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Warning: Could not drop schema: {e.stderr}")
|
||||||
|
|
||||||
|
# Restore from backup
|
||||||
|
print("Restoring database...")
|
||||||
|
restore_cmd = [
|
||||||
|
"psql",
|
||||||
|
"-h", DB_HOST,
|
||||||
|
"-p", DB_PORT,
|
||||||
|
"-U", DB_USER,
|
||||||
|
"-d", DB_NAME,
|
||||||
|
"-f", str(sql_file)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(restore_cmd, env=env, check=True, capture_output=True, text=True)
|
||||||
|
print("✓ Database restored successfully!")
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"✗ Error restoring database: {e.stderr}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main restore process"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Database Restore from Cloudflare R2")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify R2 credentials
|
||||||
|
if not all([R2_ENDPOINT, R2_ACCESS_KEY, R2_SECRET_KEY, R2_BUCKET]):
|
||||||
|
raise ValueError("Missing R2 credentials in environment variables")
|
||||||
|
|
||||||
|
# List available backups
|
||||||
|
print("Available backups:")
|
||||||
|
backups = list_r2_backups()
|
||||||
|
|
||||||
|
if not backups:
|
||||||
|
print("No backups found in R2")
|
||||||
|
return
|
||||||
|
|
||||||
|
for i, backup in enumerate(backups, 1):
|
||||||
|
size_mb = backup['Size'] / (1024 * 1024)
|
||||||
|
print(f"{i}. {backup['Key']}")
|
||||||
|
print(f" Size: {size_mb:.2f} MB, Date: {backup['LastModified']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Select backup
|
||||||
|
choice = input(f"Select backup to restore (1-{len(backups)}) or 'q' to quit: ")
|
||||||
|
|
||||||
|
if choice.lower() == 'q':
|
||||||
|
print("Restore cancelled")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
backup_index = int(choice) - 1
|
||||||
|
if backup_index < 0 or backup_index >= len(backups):
|
||||||
|
raise ValueError()
|
||||||
|
except ValueError:
|
||||||
|
print("Invalid selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_backup = backups[backup_index]['Key']
|
||||||
|
|
||||||
|
# Download backup
|
||||||
|
compressed_file = download_from_r2(selected_backup)
|
||||||
|
|
||||||
|
# Decompress backup
|
||||||
|
sql_file = decompress_file(compressed_file)
|
||||||
|
|
||||||
|
# Restore database
|
||||||
|
restore_database(sql_file)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✓ Restore completed successfully!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"✗ Restore failed: {e}")
|
||||||
|
print("=" * 60)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1496
backend/restores/recipes_db_20251221_030046.sql
Normal file
1496
backend/restores/recipes_db_20251221_030046.sql
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/restores/recipes_db_20251221_030046.sql.gz
Normal file
BIN
backend/restores/recipes_db_20251221_030046.sql.gz
Normal file
Binary file not shown.
3
backend/run_backup.bat
Normal file
3
backend/run_backup.bat
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
python backup_db.py >> backup.log 2>&1
|
||||||
@ -1,14 +1,32 @@
|
|||||||
|
-- Create users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
display_name TEXT UNIQUE NOT NULL,
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||||
|
|
||||||
-- Create recipes table
|
-- Create recipes table
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
||||||
time_minutes INTEGER NOT NULL,
|
time_minutes INTEGER NOT NULL,
|
||||||
|
tags TEXT[] NOT NULL DEFAULT '{}', -- {"מהיר", "בריא"}
|
||||||
|
ingredients TEXT[] NOT NULL DEFAULT '{}', -- {"ביצה", "עגבניה", "מלח"}
|
||||||
|
steps TEXT[] NOT NULL DEFAULT '{}', -- {"לחתוך", "לבשל", ...}
|
||||||
|
image TEXT, -- Base64-encoded image or image URL
|
||||||
made_by TEXT, -- Person who created this recipe version
|
made_by TEXT, -- Person who created this recipe version
|
||||||
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner
|
||||||
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
|
|
||||||
image TEXT -- Base64-encoded image or image URL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Optional: index for filters
|
-- Optional: index for filters
|
||||||
@ -21,9 +39,52 @@ CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
|
|||||||
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
|
CREATE INDEX IF NOT EXISTS idx_recipes_made_by
|
||||||
ON recipes (made_by);
|
ON recipes (made_by);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
|
CREATE INDEX IF NOT EXISTS idx_recipes_user_id
|
||||||
ON recipes USING GIN (tags);
|
ON recipes (user_id);
|
||||||
|
|
||||||
|
-- Create grocery lists table
|
||||||
|
CREATE TABLE IF NOT EXISTS grocery_lists (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
items TEXT[] NOT NULL DEFAULT '{}', -- Array of grocery items
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
is_pinned BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create grocery list shares table
|
||||||
|
CREATE TABLE IF NOT EXISTS grocery_list_shares (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
list_id INTEGER NOT NULL REFERENCES grocery_lists(id) ON DELETE CASCADE,
|
||||||
|
shared_with_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
can_edit BOOLEAN DEFAULT FALSE,
|
||||||
|
shared_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(list_id, shared_with_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grocery_lists_owner_id ON grocery_lists (owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grocery_list_shares_list_id ON grocery_list_shares (list_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grocery_list_shares_user_id ON grocery_list_shares (shared_with_user_id);
|
||||||
|
|
||||||
|
-- Create notifications table
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL, -- 'grocery_share', etc.
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
related_id INTEGER, -- Related entity ID (e.g., list_id)
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications (is_read);
|
||||||
|
|
||||||
|
-- Create default admin user (password: admin123)
|
||||||
|
-- Password hash generated with bcrypt for 'admin123'
|
||||||
|
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin)
|
||||||
|
VALUES ('admin', 'admin@myrecipes.local', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5lE7UGf3rCvHC', 'Admin', 'User', 'מנהל', TRUE)
|
||||||
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
|
|
||||||
ON recipes USING GIN (ingredients);
|
|
||||||
|
|
||||||
|
|||||||
202
backend/social_db_utils.py
Normal file
202
backend/social_db_utils.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from psycopg2 import errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Get database connection"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.getenv("DB_HOST", "localhost"),
|
||||||
|
port=int(os.getenv("DB_PORT", "5432")),
|
||||||
|
database=os.getenv("DB_NAME", "recipes_db"),
|
||||||
|
user=os.getenv("DB_USER", "recipes_user"),
|
||||||
|
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Friends System =============
|
||||||
|
|
||||||
|
def send_friend_request(sender_id: int, receiver_id: int):
|
||||||
|
"""Send a friend request"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Check if already friends
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
|
||||||
|
(sender_id, receiver_id, receiver_id, sender_id)
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
return {"error": "Already friends"}
|
||||||
|
|
||||||
|
# Check if request already exists
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM friend_requests WHERE sender_id = %s AND receiver_id = %s AND status = 'pending'",
|
||||||
|
(sender_id, receiver_id)
|
||||||
|
)
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
return dict(existing)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO friend_requests (sender_id, receiver_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
RETURNING id, sender_id, receiver_id, status, created_at
|
||||||
|
""",
|
||||||
|
(sender_id, receiver_id)
|
||||||
|
)
|
||||||
|
request = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(request)
|
||||||
|
except errors.UniqueViolation:
|
||||||
|
# Request already exists, fetch and return it
|
||||||
|
conn.rollback()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, sender_id, receiver_id, status, created_at FROM friend_requests WHERE sender_id = %s AND receiver_id = %s",
|
||||||
|
(sender_id, receiver_id)
|
||||||
|
)
|
||||||
|
existing_request = cur.fetchone()
|
||||||
|
if existing_request:
|
||||||
|
return dict(existing_request)
|
||||||
|
return {"error": "Friend request already exists"}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def accept_friend_request(request_id: int):
|
||||||
|
"""Accept a friend request and create friendship"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Get request details
|
||||||
|
cur.execute(
|
||||||
|
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
|
||||||
|
(request_id,)
|
||||||
|
)
|
||||||
|
request = cur.fetchone()
|
||||||
|
if not request:
|
||||||
|
return {"error": "Request not found or already processed"}
|
||||||
|
|
||||||
|
sender_id = request["sender_id"]
|
||||||
|
receiver_id = request["receiver_id"]
|
||||||
|
|
||||||
|
# Create bidirectional friendship
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO friendships (user_id, friend_id) VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
|
(sender_id, receiver_id, receiver_id, sender_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update request status
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE friend_requests SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
|
(request_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def reject_friend_request(request_id: int):
|
||||||
|
"""Reject a friend request"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE friend_requests SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND status = 'pending'",
|
||||||
|
(request_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_friend_requests(user_id: int):
|
||||||
|
"""Get pending friend requests for a user"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT fr.id AS request_id, fr.sender_id, fr.receiver_id, fr.status, fr.created_at,
|
||||||
|
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email
|
||||||
|
FROM friend_requests fr
|
||||||
|
JOIN users u ON u.id = fr.sender_id
|
||||||
|
WHERE fr.receiver_id = %s AND fr.status = 'pending'
|
||||||
|
ORDER BY fr.created_at DESC
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_friends(user_id: int):
|
||||||
|
"""Get list of user's friends"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.display_name, u.email, f.created_at AS friends_since
|
||||||
|
FROM friendships f
|
||||||
|
JOIN users u ON u.id = f.friend_id
|
||||||
|
WHERE f.user_id = %s
|
||||||
|
ORDER BY u.display_name
|
||||||
|
""",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_friend(user_id: int, friend_id: int):
|
||||||
|
"""Remove a friend"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
|
||||||
|
(user_id, friend_id, friend_id, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def search_users(query: str, current_user_id: int, limit: int = 20):
|
||||||
|
"""Search for users by username or display name"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
search_pattern = f"%{query}%"
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT u.id, u.username, u.display_name, u.email,
|
||||||
|
EXISTS(SELECT 1 FROM friendships WHERE user_id = %s AND friend_id = u.id) AS is_friend,
|
||||||
|
EXISTS(SELECT 1 FROM friend_requests WHERE sender_id = %s AND receiver_id = u.id AND status = 'pending') AS request_sent
|
||||||
|
FROM users u
|
||||||
|
WHERE (u.username ILIKE %s OR u.display_name ILIKE %s) AND u.id != %s
|
||||||
|
ORDER BY u.display_name
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(current_user_id, current_user_id, search_pattern, search_pattern, current_user_id, limit)
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
117
backend/user_db_utils.py
Normal file
117
backend/user_db_utils.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Get database connection"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.getenv("DB_HOST", "localhost"),
|
||||||
|
port=int(os.getenv("DB_PORT", "5432")),
|
||||||
|
database=os.getenv("DB_NAME", "recipes_db"),
|
||||||
|
user=os.getenv("DB_USER", "recipes_user"),
|
||||||
|
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(username: str, email: str, password_hash: str, first_name: str = None, last_name: str = None, display_name: str = None, is_admin: bool = False, auth_provider: str = "local"):
|
||||||
|
"""Create a new user"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
# Use display_name if provided, otherwise use username
|
||||||
|
final_display_name = display_name if display_name else username
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, username, email, first_name, last_name, display_name, is_admin, auth_provider, created_at
|
||||||
|
""",
|
||||||
|
(username, email, password_hash, first_name, last_name, final_display_name, is_admin, auth_provider)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return dict(user)
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(username: str):
|
||||||
|
"""Get user by username"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE username = %s",
|
||||||
|
(username,)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email: str):
|
||||||
|
"""Get user by email"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE email = %s",
|
||||||
|
(email,)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_id(user_id: int):
|
||||||
|
"""Get user by ID"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_display_name(display_name: str):
|
||||||
|
"""Get user by display name"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, email, display_name, is_admin, created_at FROM users WHERE display_name = %s",
|
||||||
|
(display_name,)
|
||||||
|
)
|
||||||
|
user = cur.fetchone()
|
||||||
|
return dict(user) if user else None
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_user_auth_provider(user_id: int, auth_provider: str):
|
||||||
|
"""Update user's auth provider"""
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET auth_provider = %s WHERE id = %s",
|
||||||
|
(auth_provider, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
178
demo-recipes.sql
Normal file
178
demo-recipes.sql
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
-- Demo recipes for user dvir (id=3)
|
||||||
|
|
||||||
|
-- Recipe 1: שקשוקה
|
||||||
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
|
VALUES (
|
||||||
|
'שקשוקה',
|
||||||
|
'breakfast',
|
||||||
|
25,
|
||||||
|
'["מהיר", "בריא", "צמחוני"]'::jsonb,
|
||||||
|
'["4 ביצים", "4 עגבניות", "1 בצל", "2 שיני שום", "פלפל חריף", "כמון", "מלח", "שמן זית"]'::jsonb,
|
||||||
|
'[
|
||||||
|
"לחתוך את הבצל והשום דק",
|
||||||
|
"לחמם שמן בסיר ולטגן את הבצל עד שקוף",
|
||||||
|
"להוסיף שום ופלפל חריף ולטגן דקה",
|
||||||
|
"לקצוץ עגבניות ולהוסיף לסיר",
|
||||||
|
"לתבל בכמון ומלח, לבשל 10 דקות",
|
||||||
|
"לפתוח גומות ברוטב ולשבור ביצה בכל גומה",
|
||||||
|
"לכסות ולבשל עד שהביצים מתקשות"
|
||||||
|
]'::jsonb,
|
||||||
|
'https://images.unsplash.com/photo-1525351484163-7529414344d8?w=500',
|
||||||
|
'דביר',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recipe 2: פסטה ברוטב עגבניות
|
||||||
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
|
VALUES (
|
||||||
|
'פסטה ברוטב עגבניות',
|
||||||
|
'lunch',
|
||||||
|
20,
|
||||||
|
'["מהיר", "צמחוני", "ילדים אוהבים"]'::jsonb,
|
||||||
|
'["500 גרם פסטה", "רסק עגבניות", "בזיליקום טרי", "3 שיני שום", "שמן זית", "מלח", "פלפל"]'::jsonb,
|
||||||
|
'[
|
||||||
|
"להרתיח מים מלוחים ולבשל את הפסטה לפי ההוראות",
|
||||||
|
"בינתיים, לחמם שמן בסיר",
|
||||||
|
"לטגן שום כתוש דקה",
|
||||||
|
"להוסיף רסק עגבניות ולתבל",
|
||||||
|
"לבשל על אש בינונית 10 דקות",
|
||||||
|
"להוסיף בזיליקום קרוע",
|
||||||
|
"לערבב את הפסטה המסוננת עם הרוטב"
|
||||||
|
]'::jsonb,
|
||||||
|
'https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=500',
|
||||||
|
'דביר',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recipe 3: סלט ישראלי
|
||||||
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
|
VALUES (
|
||||||
|
'סלט ישראלי',
|
||||||
|
'snack',
|
||||||
|
10,
|
||||||
|
'["מהיר", "בריא", "טבעוני", "צמחוני"]'::jsonb,
|
||||||
|
'["4 עגבניות", "2 מלפפונים", "1 בצל", "פטרוזיליה", "לימון", "שמן זית", "מלח"]'::jsonb,
|
||||||
|
'[
|
||||||
|
"לחתוך עגבניות ומלפפונים לקוביות קטנות",
|
||||||
|
"לקצוץ בצל דק",
|
||||||
|
"לקצוץ פטרוזיליה",
|
||||||
|
"לערבב הכל בקערה",
|
||||||
|
"להוסיף מיץ לימון ושמן זית",
|
||||||
|
"לתבל במלח ולערבב היטב"
|
||||||
|
]'::jsonb,
|
||||||
|
'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=500',
|
||||||
|
'דביר',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recipe 4: חביתה עם ירקות
|
||||||
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
|
VALUES (
|
||||||
|
'חביתה עם ירקות',
|
||||||
|
'breakfast',
|
||||||
|
15,
|
||||||
|
'["מהיר", "בריא", "חלבוני", "צמחוני"]'::jsonb,
|
||||||
|
'["3 ביצים", "1 בצל", "1 פלפל", "עגבניה", "גבינה צהובה", "מלח", "פלפל שחור", "שמן"]'::jsonb,
|
||||||
|
'[
|
||||||
|
"לקצוץ את הירקות לקוביות קטנות",
|
||||||
|
"לטגן את הירקות בשמן עד שמתרככים",
|
||||||
|
"להקציף את הביצים במזלג",
|
||||||
|
"לשפוך את הביצים על הירקות",
|
||||||
|
"לפזר גבינה קצוצה",
|
||||||
|
"לבשל עד שהתחתית מוזהבת",
|
||||||
|
"להפוך או לקפל לחצי ולהגיש"
|
||||||
|
]'::jsonb,
|
||||||
|
'https://images.unsplash.com/photo-1525351159099-122d10e7960e?w=500',
|
||||||
|
'דביר',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recipe 5: עוף בתנור עם תפוחי אדמה
|
||||||
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
|
VALUES (
|
||||||
|
'עוף בתנור עם תפוחי אדמה',
|
||||||
|
'dinner',
|
||||||
|
60,
|
||||||
|
'["משפחתי", "חגיגי"]'::jsonb,
|
||||||
|
'["1 עוף שלם", "1 ק״ג תפוחי אדמה", "פפריקה", "כורכום", "שום", "מלח", "פלפל", "שמן זית", "לימון"]'::jsonb,
|
||||||
|
'[
|
||||||
|
"לחמם תנור ל-200 מעלות",
|
||||||
|
"לחתוך תפוחי אדמה לרבעים",
|
||||||
|
"לשפשף את העוף בתבלינים, שמן ומיץ לימון",
|
||||||
|
"לסדר תפוחי אדמה בתבנית",
|
||||||
|
"להניח את העוף על התפוחי אדמה",
|
||||||
|
"לאפות כשעה עד שהעוף מוזהב",
|
||||||
|
"להוציא, לחתוך ולהגיש עם הירקות"
|
||||||
|
]'::jsonb,
|
||||||
|
'https://images.unsplash.com/photo-1598103442097-8b74394b95c6?w=500',
|
||||||
|
'דביר',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recipe 6: סנדוויץ טונה
|
||||||
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
|
VALUES (
|
||||||
|
'סנדוויץ טונה',
|
||||||
|
'lunch',
|
||||||
|
5,
|
||||||
|
'["מהיר", "קר", "חלבוני"]'::jsonb,
|
||||||
|
'["קופסת טונה", "2 פרוסות לחם", "מיונז", "חסה", "עגבניה", "מלפפון חמוץ", "מלח", "פלפל"]'::jsonb,
|
||||||
|
'[
|
||||||
|
"לסנן את הטונה",
|
||||||
|
"לערבב את הטונה עם מיונז",
|
||||||
|
"לתבל במלח ופלפל",
|
||||||
|
"למרוח על פרוסת לחם",
|
||||||
|
"להוסיף חסה, עגבניה ומלפפון",
|
||||||
|
"לכסות בפרוסת לחם שנייה"
|
||||||
|
]'::jsonb,
|
||||||
|
'https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=500',
|
||||||
|
'דביר',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recipe 7: בראוניז שוקולד
|
||||||
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
|
VALUES (
|
||||||
|
'בראוניז שוקולד',
|
||||||
|
'snack',
|
||||||
|
35,
|
||||||
|
'["קינוח", "שוקולד", "אפייה"]'::jsonb,
|
||||||
|
'["200 גרם שוקולד מריר", "150 גרם חמאה", "3 ביצים", "כוס סוכר", "חצי כוס קמח", "אבקת קקאו", "וניל"]'::jsonb,
|
||||||
|
'[
|
||||||
|
"לחמם תנור ל-180 מעלות",
|
||||||
|
"להמיס שוקולד וחמאה במיקרוגל",
|
||||||
|
"להקציף ביצים וסוכר",
|
||||||
|
"להוסיף את תערובת השוקולד",
|
||||||
|
"להוסיף קמח וקקאו ולערבב",
|
||||||
|
"לשפוך לתבנית משומנת",
|
||||||
|
"לאפות 25 דקות",
|
||||||
|
"להוציא ולהניח להתקרר לפני חיתוך"
|
||||||
|
]'::jsonb,
|
||||||
|
'https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=500',
|
||||||
|
'דביר',
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Recipe 8: מרק עדשים
|
||||||
|
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||||
|
VALUES (
|
||||||
|
'מרק עדשים',
|
||||||
|
'dinner',
|
||||||
|
40,
|
||||||
|
'["בריא", "צמחוני", "טבעוני", "חם"]'::jsonb,
|
||||||
|
'["2 כוסות עדשים כתומות", "בצל", "גזר", "3 שיני שום", "כמון", "כורכום", "מלח", "לימון"]'::jsonb,
|
||||||
|
'[
|
||||||
|
"לשטוף את העדשים",
|
||||||
|
"לקצוץ בצל, גזר ושום",
|
||||||
|
"לטגן את הבצל עד שקוף",
|
||||||
|
"להוסיף שום ותבלינים",
|
||||||
|
"להוסיף גזר ועדשים",
|
||||||
|
"להוסיף 6 כוסות מים",
|
||||||
|
"לבשל 30 דקות עד שהעדשים רכים",
|
||||||
|
"לטחון חלק מהמרק לקבלת מרקם עבה",
|
||||||
|
"להוסיף מיץ לימון לפני הגשה"
|
||||||
|
]'::jsonb,
|
||||||
|
'https://images.unsplash.com/photo-1547592166-23ac45744acd?w=500',
|
||||||
|
'דביר',
|
||||||
|
3
|
||||||
|
);
|
||||||
@ -1,12 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="he" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/src/assets/my-recipes-logo-light.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/src/assets/my-recipes-logo-light.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
|
||||||
|
<title>My Recipes | המתכונים שלי</title>
|
||||||
<!-- Load environment variables before app starts -->
|
<!-- Load environment variables before app starts -->
|
||||||
<script src="/env.js"></script>
|
<script src="/env.js?v=20251219"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
6
frontend/public/sitemap.xml
Normal file
6
frontend/public/sitemap.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://my-recipes.dvirlabs.com/</loc>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
1009
frontend/src/App.css
1009
frontend/src/App.css
File diff suppressed because it is too large
Load Diff
@ -5,12 +5,34 @@ import TopBar from "./components/TopBar";
|
|||||||
import RecipeSearchList from "./components/RecipeSearchList";
|
import RecipeSearchList from "./components/RecipeSearchList";
|
||||||
import RecipeDetails from "./components/RecipeDetails";
|
import RecipeDetails from "./components/RecipeDetails";
|
||||||
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
||||||
|
import GroceryLists from "./components/GroceryLists";
|
||||||
|
import PinnedGroceryLists from "./components/PinnedGroceryLists";
|
||||||
|
import AdminPanel from "./components/AdminPanel";
|
||||||
import Modal from "./components/Modal";
|
import Modal from "./components/Modal";
|
||||||
import ToastContainer from "./components/ToastContainer";
|
import ToastContainer from "./components/ToastContainer";
|
||||||
import ThemeToggle from "./components/ThemeToggle";
|
import ThemeToggle from "./components/ThemeToggle";
|
||||||
|
import Login from "./components/Login";
|
||||||
|
import Register from "./components/Register";
|
||||||
|
import ResetPassword from "./components/ResetPassword";
|
||||||
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||||
|
import { getToken, removeToken, getMe } from "./authApi";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [authView, setAuthView] = useState("login"); // "login" or "register"
|
||||||
|
const [loadingAuth, setLoadingAuth] = useState(true);
|
||||||
|
const [resetToken, setResetToken] = useState(null);
|
||||||
|
const [currentView, setCurrentView] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("currentView") || "recipes";
|
||||||
|
} catch {
|
||||||
|
return "recipes";
|
||||||
|
}
|
||||||
|
}); // "recipes", "grocery-lists", or "admin"
|
||||||
|
|
||||||
|
const [selectedGroceryListId, setSelectedGroceryListId] = useState(null);
|
||||||
|
|
||||||
const [recipes, setRecipes] = useState([]);
|
const [recipes, setRecipes] = useState([]);
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
const [selectedRecipe, setSelectedRecipe] = useState(null);
|
||||||
|
|
||||||
@ -19,7 +41,7 @@ function App() {
|
|||||||
const [filterMealType, setFilterMealType] = useState("");
|
const [filterMealType, setFilterMealType] = useState("");
|
||||||
const [filterMaxTime, setFilterMaxTime] = useState("");
|
const [filterMaxTime, setFilterMaxTime] = useState("");
|
||||||
const [filterTags, setFilterTags] = useState([]);
|
const [filterTags, setFilterTags] = useState([]);
|
||||||
const [filterMadeBy, setFilterMadeBy] = useState("");
|
const [filterOwner, setFilterOwner] = useState("");
|
||||||
|
|
||||||
// Random recipe filters
|
// Random recipe filters
|
||||||
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
||||||
@ -33,6 +55,7 @@ function App() {
|
|||||||
const [editingRecipe, setEditingRecipe] = useState(null);
|
const [editingRecipe, setEditingRecipe] = useState(null);
|
||||||
|
|
||||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
||||||
|
const [logoutModal, setLogoutModal] = useState(false);
|
||||||
const [toasts, setToasts] = useState([]);
|
const [toasts, setToasts] = useState([]);
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@ -41,7 +64,86 @@ function App() {
|
|||||||
return "dark";
|
return "dark";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const [showPinnedSidebar, setShowPinnedSidebar] = useState(false);
|
||||||
|
|
||||||
|
// Swipe gesture handling for mobile sidebar
|
||||||
|
const [touchStart, setTouchStart] = useState(null);
|
||||||
|
const [touchEnd, setTouchEnd] = useState(null);
|
||||||
|
|
||||||
|
// Minimum swipe distance (in px)
|
||||||
|
const minSwipeDistance = 50;
|
||||||
|
|
||||||
|
const onTouchStart = (e) => {
|
||||||
|
setTouchEnd(null);
|
||||||
|
setTouchStart(e.targetTouches[0].clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e) => {
|
||||||
|
setTouchEnd(e.targetTouches[0].clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
if (!touchStart || !touchEnd) return;
|
||||||
|
|
||||||
|
const distance = touchStart - touchEnd;
|
||||||
|
const isLeftSwipe = distance > minSwipeDistance;
|
||||||
|
|
||||||
|
if (isLeftSwipe) {
|
||||||
|
setShowPinnedSidebar(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTouchStart(null);
|
||||||
|
setTouchEnd(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check authentication on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
// Check for reset token in URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const resetTokenParam = urlParams.get('reset_token');
|
||||||
|
if (resetTokenParam) {
|
||||||
|
setResetToken(resetTokenParam);
|
||||||
|
// Clean URL
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
setLoadingAuth(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const userData = await getMe(token);
|
||||||
|
setUser(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} catch (err) {
|
||||||
|
// Only remove token on authentication errors (401), not network errors
|
||||||
|
if (err.status === 401) {
|
||||||
|
console.log("Token invalid or expired, logging out");
|
||||||
|
removeToken();
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
} else {
|
||||||
|
// Network error or server error - keep user logged in
|
||||||
|
console.warn("Auth check failed but keeping session:", err.message);
|
||||||
|
setIsAuthenticated(true); // Assume still authenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoadingAuth(false);
|
||||||
|
};
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save currentView to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("currentView", currentView);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Unable to save view", err);
|
||||||
|
}
|
||||||
|
}, [currentView]);
|
||||||
|
|
||||||
|
// Load recipes for everyone (readonly for non-authenticated)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRecipes();
|
loadRecipes();
|
||||||
}, []);
|
}, []);
|
||||||
@ -96,8 +198,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by made_by
|
// Filter by made_by (username)
|
||||||
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
|
if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +236,8 @@ function App() {
|
|||||||
|
|
||||||
const handleCreateRecipe = async (payload) => {
|
const handleCreateRecipe = async (payload) => {
|
||||||
try {
|
try {
|
||||||
const created = await createRecipe(payload);
|
const token = getToken();
|
||||||
|
const created = await createRecipe(payload, token);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setEditingRecipe(null);
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
@ -153,7 +256,8 @@ function App() {
|
|||||||
|
|
||||||
const handleUpdateRecipe = async (payload) => {
|
const handleUpdateRecipe = async (payload) => {
|
||||||
try {
|
try {
|
||||||
await updateRecipe(editingRecipe.id, payload);
|
const token = getToken();
|
||||||
|
await updateRecipe(editingRecipe.id, payload, token);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
setEditingRecipe(null);
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
@ -177,7 +281,8 @@ function App() {
|
|||||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteRecipe(recipeId);
|
const token = getToken();
|
||||||
|
await deleteRecipe(recipeId, token);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
setSelectedRecipe(null);
|
setSelectedRecipe(null);
|
||||||
addToast("המתכון נמחק בהצלחה!", "success");
|
addToast("המתכון נמחק בהצלחה!", "success");
|
||||||
@ -208,31 +313,204 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLoginSuccess = async () => {
|
||||||
|
const token = getToken();
|
||||||
|
const userData = await getMe(token);
|
||||||
|
setUser(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
await loadRecipes();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setLogoutModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmLogout = () => {
|
||||||
|
removeToken();
|
||||||
|
setUser(null);
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setRecipes([]);
|
||||||
|
setSelectedRecipe(null);
|
||||||
|
setLogoutModal(false);
|
||||||
|
addToast('התנתקת בהצלחה', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state while checking auth
|
||||||
|
if (loadingAuth) {
|
||||||
|
return (
|
||||||
|
<div className="app-root">
|
||||||
|
<div style={{ textAlign: "center", padding: "3rem", color: "var(--text-muted)" }}>
|
||||||
|
טוען...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show main app (readonly if not authenticated)
|
||||||
return (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
|
||||||
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
|
||||||
|
{/* Pinned notes toggle button - only visible on recipes view for authenticated users */}
|
||||||
|
{isAuthenticated && currentView === "recipes" && (
|
||||||
|
<button
|
||||||
|
className="pinned-toggle-btn mobile-only"
|
||||||
|
onClick={() => setShowPinnedSidebar(!showPinnedSidebar)}
|
||||||
|
aria-label="הצג תזכירים"
|
||||||
|
title="תזכירים נעוצים"
|
||||||
|
>
|
||||||
|
<span className="note-icon-lines">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User greeting above TopBar */}
|
||||||
|
{isAuthenticated && user && (
|
||||||
|
<div className="user-greeting-header">
|
||||||
|
שלום, {user.display_name || user.username} 👋
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show login/register option in TopBar if not authenticated */}
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-left">
|
||||||
|
<span className="logo-emoji" role="img" aria-label="plate">🍽</span>
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-title">מה לבשל היום?</div>
|
||||||
|
<div className="brand-subtitle">מנהל המתכונים האישי שלך</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||||
|
<button className="btn ghost" onClick={() => setAuthView("login")}>
|
||||||
|
התחבר
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" onClick={() => setAuthView("register")}>
|
||||||
|
הירשם
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
) : (
|
||||||
|
<TopBar
|
||||||
|
onAddClick={() => setDrawerOpen(true)}
|
||||||
|
user={user}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
onShowToast={addToast}
|
||||||
|
onNotificationClick={(listId) => {
|
||||||
|
setCurrentView("grocery-lists");
|
||||||
|
setSelectedGroceryListId(listId);
|
||||||
|
}}
|
||||||
|
onAdminClick={() => setCurrentView("admin")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show auth modal if needed */}
|
||||||
|
{!isAuthenticated && authView !== null && !resetToken && (
|
||||||
|
<div className="drawer-backdrop" onClick={() => setAuthView(null)}>
|
||||||
|
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{authView === "login" ? (
|
||||||
|
<Login
|
||||||
|
onSuccess={handleLoginSuccess}
|
||||||
|
onSwitchToRegister={() => setAuthView("register")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Register
|
||||||
|
onSuccess={handleLoginSuccess}
|
||||||
|
onSwitchToLogin={() => setAuthView("login")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show reset password if token present */}
|
||||||
|
{!isAuthenticated && resetToken && (
|
||||||
|
<div className="drawer-backdrop">
|
||||||
|
<div className="auth-modal">
|
||||||
|
<ResetPassword
|
||||||
|
token={resetToken}
|
||||||
|
onSuccess={() => {
|
||||||
|
setResetToken(null);
|
||||||
|
setAuthView("login");
|
||||||
|
addToast("הסיסמה עודכנה בהצלחה! כעת תוכל להתחבר עם הסיסמה החדשה", "success");
|
||||||
|
}}
|
||||||
|
onBack={() => {
|
||||||
|
setResetToken(null);
|
||||||
|
setAuthView("login");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<nav className="main-navigation">
|
||||||
|
<button
|
||||||
|
className={`nav-tab ${currentView === "recipes" ? "active" : ""}`}
|
||||||
|
onClick={() => setCurrentView("recipes")}
|
||||||
|
>
|
||||||
|
📖 מתכונים
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`nav-tab ${currentView === "grocery-lists" ? "active" : ""}`}
|
||||||
|
onClick={() => setCurrentView("grocery-lists")}
|
||||||
|
>
|
||||||
|
🛒 רשימות קניות
|
||||||
|
</button>
|
||||||
|
{user?.is_admin && (
|
||||||
|
<button
|
||||||
|
className={`nav-tab ${currentView === "admin" ? "active" : ""}`}
|
||||||
|
onClick={() => setCurrentView("admin")}
|
||||||
|
>
|
||||||
|
🛡️ ניהול
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="layout">
|
<main className="layout">
|
||||||
<section className="sidebar">
|
{currentView === "admin" ? (
|
||||||
<RecipeSearchList
|
<div className="admin-view">
|
||||||
allRecipes={recipes}
|
<AdminPanel onShowToast={addToast} />
|
||||||
recipes={getFilteredRecipes()}
|
</div>
|
||||||
selectedId={selectedRecipe?.id}
|
) : currentView === "grocery-lists" ? (
|
||||||
onSelect={setSelectedRecipe}
|
<GroceryLists
|
||||||
searchQuery={searchQuery}
|
user={user}
|
||||||
onSearchChange={setSearchQuery}
|
onShowToast={addToast}
|
||||||
filterMealType={filterMealType}
|
selectedListIdFromNotification={selectedGroceryListId}
|
||||||
onMealTypeChange={setFilterMealType}
|
onListSelected={() => setSelectedGroceryListId(null)}
|
||||||
filterMaxTime={filterMaxTime}
|
|
||||||
onMaxTimeChange={setFilterMaxTime}
|
|
||||||
filterTags={filterTags}
|
|
||||||
onTagsChange={setFilterTags}
|
|
||||||
filterMadeBy={filterMadeBy}
|
|
||||||
onMadeByChange={setFilterMadeBy}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
) : (
|
||||||
|
<>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<>
|
||||||
|
<aside
|
||||||
|
className={`pinned-lists-sidebar ${showPinnedSidebar ? 'mobile-visible' : ''}`}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="close-sidebar-btn mobile-only"
|
||||||
|
onClick={() => setShowPinnedSidebar(false)}
|
||||||
|
aria-label="סגור תזכירים"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<PinnedGroceryLists onShowToast={addToast} />
|
||||||
|
</aside>
|
||||||
|
{showPinnedSidebar && (
|
||||||
|
<div
|
||||||
|
className="sidebar-backdrop mobile-only"
|
||||||
|
onClick={() => setShowPinnedSidebar(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<section className="content-wrapper">
|
||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
@ -250,7 +528,7 @@ function App() {
|
|||||||
<option value="breakfast">בוקר</option>
|
<option value="breakfast">בוקר</option>
|
||||||
<option value="lunch">צהריים</option>
|
<option value="lunch">צהריים</option>
|
||||||
<option value="dinner">ערב</option>
|
<option value="dinner">ערב</option>
|
||||||
<option value="snack">נשנוש</option>
|
<option value="snack">קינוחים</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -289,10 +567,35 @@ function App() {
|
|||||||
recipe={selectedRecipe}
|
recipe={selectedRecipe}
|
||||||
onEditClick={handleEditRecipe}
|
onEditClick={handleEditRecipe}
|
||||||
onShowDeleteModal={handleShowDeleteModal}
|
onShowDeleteModal={handleShowDeleteModal}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
currentUser={user}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="sidebar">
|
||||||
|
<RecipeSearchList
|
||||||
|
allRecipes={recipes}
|
||||||
|
recipes={getFilteredRecipes()}
|
||||||
|
selectedId={selectedRecipe?.id}
|
||||||
|
onSelect={setSelectedRecipe}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
filterMealType={filterMealType}
|
||||||
|
onMealTypeChange={setFilterMealType}
|
||||||
|
filterMaxTime={filterMaxTime}
|
||||||
|
onMaxTimeChange={setFilterMaxTime}
|
||||||
|
filterTags={filterTags}
|
||||||
|
onTagsChange={setFilterTags}
|
||||||
|
filterOwner={filterOwner}
|
||||||
|
onOwnerChange={setFilterOwner}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
<RecipeFormDrawer
|
<RecipeFormDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@ -301,7 +604,10 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
editingRecipe={editingRecipe}
|
editingRecipe={editingRecipe}
|
||||||
|
currentUser={user}
|
||||||
|
allRecipes={recipes}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={deleteModal.isOpen}
|
isOpen={deleteModal.isOpen}
|
||||||
@ -314,6 +620,17 @@ function App() {
|
|||||||
onCancel={handleCancelDelete}
|
onCancel={handleCancelDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={logoutModal}
|
||||||
|
title="התנתקות"
|
||||||
|
message="האם אתה בטוח שברצונך להתנתק?"
|
||||||
|
confirmText="התנתק"
|
||||||
|
cancelText="ביטול"
|
||||||
|
isDangerous={false}
|
||||||
|
onConfirm={confirmLogout}
|
||||||
|
onCancel={() => setLogoutModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// Get API base from injected env.js or fallback to /api relative path
|
// Get API base from injected env.js or fallback to /api relative path
|
||||||
const getApiBase = () => {
|
export const getApiBase = () => {
|
||||||
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||||
return window.__ENV__.API_BASE;
|
return window.__ENV__.API_BASE;
|
||||||
}
|
}
|
||||||
@ -37,10 +37,14 @@ export async function getRandomRecipe(filters) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRecipe(recipe) {
|
export async function createRecipe(recipe, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes`, {
|
const res = await fetch(`${API_BASE}/recipes`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(recipe),
|
body: JSON.stringify(recipe),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -49,10 +53,14 @@ export async function createRecipe(recipe) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRecipe(id, payload) {
|
export async function updateRecipe(id, payload, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -61,9 +69,14 @@ export async function updateRecipe(id, payload) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRecipe(id) {
|
export async function deleteRecipe(id, token) {
|
||||||
|
const headers = {};
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
if (!res.ok && res.status !== 204) {
|
if (!res.ok && res.status !== 204) {
|
||||||
throw new Error("Failed to delete recipe");
|
throw new Error("Failed to delete recipe");
|
||||||
|
|||||||
BIN
frontend/src/assets/my-recipes-logo-dark.png
Normal file
BIN
frontend/src/assets/my-recipes-logo-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
BIN
frontend/src/assets/my-recipes-logo-light.png
Normal file
BIN
frontend/src/assets/my-recipes-logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
BIN
frontend/src/assets/placeholder-dark.png
Normal file
BIN
frontend/src/assets/placeholder-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/src/assets/placeholder-light.png
Normal file
BIN
frontend/src/assets/placeholder-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
103
frontend/src/authApi.js
Normal file
103
frontend/src/authApi.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
// Get API base from injected env.js or fallback to /api relative path
|
||||||
|
const getApiBase = () => {
|
||||||
|
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||||
|
return window.__ENV__.API_BASE;
|
||||||
|
}
|
||||||
|
return "/api";
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE = getApiBase();
|
||||||
|
|
||||||
|
export async function register(username, email, password, firstName, lastName, displayName) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
display_name: displayName
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to register");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username, password) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to login");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(token) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = new Error("Failed to get user info");
|
||||||
|
error.status = res.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestPasswordChangeCode(token) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/request-password-change-code`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to send verification code");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(verificationCode, currentPassword, newPassword, token) {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/change-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
verification_code: verificationCode,
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to change password");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth helpers
|
||||||
|
export function saveToken(token) {
|
||||||
|
localStorage.setItem("auth_token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem("auth_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToken() {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
}
|
||||||
66
frontend/src/backupApi.js
Normal file
66
frontend/src/backupApi.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { getToken } from './authApi';
|
||||||
|
|
||||||
|
const API_BASE_URL = window.ENV?.API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a manual database backup (admin only)
|
||||||
|
*/
|
||||||
|
export async function triggerBackup() {
|
||||||
|
const token = getToken();
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/backup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to create backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available backups (admin only)
|
||||||
|
*/
|
||||||
|
export async function listBackups() {
|
||||||
|
const token = getToken();
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/backups`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to list backups');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore database from a backup (admin only)
|
||||||
|
*/
|
||||||
|
export async function restoreBackup(filename) {
|
||||||
|
const token = getToken();
|
||||||
|
const response = await fetch(`${API_BASE_URL}/admin/restore?filename=${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to restore backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
177
frontend/src/components/AdminPanel.jsx
Normal file
177
frontend/src/components/AdminPanel.jsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { triggerBackup, listBackups, restoreBackup } from '../backupApi';
|
||||||
|
import Modal from './Modal';
|
||||||
|
|
||||||
|
function AdminPanel({ onShowToast }) {
|
||||||
|
const [backups, setBackups] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [restoreModal, setRestoreModal] = useState({ isOpen: false, filename: '' });
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBackups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadBackups = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await listBackups();
|
||||||
|
setBackups(data.backups || []);
|
||||||
|
} catch (error) {
|
||||||
|
onShowToast(error.message || 'שגיאה בטעינת גיבויים', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateBackup = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await triggerBackup();
|
||||||
|
onShowToast('גיבוי נוצר בהצלחה! 📦', 'success');
|
||||||
|
loadBackups(); // Refresh list
|
||||||
|
} catch (error) {
|
||||||
|
onShowToast(error.message || 'שגיאה ביצירת גיבוי', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreClick = (filename) => {
|
||||||
|
setRestoreModal({ isOpen: true, filename });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreConfirm = async () => {
|
||||||
|
console.log('Restore confirm clicked, filename:', restoreModal.filename);
|
||||||
|
setRestoreModal({ isOpen: false, filename: '' });
|
||||||
|
setRestoring(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Starting restore...');
|
||||||
|
const result = await restoreBackup(restoreModal.filename);
|
||||||
|
console.log('Restore result:', result);
|
||||||
|
onShowToast('שחזור הושלם בהצלחה! ♻️ מרענן את הדף...', 'success');
|
||||||
|
|
||||||
|
// Refresh page after 2 seconds to reload all data
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Restore error:', error);
|
||||||
|
onShowToast(error.message || 'שגיאה בשחזור גיבוי', 'error');
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes) => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (isoString) => {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleString('he-IL', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="admin-header">
|
||||||
|
<h2>ניהול גיבויים 🛡️</h2>
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleCreateBackup}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'יוצר גיבוי...' : 'צור גיבוי חדש'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && backups.length === 0 ? (
|
||||||
|
<div className="loading">טוען גיבויים...</div>
|
||||||
|
) : backups.length === 0 ? (
|
||||||
|
<div className="empty-state">אין גיבויים זמינים</div>
|
||||||
|
) : (
|
||||||
|
<div className="backups-list">
|
||||||
|
<table className="backups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="col-filename">קובץ</th>
|
||||||
|
<th className="col-date">תאריך</th>
|
||||||
|
<th className="col-size">גודל</th>
|
||||||
|
<th className="col-actions">פעולות</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{backups.map((backup) => (
|
||||||
|
<tr key={backup.filename}>
|
||||||
|
<td className="filename">{backup.filename}</td>
|
||||||
|
<td className="date">{formatDate(backup.last_modified)}</td>
|
||||||
|
<td className="size">{formatBytes(backup.size)}</td>
|
||||||
|
<td className="actions">
|
||||||
|
<button
|
||||||
|
className="btn ghost small"
|
||||||
|
onClick={() => handleRestoreClick(backup.filename)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
שחזר
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={restoreModal.isOpen || restoring}
|
||||||
|
onClose={() => !restoring && setRestoreModal({ isOpen: false, filename: '' })}
|
||||||
|
title={restoring ? "⏳ משחזר גיבוי..." : "⚠️ אישור שחזור גיבוי"}
|
||||||
|
>
|
||||||
|
{restoring ? (
|
||||||
|
<div className="restore-progress">
|
||||||
|
<div className="progress-bar-container">
|
||||||
|
<div className="progress-bar-fill"></div>
|
||||||
|
</div>
|
||||||
|
<p className="progress-text">מוריד גיבוי...</p>
|
||||||
|
<p className="progress-text">משחזר מסד נתונים...</p>
|
||||||
|
<p className="progress-text-muted">אנא המתן, התהליך עשוי לקחת מספר דקות</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="restore-warning">
|
||||||
|
<p>פעולה זו תמחק את כל הנתונים הנוכחיים!</p>
|
||||||
|
<p>האם אתה בטוח שברצונך לשחזר מהגיבוי:</p>
|
||||||
|
<p className="filename-highlight">{restoreModal.filename}</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={() => setRestoreModal({ isOpen: false, filename: '' })}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn danger"
|
||||||
|
onClick={handleRestoreConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
שחזר
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminPanel;
|
||||||
49
frontend/src/components/AutocompleteInput.jsx
Normal file
49
frontend/src/components/AutocompleteInput.jsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
function AutocompleteInput({ value, onChange, onKeyDown, suggestions = [], placeholder, inputRef, ...props }) {
|
||||||
|
const [suggestion, setSuggestion] = useState("");
|
||||||
|
const localRef = useRef(null);
|
||||||
|
const ref = inputRef || localRef;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value && suggestions.length > 0) {
|
||||||
|
const match = suggestions.find(s =>
|
||||||
|
s.toLowerCase().startsWith(value.toLowerCase()) && s.toLowerCase() !== value.toLowerCase()
|
||||||
|
);
|
||||||
|
setSuggestion(match || "");
|
||||||
|
} else {
|
||||||
|
setSuggestion("");
|
||||||
|
}
|
||||||
|
}, [value, suggestions]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Tab' && suggestion) {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange({ target: { value: suggestion } });
|
||||||
|
setSuggestion("");
|
||||||
|
} else if (onKeyDown) {
|
||||||
|
onKeyDown(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="autocomplete-wrapper">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{suggestion && (
|
||||||
|
<div className="autocomplete-suggestion">
|
||||||
|
<span className="autocomplete-typed">{value}</span>
|
||||||
|
<span className="autocomplete-rest">{suggestion.slice(value.length)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AutocompleteInput;
|
||||||
176
frontend/src/components/ChangePassword.jsx
Normal file
176
frontend/src/components/ChangePassword.jsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { changePassword, requestPasswordChangeCode } from "../authApi";
|
||||||
|
|
||||||
|
export default function ChangePassword({ token, onClose, onSuccess }) {
|
||||||
|
const [step, setStep] = useState(1); // 1: request code, 2: enter code & passwords
|
||||||
|
const [verificationCode, setVerificationCode] = useState("");
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [codeSent, setCodeSent] = useState(false);
|
||||||
|
|
||||||
|
const handleRequestCode = async () => {
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestPasswordChangeCode(token);
|
||||||
|
setCodeSent(true);
|
||||||
|
setStep(2);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!verificationCode || !currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
setError("נא למלא את כל השדות");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationCode.length !== 6) {
|
||||||
|
setError("קוד האימות חייב להכיל 6 ספרות");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("הסיסמאות החדשות אינן תואמות");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changePassword(verificationCode, currentPassword, newPassword, token);
|
||||||
|
onSuccess?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>שינוי סיסמה</h2>
|
||||||
|
<button className="close-btn" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div>
|
||||||
|
<p style={{ marginBottom: "1rem", color: "var(--text-muted)" }}>
|
||||||
|
קוד אימות יישלח לכתובת המייל שלך. הקוד תקף ל-10 דקות.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary full"
|
||||||
|
onClick={handleRequestCode}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "שולח..." : "שלח קוד אימות"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{codeSent && (
|
||||||
|
<div style={{
|
||||||
|
padding: "0.75rem",
|
||||||
|
background: "rgba(34, 197, 94, 0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
color: "var(--accent)"
|
||||||
|
}}>
|
||||||
|
✓ קוד אימות נשלח לכתובת המייל שלך
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>קוד אימות (6 ספרות)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
placeholder="123456"
|
||||||
|
maxLength={6}
|
||||||
|
style={{ fontSize: "1.2rem", letterSpacing: "0.3rem", textAlign: "center" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה נוכחית</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה חדשה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>אימות סיסמה חדשה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
ביטול
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "משנה..." : "שמור סיסמה"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/src/components/ForgotPassword.jsx
Normal file
100
frontend/src/components/ForgotPassword.jsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { getApiBase } from "../api";
|
||||||
|
|
||||||
|
function ForgotPassword({ onBack }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiBase()}/forgot-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessage(data.message);
|
||||||
|
setEmail("");
|
||||||
|
} else {
|
||||||
|
setError(data.detail || "שגיאה בשליחת הבקשה");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("שגיאה בשליחת הבקשה");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-title">שכחת סיסמה?</h1>
|
||||||
|
<p className="auth-subtitle">
|
||||||
|
הזן את כתובת המייל שלך ונשלח לך קישור לאיפוס הסיסמה
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
background: "var(--success-bg, #dcfce7)",
|
||||||
|
border: "1px solid var(--success-border, #22c55e)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--success-text, #166534)",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>כתובת מייל</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן כתובת מייל"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn primary full-width"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "שולח..." : "שלח קישור לאיפוס"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-footer">
|
||||||
|
<p>
|
||||||
|
נזכרת בסיסמה?{" "}
|
||||||
|
<button className="link-btn" onClick={onBack}>
|
||||||
|
חזור להתחברות
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ForgotPassword;
|
||||||
1038
frontend/src/components/GroceryLists.jsx
Normal file
1038
frontend/src/components/GroceryLists.jsx
Normal file
File diff suppressed because it is too large
Load Diff
181
frontend/src/components/Login.jsx
Normal file
181
frontend/src/components/Login.jsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { login, saveToken } from "../authApi";
|
||||||
|
import ForgotPassword from "./ForgotPassword";
|
||||||
|
|
||||||
|
function Login({ onSuccess, onSwitchToRegister }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||||
|
|
||||||
|
// Check for token in URL (from Google OAuth redirect)
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const token = urlParams.get('token');
|
||||||
|
if (token) {
|
||||||
|
saveToken(token);
|
||||||
|
// Clean URL
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
}, [onSuccess]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await login(username, password);
|
||||||
|
saveToken(data.access_token);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
const apiBase = window.__ENV__?.API_BASE || "http://localhost:8000";
|
||||||
|
window.location.href = `${apiBase}/auth/google/login`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAzureLogin = () => {
|
||||||
|
const apiBase = window.__ENV__?.API_BASE || "http://localhost:8000";
|
||||||
|
window.location.href = `${apiBase}/auth/azure/login`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showForgotPassword) {
|
||||||
|
return <ForgotPassword onBack={() => setShowForgotPassword(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-title">התחברות</h1>
|
||||||
|
<p className="auth-subtitle">ברוכים השבים למתכונים שלכם</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם משתמש</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן שם משתמש"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן סיסמה"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||||
|
{loading ? "מתחבר..." : "התחבר"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ textAlign: "center", marginTop: "0.75rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn"
|
||||||
|
style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}
|
||||||
|
onClick={() => setShowForgotPassword(true)}
|
||||||
|
>
|
||||||
|
שכחת סיסמה?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
margin: "1rem 0",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--text-muted)",
|
||||||
|
position: "relative"
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderTop: "1px solid var(--border-subtle)",
|
||||||
|
zIndex: 0
|
||||||
|
}}></div>
|
||||||
|
<span style={{
|
||||||
|
background: "var(--card)",
|
||||||
|
padding: "0 1rem",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1
|
||||||
|
}}>או</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
className="btn ghost full-width"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
border: "1px solid var(--border-subtle)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18">
|
||||||
|
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/>
|
||||||
|
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/>
|
||||||
|
<path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707 0-.593.102-1.17.282-1.709V4.958H.957C.347 6.173 0 7.548 0 9c0 1.452.348 2.827.957 4.042l3.007-2.335z"/>
|
||||||
|
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
|
||||||
|
</svg>
|
||||||
|
המשך עם Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* <button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAzureLogin}
|
||||||
|
className="btn ghost full-width"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
border: "1px solid var(--border-subtle)",
|
||||||
|
marginTop: "0.5rem"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 23 23">
|
||||||
|
<path fill="#f25022" d="M1 1h10v10H1z"/>
|
||||||
|
<path fill="#00a4ef" d="M12 1h10v10H12z"/>
|
||||||
|
<path fill="#7fba00" d="M1 12h10v10H1z"/>
|
||||||
|
<path fill="#ffb900" d="M12 12h10v10H12z"/>
|
||||||
|
</svg>
|
||||||
|
המשך עם Microsoft
|
||||||
|
</button> */}
|
||||||
|
|
||||||
|
<div className="auth-footer">
|
||||||
|
<p>
|
||||||
|
עדיין אין לך חשבון?{" "}
|
||||||
|
<button className="link-btn" onClick={onSwitchToRegister}>
|
||||||
|
הירשם עכשיו
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מחק", cancelText = "ביטול", isDangerous = false }) {
|
function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מחק", cancelText = "ביטול", isDangerous = false, children }) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -8,8 +8,9 @@ function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מ
|
|||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
</header>
|
</header>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{message}
|
{children || message}
|
||||||
</div>
|
</div>
|
||||||
|
{!children && (
|
||||||
<footer className="modal-footer">
|
<footer className="modal-footer">
|
||||||
<button className="btn ghost" onClick={onCancel}>
|
<button className="btn ghost" onClick={onCancel}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
@ -21,6 +22,7 @@ function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מ
|
|||||||
{confirmText}
|
{confirmText}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
229
frontend/src/components/NotificationBell.css
Normal file
229
frontend/src/components/NotificationBell.css
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
.notification-bell-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell-btn {
|
||||||
|
position: relative;
|
||||||
|
background: var(--card-soft);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell-btn:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
right: 0;
|
||||||
|
width: 420px;
|
||||||
|
max-height: 550px;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--card-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list::-webkit-scrollbar-track {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.clickable:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread::before {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.unread:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.read .notification-message {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small.delete {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-small.delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
202
frontend/src/components/NotificationBell.jsx
Normal file
202
frontend/src/components/NotificationBell.jsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
getNotifications,
|
||||||
|
markNotificationAsRead,
|
||||||
|
markAllNotificationsAsRead,
|
||||||
|
deleteNotification,
|
||||||
|
} from "../notificationApi";
|
||||||
|
import "./NotificationBell.css";
|
||||||
|
|
||||||
|
function NotificationBell({ onShowToast, onNotificationClick }) {
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotifications();
|
||||||
|
// Poll for new notifications every 30 seconds
|
||||||
|
const interval = setInterval(loadNotifications, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDropdown) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showDropdown]);
|
||||||
|
|
||||||
|
const loadNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getNotifications();
|
||||||
|
setNotifications(data);
|
||||||
|
setUnreadCount(data.filter((n) => !n.is_read).length);
|
||||||
|
} catch (error) {
|
||||||
|
// If unauthorized (401), user is not logged in - don't show errors
|
||||||
|
if (error.message.includes("401") || error.message.includes("Unauthorized") || error.message.includes("User not found")) {
|
||||||
|
setNotifications([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Silent fail for other polling errors
|
||||||
|
console.error("Failed to load notifications", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAsRead = async (notificationId) => {
|
||||||
|
try {
|
||||||
|
await markNotificationAsRead(notificationId);
|
||||||
|
setNotifications(
|
||||||
|
notifications.map((n) =>
|
||||||
|
n.id === notificationId ? { ...n, is_read: true } : n
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setUnreadCount(Math.max(0, unreadCount - 1));
|
||||||
|
} catch (error) {
|
||||||
|
onShowToast?.(error.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationClick = async (notification) => {
|
||||||
|
// Mark as read
|
||||||
|
if (!notification.is_read) {
|
||||||
|
await handleMarkAsRead(notification.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle grocery share notifications
|
||||||
|
if (notification.type === "grocery_share" && notification.related_id) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
onNotificationClick?.(notification.related_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = async () => {
|
||||||
|
try {
|
||||||
|
await markAllNotificationsAsRead();
|
||||||
|
setNotifications(notifications.map((n) => ({ ...n, is_read: true })));
|
||||||
|
setUnreadCount(0);
|
||||||
|
onShowToast?.("כל ההתראות סומנו כנקראו", "success");
|
||||||
|
} catch (error) {
|
||||||
|
onShowToast?.(error.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (notificationId) => {
|
||||||
|
try {
|
||||||
|
await deleteNotification(notificationId);
|
||||||
|
const notification = notifications.find((n) => n.id === notificationId);
|
||||||
|
setNotifications(notifications.filter((n) => n.id !== notificationId));
|
||||||
|
if (notification && !notification.is_read) {
|
||||||
|
setUnreadCount(Math.max(0, unreadCount - 1));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onShowToast?.(error.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return "עכשיו";
|
||||||
|
if (minutes < 60) return `לפני ${minutes} דקות`;
|
||||||
|
if (hours < 24) return `לפני ${hours} שעות`;
|
||||||
|
return `לפני ${days} ימים`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notification-bell-container" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
className="notification-bell-btn"
|
||||||
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
|
title="התראות"
|
||||||
|
>
|
||||||
|
🔔
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="notification-badge">{unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div className="notification-dropdown">
|
||||||
|
<div className="notification-header">
|
||||||
|
<h3>התראות</h3>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={handleMarkAllAsRead}
|
||||||
|
>
|
||||||
|
סמן הכל כנקרא
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="notification-list">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="notification-empty">אין התראות חדשות</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className={`notification-item ${
|
||||||
|
notification.is_read ? "read" : "unread"
|
||||||
|
} ${notification.type === "grocery_share" ? "clickable" : ""}`}
|
||||||
|
onClick={() => handleNotificationClick(notification)}
|
||||||
|
>
|
||||||
|
<div className="notification-content">
|
||||||
|
<p className="notification-message">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
<span className="notification-time">
|
||||||
|
{formatTime(notification.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="notification-actions">
|
||||||
|
{!notification.is_read && (
|
||||||
|
<button
|
||||||
|
className="btn-icon-small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMarkAsRead(notification.id);
|
||||||
|
}}
|
||||||
|
title="סמן כנקרא"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-icon-small delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(notification.id);
|
||||||
|
}}
|
||||||
|
title="מחק"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotificationBell;
|
||||||
222
frontend/src/components/PinnedGroceryLists.jsx
Normal file
222
frontend/src/components/PinnedGroceryLists.jsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getGroceryLists, updateGroceryList } from "../groceryApi";
|
||||||
|
|
||||||
|
function PinnedGroceryLists({ onShowToast }) {
|
||||||
|
const [pinnedLists, setPinnedLists] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPinnedLists();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPinnedLists = async () => {
|
||||||
|
try {
|
||||||
|
const allLists = await getGroceryLists();
|
||||||
|
const pinned = allLists.filter((list) => list.is_pinned);
|
||||||
|
setPinnedLists(pinned);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load pinned lists", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleItem = async (listId, itemIndex) => {
|
||||||
|
const list = pinnedLists.find((l) => l.id === listId);
|
||||||
|
if (!list || !list.can_edit) return;
|
||||||
|
|
||||||
|
const updatedItems = [...list.items];
|
||||||
|
const item = updatedItems[itemIndex];
|
||||||
|
|
||||||
|
if (item.startsWith("✓ ")) {
|
||||||
|
updatedItems[itemIndex] = item.substring(2);
|
||||||
|
} else {
|
||||||
|
updatedItems[itemIndex] = "✓ " + item;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateGroceryList(listId, { items: updatedItems });
|
||||||
|
setPinnedLists(
|
||||||
|
pinnedLists.map((l) =>
|
||||||
|
l.id === listId ? { ...l, items: updatedItems } : l
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
onShowToast?.(error.message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pinnedLists.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pinned-grocery-lists">
|
||||||
|
{pinnedLists.map((list) => (
|
||||||
|
<div key={list.id} className="pinned-note">
|
||||||
|
<div className="pin-icon">📌</div>
|
||||||
|
<h3 className="note-title">{list.name}</h3>
|
||||||
|
<ul className="note-items">
|
||||||
|
{list.items.length === 0 ? (
|
||||||
|
<li className="empty-note">הרשימה ריקה</li>
|
||||||
|
) : (
|
||||||
|
list.items.map((item, index) => {
|
||||||
|
const isChecked = item.startsWith("✓ ");
|
||||||
|
const itemText = isChecked ? item.substring(2) : item;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className={`note-item ${isChecked ? "checked" : ""}`}
|
||||||
|
onClick={() =>
|
||||||
|
list.can_edit && handleToggleItem(list.id, index)
|
||||||
|
}
|
||||||
|
style={{ cursor: list.can_edit ? "pointer" : "default" }}
|
||||||
|
>
|
||||||
|
<span className="checkbox">{isChecked ? "☑" : "☐"}</span>
|
||||||
|
<span className="item-text">{itemText}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap');
|
||||||
|
|
||||||
|
.pinned-grocery-lists {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-note {
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(135deg, #fff9e6 0%, #fffaed 100%);
|
||||||
|
padding: 2rem 1.5rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
border: 1px solid #f5e6c8;
|
||||||
|
transform: rotate(-1deg);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-family: 'Caveat', cursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-note:nth-child(even) {
|
||||||
|
transform: rotate(1deg);
|
||||||
|
background: linear-gradient(135deg, #fff5e1 0%, #fff9eb 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-note:hover {
|
||||||
|
transform: rotate(0deg) scale(1.02);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.15),
|
||||||
|
0 8px 20px rgba(0, 0, 0, 0.1),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 2rem;
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2));
|
||||||
|
transform: rotate(25deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: #5a4a2a;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid rgba(90, 74, 42, 0.2);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-items {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-note {
|
||||||
|
text-align: center;
|
||||||
|
color: #9a8a6a;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
padding: 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: #4a3a1a;
|
||||||
|
line-height: 1.6;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:hover {
|
||||||
|
color: #2a1a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.checked .item-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-text {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper texture overlay */
|
||||||
|
.pinned-note::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 31px,
|
||||||
|
rgba(90, 74, 42, 0.03) 31px,
|
||||||
|
rgba(90, 74, 42, 0.03) 32px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode specific adjustments */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.pinned-note {
|
||||||
|
background: linear-gradient(135deg, #fffbf0 0%, #fffef8 100%);
|
||||||
|
border-color: #f0e0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-note:nth-child(even) {
|
||||||
|
background: linear-gradient(135deg, #fff8e8 0%, #fffcf3 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PinnedGroceryLists;
|
||||||
@ -1,6 +1,19 @@
|
|||||||
import placeholderImage from "../assets/placeholder.svg";
|
import { useEffect, useState } from "react";
|
||||||
|
import placeholderLight from "../assets/placeholder-light.png";
|
||||||
|
import placeholderDark from "../assets/placeholder-dark.png";
|
||||||
|
|
||||||
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
|
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser }) {
|
||||||
|
const [theme, setTheme] = useState(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setTheme(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const placeholderImage = theme === 'dark' ? placeholderDark : placeholderLight;
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return (
|
return (
|
||||||
<section className="panel placeholder">
|
<section className="panel placeholder">
|
||||||
@ -13,6 +26,15 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
onShowDeleteModal(recipe.id, recipe.name);
|
onShowDeleteModal(recipe.id, recipe.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Debug ownership check
|
||||||
|
console.log('Recipe ownership check:', {
|
||||||
|
recipeUserId: recipe.user_id,
|
||||||
|
recipeUserIdType: typeof recipe.user_id,
|
||||||
|
currentUserId: currentUser?.id,
|
||||||
|
currentUserIdType: typeof currentUser?.id,
|
||||||
|
isEqual: recipe.user_id === currentUser?.id
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel recipe-card">
|
<section className="panel recipe-card">
|
||||||
{/* Recipe Image */}
|
{/* Recipe Image */}
|
||||||
@ -26,8 +48,8 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
<p className="recipe-subtitle">
|
<p className="recipe-subtitle">
|
||||||
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
||||||
</p>
|
</p>
|
||||||
{recipe.made_by && (
|
{(recipe.made_by || recipe.owner_display_name) && (
|
||||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
|
<h4 className="recipe-made-by">המתכון של: {recipe.made_by || recipe.owner_display_name}</h4>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pill-row">
|
<div className="pill-row">
|
||||||
@ -66,6 +88,7 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAuthenticated && currentUser && (Number(recipe.user_id) === Number(currentUser.id) || currentUser.is_admin) && (
|
||||||
<div className="recipe-actions">
|
<div className="recipe-actions">
|
||||||
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||||
✏️ ערוך
|
✏️ ערוך
|
||||||
@ -74,6 +97,7 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
|||||||
🗑 מחק
|
🗑 מחק
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -87,7 +111,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,7 +148,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import AutocompleteInput from "./AutocompleteInput";
|
||||||
|
|
||||||
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, currentUser = null, allRecipes = [] }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [mealType, setMealType] = useState("lunch");
|
const [mealType, setMealType] = useState("lunch");
|
||||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||||
@ -11,6 +12,19 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
const [ingredients, setIngredients] = useState([""]);
|
const [ingredients, setIngredients] = useState([""]);
|
||||||
const [steps, setSteps] = useState([""]);
|
const [steps, setSteps] = useState([""]);
|
||||||
|
|
||||||
|
// Extract unique made_by values for autocomplete
|
||||||
|
const uniqueMadeBy = Array.from(
|
||||||
|
new Set(allRecipes.map(r => r.made_by).filter(Boolean))
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
// Extract unique ingredients for autocomplete
|
||||||
|
const uniqueIngredients = Array.from(
|
||||||
|
new Set(allRecipes.flatMap(r => r.ingredients || []).filter(Boolean))
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
const lastIngredientRef = useRef(null);
|
||||||
|
const lastStepRef = useRef(null);
|
||||||
|
|
||||||
const isEditMode = !!editingRecipe;
|
const isEditMode = !!editingRecipe;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -20,7 +34,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
setMealType(editingRecipe.meal_type || "lunch");
|
setMealType(editingRecipe.meal_type || "lunch");
|
||||||
setTimeMinutes(editingRecipe.time_minutes || 15);
|
setTimeMinutes(editingRecipe.time_minutes || 15);
|
||||||
setMadeBy(editingRecipe.made_by || "");
|
setMadeBy(editingRecipe.made_by || "");
|
||||||
setTags((editingRecipe.tags || []).join(", "));
|
setTags((editingRecipe.tags || []).join(" "));
|
||||||
setImage(editingRecipe.image || "");
|
setImage(editingRecipe.image || "");
|
||||||
setIngredients(editingRecipe.ingredients || [""]);
|
setIngredients(editingRecipe.ingredients || [""]);
|
||||||
setSteps(editingRecipe.steps || [""]);
|
setSteps(editingRecipe.steps || [""]);
|
||||||
@ -28,19 +42,23 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
setName("");
|
setName("");
|
||||||
setMealType("lunch");
|
setMealType("lunch");
|
||||||
setTimeMinutes(15);
|
setTimeMinutes(15);
|
||||||
setMadeBy("");
|
setMadeBy(currentUser?.username || "");
|
||||||
setTags("");
|
setTags("");
|
||||||
setImage("");
|
setImage("");
|
||||||
setIngredients([""]);
|
setIngredients([""]);
|
||||||
setSteps([""]);
|
setSteps([""]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, editingRecipe, isEditMode]);
|
}, [open, editingRecipe, isEditMode, currentUser]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleAddIngredient = () => {
|
const handleAddIngredient = () => {
|
||||||
setIngredients((prev) => [...prev, ""]);
|
setIngredients((prev) => [...prev, ""]);
|
||||||
|
setTimeout(() => {
|
||||||
|
lastIngredientRef.current?.focus();
|
||||||
|
lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeIngredient = (idx, value) => {
|
const handleChangeIngredient = (idx, value) => {
|
||||||
@ -53,6 +71,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
|
|
||||||
const handleAddStep = () => {
|
const handleAddStep = () => {
|
||||||
setSteps((prev) => [...prev, ""]);
|
setSteps((prev) => [...prev, ""]);
|
||||||
|
setTimeout(() => {
|
||||||
|
lastStepRef.current?.focus();
|
||||||
|
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeStep = (idx, value) => {
|
const handleChangeStep = (idx, value) => {
|
||||||
@ -84,7 +106,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
|
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
|
||||||
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
||||||
const tagsArr = tags
|
const tagsArr = tags
|
||||||
.split(",")
|
.split(" ")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@ -95,12 +117,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
tags: tagsArr,
|
tags: tagsArr,
|
||||||
ingredients: cleanIngredients,
|
ingredients: cleanIngredients,
|
||||||
steps: cleanSteps,
|
steps: cleanSteps,
|
||||||
|
made_by: madeBy.trim() || currentUser?.username || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (madeBy.trim()) {
|
|
||||||
payload.made_by = madeBy.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
payload.image = image;
|
payload.image = image;
|
||||||
}
|
}
|
||||||
@ -136,7 +155,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
<option value="breakfast">בוקר</option>
|
<option value="breakfast">בוקר</option>
|
||||||
<option value="lunch">צהריים</option>
|
<option value="lunch">צהריים</option>
|
||||||
<option value="dinner">ערב</option>
|
<option value="dinner">ערב</option>
|
||||||
<option value="snack">נשנוש</option>
|
<option value="snack">קינוחים</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -154,9 +173,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>המתכון של:</label>
|
<label>המתכון של:</label>
|
||||||
<input
|
<AutocompleteInput
|
||||||
value={madeBy}
|
value={madeBy}
|
||||||
onChange={(e) => setMadeBy(e.target.value)}
|
onChange={(e) => setMadeBy(e.target.value)}
|
||||||
|
suggestions={uniqueMadeBy}
|
||||||
placeholder="שם האדם שיצר את הגרסה הזו..."
|
placeholder="שם האדם שיצר את הגרסה הזו..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -191,11 +211,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>תגיות (מופרד בפסיקים)</label>
|
<label>תגיות (מופרד ברווחים)</label>
|
||||||
<input
|
<input
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={(e) => setTags(e.target.value)}
|
onChange={(e) => setTags(e.target.value)}
|
||||||
placeholder="מהיר, טבעוני, משפחתי..."
|
placeholder="מהיר טבעוני משפחתי..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -204,9 +224,17 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
<div className="dynamic-list">
|
<div className="dynamic-list">
|
||||||
{ingredients.map((val, idx) => (
|
{ingredients.map((val, idx) => (
|
||||||
<div key={idx} className="dynamic-row">
|
<div key={idx} className="dynamic-row">
|
||||||
<input
|
<AutocompleteInput
|
||||||
|
inputRef={idx === ingredients.length - 1 ? lastIngredientRef : null}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddIngredient();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
suggestions={uniqueIngredients}
|
||||||
placeholder="למשל: 2 ביצים"
|
placeholder="למשל: 2 ביצים"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -234,8 +262,15 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
|||||||
{steps.map((val, idx) => (
|
{steps.map((val, idx) => (
|
||||||
<div key={idx} className="dynamic-row">
|
<div key={idx} className="dynamic-row">
|
||||||
<input
|
<input
|
||||||
|
ref={idx === steps.length - 1 ? lastStepRef : null}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddStep();
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -42,7 +42,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import placeholderImage from "../assets/placeholder.svg";
|
import placeholderLight from "../assets/placeholder-light.png";
|
||||||
|
import placeholderDark from "../assets/placeholder-dark.png";
|
||||||
|
|
||||||
function RecipeSearchList({
|
function RecipeSearchList({
|
||||||
allRecipes,
|
allRecipes,
|
||||||
@ -14,10 +15,21 @@ function RecipeSearchList({
|
|||||||
onMaxTimeChange,
|
onMaxTimeChange,
|
||||||
filterTags,
|
filterTags,
|
||||||
onTagsChange,
|
onTagsChange,
|
||||||
filterMadeBy,
|
filterOwner,
|
||||||
onMadeByChange,
|
onOwnerChange,
|
||||||
}) {
|
}) {
|
||||||
const [expandFilters, setExpandFilters] = useState(false);
|
const [expandFilters, setExpandFilters] = useState(false);
|
||||||
|
const [theme, setTheme] = useState(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setTheme(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const placeholderImage = theme === 'dark' ? placeholderDark : placeholderLight;
|
||||||
|
|
||||||
// Extract unique tags from ALL recipes (not filtered)
|
// Extract unique tags from ALL recipes (not filtered)
|
||||||
const allTags = Array.from(
|
const allTags = Array.from(
|
||||||
@ -27,8 +39,19 @@ function RecipeSearchList({
|
|||||||
// Extract unique meal types from ALL recipes (not filtered)
|
// Extract unique meal types from ALL recipes (not filtered)
|
||||||
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
||||||
|
|
||||||
// Extract unique made_by from ALL recipes (not filtered)
|
// Extract unique made_by values from ALL recipes
|
||||||
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort();
|
// The made_by field is what the user defined when creating the recipe,
|
||||||
|
// so we use it for both filtering and display
|
||||||
|
const madeByMap = new Map();
|
||||||
|
allRecipes.forEach((r) => {
|
||||||
|
if (r.made_by) {
|
||||||
|
// Always use made_by as the display name (it's the custom name the user entered)
|
||||||
|
if (!madeByMap.has(r.made_by)) {
|
||||||
|
madeByMap.set(r.made_by, r.made_by);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const allMadeBy = Array.from(madeByMap.keys()).sort();
|
||||||
|
|
||||||
// Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
|
// Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
|
||||||
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
|
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
|
||||||
@ -47,10 +70,10 @@ function RecipeSearchList({
|
|||||||
onMealTypeChange("");
|
onMealTypeChange("");
|
||||||
onMaxTimeChange("");
|
onMaxTimeChange("");
|
||||||
onTagsChange([]);
|
onTagsChange([]);
|
||||||
onMadeByChange("");
|
onOwnerChange("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
|
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterOwner;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel secondary recipe-search-list">
|
<section className="panel secondary recipe-search-list">
|
||||||
@ -165,18 +188,18 @@ function RecipeSearchList({
|
|||||||
<label className="filter-label">המתכונים של:</label>
|
<label className="filter-label">המתכונים של:</label>
|
||||||
<div className="filter-options">
|
<div className="filter-options">
|
||||||
<button
|
<button
|
||||||
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
|
className={`filter-btn ${filterOwner === "" ? "active" : ""}`}
|
||||||
onClick={() => onMadeByChange("")}
|
onClick={() => onOwnerChange("")}
|
||||||
>
|
>
|
||||||
הכל
|
הכל
|
||||||
</button>
|
</button>
|
||||||
{allMadeBy.map((person) => (
|
{allMadeBy.map((madeBy) => (
|
||||||
<button
|
<button
|
||||||
key={person}
|
key={madeBy}
|
||||||
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
|
className={`filter-btn ${filterOwner === madeBy ? "active" : ""}`}
|
||||||
onClick={() => onMadeByChange(person)}
|
onClick={() => onOwnerChange(madeBy)}
|
||||||
>
|
>
|
||||||
{person}
|
{madeByMap.get(madeBy) || madeBy}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -239,7 +262,7 @@ function translateMealType(type) {
|
|||||||
case "dinner":
|
case "dinner":
|
||||||
return "ערב";
|
return "ערב";
|
||||||
case "snack":
|
case "snack":
|
||||||
return "נשנוש";
|
return "קינוחים";
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
164
frontend/src/components/Register.jsx
Normal file
164
frontend/src/components/Register.jsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { register, login, saveToken } from "../authApi";
|
||||||
|
|
||||||
|
function Register({ onSuccess, onSwitchToLogin }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("הסיסמאות אינן תואמות");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!displayName.trim()) {
|
||||||
|
setError("שם תצוגה הוא שדה חובה");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Register the user
|
||||||
|
await register(username, email, password, firstName, lastName, displayName);
|
||||||
|
|
||||||
|
// Automatically login after successful registration
|
||||||
|
const response = await login(username, password);
|
||||||
|
saveToken(response.access_token);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-title">הרשמה</h1>
|
||||||
|
<p className="auth-subtitle">צור חשבון חדש והתחל לנהל את המתכונים שלך</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם פרטי</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
placeholder="שם פרטי (אופציונלי)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם משפחה</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
placeholder="שם משפחה (אופציונלי)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם תצוגה *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="איך תרצה שיופיע שמך?"
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>שם משתמש * (אנגלית בלבד)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="username (English only)"
|
||||||
|
autoComplete="username"
|
||||||
|
minLength={3}
|
||||||
|
pattern="[a-zA-Z0-9_-]+"
|
||||||
|
title="שם משתמש יכול להכיל רק אותיות באנגלית, מספרים, _ ו-"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>אימייל *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="your@email.com"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="בחר סיסמה חזקה"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>אימות סיסמה *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן סיסמה שוב"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn primary full-width" disabled={loading}>
|
||||||
|
{loading ? "נרשם..." : "הירשם"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-footer">
|
||||||
|
<p>
|
||||||
|
כבר יש לך חשבון?{" "}
|
||||||
|
<button className="link-btn" onClick={onSwitchToLogin}>
|
||||||
|
התחבר
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Register;
|
||||||
108
frontend/src/components/ResetPassword.jsx
Normal file
108
frontend/src/components/ResetPassword.jsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getApiBase } from "../api";
|
||||||
|
|
||||||
|
function ResetPassword({ token, onSuccess, onBack }) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("הסיסמאות אינן תואמות");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiBase()}/reset-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: token,
|
||||||
|
new_password: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
onSuccess();
|
||||||
|
} else {
|
||||||
|
setError(data.detail || "שגיאה באיפוס הסיסמה");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("שגיאה באיפוס הסיסמה");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-container">
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-title">איפוס סיסמה</h1>
|
||||||
|
<p className="auth-subtitle">הזן את הסיסמה החדשה שלך</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>סיסמה חדשה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן סיסמה חדשה"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>אימות סיסמה</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="הזן שוב את הסיסמה"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn primary full-width"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "מאפס..." : "איפוס סיסמה"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-footer">
|
||||||
|
<p>
|
||||||
|
<button className="link-btn" onClick={onBack}>
|
||||||
|
חזור להתחברות
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResetPassword;
|
||||||
@ -1,4 +1,6 @@
|
|||||||
function TopBar({ onAddClick }) {
|
import NotificationBell from "./NotificationBell";
|
||||||
|
|
||||||
|
function TopBar({ onAddClick, user, onLogout, onShowToast, onNotificationClick, onAdminClick }) {
|
||||||
return (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
@ -11,10 +13,26 @@ function TopBar({ onAddClick }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
<div className="topbar-actions">
|
||||||
<button className="btn primary" onClick={onAddClick}>
|
{user && <NotificationBell onShowToast={onShowToast} onNotificationClick={onNotificationClick} />}
|
||||||
+ מתכון חדש
|
{user?.is_admin && (
|
||||||
|
<button className="btn ghost btn-mobile-compact" onClick={onAdminClick}>
|
||||||
|
<span className="btn-text-desktop">🛡️ ניהול</span>
|
||||||
|
<span className="btn-text-mobile">🛡️</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{user && (
|
||||||
|
<button className="btn primary btn-mobile-compact" onClick={onAddClick}>
|
||||||
|
<span className="btn-text-desktop">+ מתכון חדש</span>
|
||||||
|
<span className="btn-text-mobile">+</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onLogout && (
|
||||||
|
<button className="btn ghost btn-mobile-compact" onClick={onLogout}>
|
||||||
|
<span className="btn-text-desktop">יציאה</span>
|
||||||
|
<span className="btn-text-mobile">↩️</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
145
frontend/src/groceryApi.js
Normal file
145
frontend/src/groceryApi.js
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
const API_URL = window.__ENV__?.API_BASE || window.ENV?.VITE_API_URL || "http://192.168.1.100:8000";
|
||||||
|
|
||||||
|
// Get auth token from localStorage
|
||||||
|
const getAuthHeaders = () => {
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all grocery lists
|
||||||
|
export const getGroceryLists = async () => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch grocery lists");
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a new grocery list
|
||||||
|
export const createGroceryList = async (data) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to create grocery list");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get a specific grocery list
|
||||||
|
export const getGroceryList = async (id) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch grocery list");
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update a grocery list
|
||||||
|
export const updateGroceryList = async (id, data) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to update grocery list");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete a grocery list
|
||||||
|
export const deleteGroceryList = async (id) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to delete grocery list");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle pin status for a grocery list
|
||||||
|
export const togglePinGroceryList = async (id) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorMessage = "Failed to toggle pin status";
|
||||||
|
try {
|
||||||
|
const error = await res.json();
|
||||||
|
errorMessage = error.detail || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = `HTTP ${res.status}: ${res.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Share a grocery list
|
||||||
|
export const shareGroceryList = async (listId, data) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${listId}/share`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to share grocery list");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get grocery list shares
|
||||||
|
export const getGroceryListShares = async (listId) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch shares");
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unshare a grocery list
|
||||||
|
export const unshareGroceryList = async (listId, userId) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to unshare grocery list");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update share permissions
|
||||||
|
export const updateSharePermission = async (listId, userId, canEdit) => {
|
||||||
|
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares/${userId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ can_edit: canEdit }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.detail || "Failed to update share permission");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search users
|
||||||
|
export const searchUsers = async (query) => {
|
||||||
|
const res = await fetch(`${API_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to search users");
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
69
frontend/src/notificationApi.js
Normal file
69
frontend/src/notificationApi.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
const API_BASE_URL = window.__ENV__?.API_BASE || window._env_?.VITE_API_URL || import.meta.env.VITE_API_URL || "http://192.168.1.100:8000";
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotifications(unreadOnly = false) {
|
||||||
|
const url = `${API_BASE_URL}/notifications${unreadOnly ? '?unread_only=true' : ''}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Failed to fetch notifications" }));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationAsRead(notificationId) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Failed to mark notification as read" }));
|
||||||
|
throw new Error(errorData.detail || "Failed to mark notification as read");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsAsRead() {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/notifications/read-all`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Failed to mark all notifications as read" }));
|
||||||
|
throw new Error(errorData.detail || "Failed to mark all notifications as read");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotification(notificationId) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: "Failed to delete notification" }));
|
||||||
|
throw new Error(errorData.detail || "Failed to delete notification");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
@ -5,4 +5,8 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
assetsInclude: ['**/*.svg'],
|
assetsInclude: ['**/*.svg'],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
// port: 5173, // Default port - uncomment to switch back
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user