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 |
4
.gitignore
vendored
4
.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}"
|
||||
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 commit -m "frontend: update tag to $TAG" || echo "No changes"
|
||||
git push origin HEAD
|
||||
@ -93,7 +93,7 @@ steps:
|
||||
- |
|
||||
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
|
||||
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 commit -m "backend: update tag to $TAG" || echo "No changes"
|
||||
git push origin HEAD
|
||||
|
||||
25
backend/.env
25
backend/.env
@ -4,3 +4,28 @@ 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
|
||||
|
||||
# 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:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, meal_type, time_minutes,
|
||||
tags, ingredients, steps, image, made_by
|
||||
FROM recipes
|
||||
ORDER BY id
|
||||
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||
u.display_name as owner_display_name
|
||||
FROM recipes r
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
ORDER BY r.id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
@ -85,7 +87,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
|
||||
image = %s,
|
||||
made_by = %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"],
|
||||
@ -133,9 +135,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, 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, %s)
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
|
||||
""",
|
||||
(
|
||||
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", [])),
|
||||
recipe_data.get("image"),
|
||||
recipe_data.get("made_by"),
|
||||
recipe_data.get("user_id"),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@ -162,19 +165,21 @@ def get_recipes_by_filters_db(
|
||||
conn = get_conn()
|
||||
try:
|
||||
query = """
|
||||
SELECT id, name, meal_type, time_minutes,
|
||||
tags, ingredients, steps, image, made_by
|
||||
FROM recipes
|
||||
SELECT r.id, r.name, r.meal_type, r.time_minutes,
|
||||
r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
|
||||
u.display_name as owner_display_name
|
||||
FROM recipes r
|
||||
LEFT JOIN users u ON r.user_id = u.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params: List = []
|
||||
|
||||
if meal_type:
|
||||
query += " AND meal_type = %s"
|
||||
query += " AND r.meal_type = %s"
|
||||
params.append(meal_type.lower())
|
||||
|
||||
if max_time:
|
||||
query += " AND time_minutes <= %s"
|
||||
query += " AND r.time_minutes <= %s"
|
||||
params.append(max_time)
|
||||
|
||||
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()
|
||||
1043
backend/main.py
1043
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
|
||||
|
||||
pydantic==2.7.4
|
||||
pydantic[email]==2.7.4
|
||||
python-dotenv==1.0.1
|
||||
|
||||
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 TABLE IF NOT EXISTS recipes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
|
||||
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
|
||||
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
||||
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
||||
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
|
||||
image TEXT -- Base64-encoded image or image URL
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 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
|
||||
ON recipes (made_by);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb
|
||||
ON recipes USING GIN (tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_user_id
|
||||
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
|
||||
);
|
||||
@ -34,4 +34,4 @@ RUN chmod +x /docker-entrypoint.d/10-generate-env.sh && \
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# nginx will start automatically; our script in /docker-entrypoint.d runs first
|
||||
# nginx will start automatically; our script in /docker-entrypoint.d runs first
|
||||
@ -1,12 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="he" dir="rtl">
|
||||
<head>
|
||||
<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" />
|
||||
<title>frontend</title>
|
||||
<meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
|
||||
<title>My Recipes | המתכונים שלי</title>
|
||||
<!-- Load environment variables before app starts -->
|
||||
<script src="/env.js"></script>
|
||||
<script src="/env.js?v=20251219"></script>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
1021
frontend/src/App.css
1021
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 RecipeDetails from "./components/RecipeDetails";
|
||||
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 ToastContainer from "./components/ToastContainer";
|
||||
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 { getToken, removeToken, getMe } from "./authApi";
|
||||
|
||||
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 [selectedRecipe, setSelectedRecipe] = useState(null);
|
||||
|
||||
@ -19,7 +41,7 @@ function App() {
|
||||
const [filterMealType, setFilterMealType] = useState("");
|
||||
const [filterMaxTime, setFilterMaxTime] = useState("");
|
||||
const [filterTags, setFilterTags] = useState([]);
|
||||
const [filterMadeBy, setFilterMadeBy] = useState("");
|
||||
const [filterOwner, setFilterOwner] = useState("");
|
||||
|
||||
// Random recipe filters
|
||||
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
||||
@ -33,6 +55,7 @@ function App() {
|
||||
const [editingRecipe, setEditingRecipe] = useState(null);
|
||||
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
const [logoutModal, setLogoutModal] = useState(false);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [theme, setTheme] = useState(() => {
|
||||
try {
|
||||
@ -41,7 +64,86 @@ function App() {
|
||||
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(() => {
|
||||
loadRecipes();
|
||||
}, []);
|
||||
@ -96,8 +198,8 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by made_by
|
||||
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
|
||||
// Filter by made_by (username)
|
||||
if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -134,7 +236,8 @@ function App() {
|
||||
|
||||
const handleCreateRecipe = async (payload) => {
|
||||
try {
|
||||
const created = await createRecipe(payload);
|
||||
const token = getToken();
|
||||
const created = await createRecipe(payload, token);
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
await loadRecipes();
|
||||
@ -153,7 +256,8 @@ function App() {
|
||||
|
||||
const handleUpdateRecipe = async (payload) => {
|
||||
try {
|
||||
await updateRecipe(editingRecipe.id, payload);
|
||||
const token = getToken();
|
||||
await updateRecipe(editingRecipe.id, payload, token);
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
await loadRecipes();
|
||||
@ -177,7 +281,8 @@ function App() {
|
||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
|
||||
try {
|
||||
await deleteRecipe(recipeId);
|
||||
const token = getToken();
|
||||
await deleteRecipe(recipeId, token);
|
||||
await loadRecipes();
|
||||
setSelectedRecipe(null);
|
||||
addToast("המתכון נמחק בהצלחה!", "success");
|
||||
@ -208,51 +313,224 @@ 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 (
|
||||
<div className="app-root">
|
||||
<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">
|
||||
<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}
|
||||
filterMadeBy={filterMadeBy}
|
||||
onMadeByChange={setFilterMadeBy}
|
||||
{currentView === "admin" ? (
|
||||
<div className="admin-view">
|
||||
<AdminPanel onShowToast={addToast} />
|
||||
</div>
|
||||
) : currentView === "grocery-lists" ? (
|
||||
<GroceryLists
|
||||
user={user}
|
||||
onShowToast={addToast}
|
||||
selectedListIdFromNotification={selectedGroceryListId}
|
||||
onListSelected={() => setSelectedGroceryListId(null)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{/* Random Recipe Suggester - Top Left */}
|
||||
<section className="panel filter-panel">
|
||||
<h3>חיפוש מתכון רנדומלי</h3>
|
||||
<div className="panel-grid">
|
||||
<div className="field">
|
||||
<label>סוג ארוחה</label>
|
||||
<select
|
||||
value={mealTypeFilter}
|
||||
onChange={(e) => setMealTypeFilter(e.target.value)}
|
||||
) : (
|
||||
<>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<aside
|
||||
className={`pinned-lists-sidebar ${showPinnedSidebar ? 'mobile-visible' : ''}`}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<option value="">לא משנה</option>
|
||||
<option value="breakfast">בוקר</option>
|
||||
<option value="lunch">צהריים</option>
|
||||
<option value="dinner">ערב</option>
|
||||
<option value="snack">נשנוש</option>
|
||||
</select>
|
||||
</div>
|
||||
<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">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{/* Random Recipe Suggester - Top Left */}
|
||||
<section className="panel filter-panel">
|
||||
<h3>חיפוש מתכון רנדומלי</h3>
|
||||
<div className="panel-grid">
|
||||
<div className="field">
|
||||
<label>סוג ארוחה</label>
|
||||
<select
|
||||
value={mealTypeFilter}
|
||||
onChange={(e) => setMealTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">לא משנה</option>
|
||||
<option value="breakfast">בוקר</option>
|
||||
<option value="lunch">צהריים</option>
|
||||
<option value="dinner">ערב</option>
|
||||
<option value="snack">קינוחים</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>זמן מקסימלי (דקות)</label>
|
||||
@ -289,19 +567,47 @@ function App() {
|
||||
recipe={selectedRecipe}
|
||||
onEditClick={handleEditRecipe}
|
||||
onShowDeleteModal={handleShowDeleteModal}
|
||||
isAuthenticated={isAuthenticated}
|
||||
currentUser={user}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<RecipeFormDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
editingRecipe={editingRecipe}
|
||||
/>
|
||||
{isAuthenticated && (
|
||||
<RecipeFormDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
editingRecipe={editingRecipe}
|
||||
currentUser={user}
|
||||
allRecipes={recipes}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={deleteModal.isOpen}
|
||||
@ -314,6 +620,17 @@ function App() {
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={logoutModal}
|
||||
title="התנתקות"
|
||||
message="האם אתה בטוח שברצונך להתנתק?"
|
||||
confirmText="התנתק"
|
||||
cancelText="ביטול"
|
||||
isDangerous={false}
|
||||
onConfirm={confirmLogout}
|
||||
onCancel={() => setLogoutModal(false)}
|
||||
/>
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// 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) {
|
||||
return window.__ENV__.API_BASE;
|
||||
}
|
||||
@ -37,10 +37,14 @@ export async function getRandomRecipe(filters) {
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
body: JSON.stringify(recipe),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@ -49,10 +53,14 @@ export async function createRecipe(recipe) {
|
||||
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}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@ -61,9 +69,14 @@ export async function updateRecipe(id, payload) {
|
||||
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}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
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;
|
||||
|
||||
return (
|
||||
@ -8,19 +8,21 @@ function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מ
|
||||
<h2>{title}</h2>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
{message}
|
||||
{children || message}
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
<button className="btn ghost" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${isDangerous ? "danger" : "primary"}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</footer>
|
||||
{!children && (
|
||||
<footer className="modal-footer">
|
||||
<button className="btn ghost" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${isDangerous ? "danger" : "primary"}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</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) {
|
||||
return (
|
||||
<section className="panel placeholder">
|
||||
@ -13,6 +26,15 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
||||
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 (
|
||||
<section className="panel recipe-card">
|
||||
{/* Recipe Image */}
|
||||
@ -26,8 +48,8 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
||||
<p className="recipe-subtitle">
|
||||
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
||||
</p>
|
||||
{recipe.made_by && (
|
||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
|
||||
{(recipe.made_by || recipe.owner_display_name) && (
|
||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by || recipe.owner_display_name}</h4>
|
||||
)}
|
||||
</div>
|
||||
<div className="pill-row">
|
||||
@ -66,14 +88,16 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
|
||||
</footer>
|
||||
)}
|
||||
|
||||
<div className="recipe-actions">
|
||||
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||
✏️ ערוך
|
||||
</button>
|
||||
<button className="btn ghost small" onClick={handleDelete}>
|
||||
🗑 מחק
|
||||
</button>
|
||||
</div>
|
||||
{isAuthenticated && currentUser && (Number(recipe.user_id) === Number(currentUser.id) || currentUser.is_admin) && (
|
||||
<div className="recipe-actions">
|
||||
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||
✏️ ערוך
|
||||
</button>
|
||||
<button className="btn ghost small" onClick={handleDelete}>
|
||||
🗑 מחק
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -87,7 +111,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "נשנוש";
|
||||
return "קינוחים";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "נשנוש";
|
||||
return "קינוחים";
|
||||
default:
|
||||
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 [mealType, setMealType] = useState("lunch");
|
||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||
@ -11,6 +12,19 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
const [ingredients, setIngredients] = 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;
|
||||
|
||||
useEffect(() => {
|
||||
@ -20,7 +34,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
setMealType(editingRecipe.meal_type || "lunch");
|
||||
setTimeMinutes(editingRecipe.time_minutes || 15);
|
||||
setMadeBy(editingRecipe.made_by || "");
|
||||
setTags((editingRecipe.tags || []).join(", "));
|
||||
setTags((editingRecipe.tags || []).join(" "));
|
||||
setImage(editingRecipe.image || "");
|
||||
setIngredients(editingRecipe.ingredients || [""]);
|
||||
setSteps(editingRecipe.steps || [""]);
|
||||
@ -28,19 +42,23 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
setName("");
|
||||
setMealType("lunch");
|
||||
setTimeMinutes(15);
|
||||
setMadeBy("");
|
||||
setMadeBy(currentUser?.username || "");
|
||||
setTags("");
|
||||
setImage("");
|
||||
setIngredients([""]);
|
||||
setSteps([""]);
|
||||
}
|
||||
}
|
||||
}, [open, editingRecipe, isEditMode]);
|
||||
}, [open, editingRecipe, isEditMode, currentUser]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleAddIngredient = () => {
|
||||
setIngredients((prev) => [...prev, ""]);
|
||||
setTimeout(() => {
|
||||
lastIngredientRef.current?.focus();
|
||||
lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleChangeIngredient = (idx, value) => {
|
||||
@ -53,6 +71,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
|
||||
const handleAddStep = () => {
|
||||
setSteps((prev) => [...prev, ""]);
|
||||
setTimeout(() => {
|
||||
lastStepRef.current?.focus();
|
||||
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
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 cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
|
||||
const tagsArr = tags
|
||||
.split(",")
|
||||
.split(" ")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
@ -95,12 +117,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
tags: tagsArr,
|
||||
ingredients: cleanIngredients,
|
||||
steps: cleanSteps,
|
||||
made_by: madeBy.trim() || currentUser?.username || "",
|
||||
};
|
||||
|
||||
if (madeBy.trim()) {
|
||||
payload.made_by = madeBy.trim();
|
||||
}
|
||||
|
||||
if (image) {
|
||||
payload.image = image;
|
||||
}
|
||||
@ -136,7 +155,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
<option value="breakfast">בוקר</option>
|
||||
<option value="lunch">צהריים</option>
|
||||
<option value="dinner">ערב</option>
|
||||
<option value="snack">נשנוש</option>
|
||||
<option value="snack">קינוחים</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -154,9 +173,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
|
||||
<div className="field">
|
||||
<label>המתכון של:</label>
|
||||
<input
|
||||
<AutocompleteInput
|
||||
value={madeBy}
|
||||
onChange={(e) => setMadeBy(e.target.value)}
|
||||
suggestions={uniqueMadeBy}
|
||||
placeholder="שם האדם שיצר את הגרסה הזו..."
|
||||
/>
|
||||
</div>
|
||||
@ -191,11 +211,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>תגיות (מופרד בפסיקים)</label>
|
||||
<label>תגיות (מופרד ברווחים)</label>
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="מהיר, טבעוני, משפחתי..."
|
||||
placeholder="מהיר טבעוני משפחתי..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -204,9 +224,17 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
<div className="dynamic-list">
|
||||
{ingredients.map((val, idx) => (
|
||||
<div key={idx} className="dynamic-row">
|
||||
<input
|
||||
<AutocompleteInput
|
||||
inputRef={idx === ingredients.length - 1 ? lastIngredientRef : null}
|
||||
value={val}
|
||||
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddIngredient();
|
||||
}
|
||||
}}
|
||||
suggestions={uniqueIngredients}
|
||||
placeholder="למשל: 2 ביצים"
|
||||
/>
|
||||
<button
|
||||
@ -234,8 +262,15 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
{steps.map((val, idx) => (
|
||||
<div key={idx} className="dynamic-row">
|
||||
<input
|
||||
ref={idx === steps.length - 1 ? lastStepRef : null}
|
||||
value={val}
|
||||
onChange={(e) => handleChangeStep(idx, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddStep();
|
||||
}
|
||||
}}
|
||||
placeholder="למשל: לחמם את התנור ל־180 מעלות"
|
||||
/>
|
||||
<button
|
||||
|
||||
@ -42,7 +42,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "נשנוש";
|
||||
return "קינוחים";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import placeholderImage from "../assets/placeholder.svg";
|
||||
import { useState, useEffect } from "react";
|
||||
import placeholderLight from "../assets/placeholder-light.png";
|
||||
import placeholderDark from "../assets/placeholder-dark.png";
|
||||
|
||||
function RecipeSearchList({
|
||||
allRecipes,
|
||||
@ -14,10 +15,21 @@ function RecipeSearchList({
|
||||
onMaxTimeChange,
|
||||
filterTags,
|
||||
onTagsChange,
|
||||
filterMadeBy,
|
||||
onMadeByChange,
|
||||
filterOwner,
|
||||
onOwnerChange,
|
||||
}) {
|
||||
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)
|
||||
const allTags = Array.from(
|
||||
@ -27,8 +39,19 @@ function RecipeSearchList({
|
||||
// Extract unique meal types from ALL recipes (not filtered)
|
||||
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
|
||||
|
||||
// Extract unique made_by from ALL recipes (not filtered)
|
||||
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort();
|
||||
// Extract unique made_by values from ALL recipes
|
||||
// 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
|
||||
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
|
||||
@ -47,10 +70,10 @@ function RecipeSearchList({
|
||||
onMealTypeChange("");
|
||||
onMaxTimeChange("");
|
||||
onTagsChange([]);
|
||||
onMadeByChange("");
|
||||
onOwnerChange("");
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
|
||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterOwner;
|
||||
|
||||
return (
|
||||
<section className="panel secondary recipe-search-list">
|
||||
@ -165,18 +188,18 @@ function RecipeSearchList({
|
||||
<label className="filter-label">המתכונים של:</label>
|
||||
<div className="filter-options">
|
||||
<button
|
||||
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
|
||||
onClick={() => onMadeByChange("")}
|
||||
className={`filter-btn ${filterOwner === "" ? "active" : ""}`}
|
||||
onClick={() => onOwnerChange("")}
|
||||
>
|
||||
הכל
|
||||
</button>
|
||||
{allMadeBy.map((person) => (
|
||||
{allMadeBy.map((madeBy) => (
|
||||
<button
|
||||
key={person}
|
||||
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
|
||||
onClick={() => onMadeByChange(person)}
|
||||
key={madeBy}
|
||||
className={`filter-btn ${filterOwner === madeBy ? "active" : ""}`}
|
||||
onClick={() => onOwnerChange(madeBy)}
|
||||
>
|
||||
{person}
|
||||
{madeByMap.get(madeBy) || madeBy}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -239,7 +262,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "נשנוש";
|
||||
return "קינוחים";
|
||||
default:
|
||||
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 (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
@ -11,10 +13,26 @@ function TopBar({ onAddClick }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<button className="btn primary" onClick={onAddClick}>
|
||||
+ מתכון חדש
|
||||
</button>
|
||||
<div className="topbar-actions">
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
</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({
|
||||
plugins: [react()],
|
||||
assetsInclude: ['**/*.svg'],
|
||||
server: {
|
||||
port: 5174,
|
||||
// port: 5173, // Default port - uncomment to switch back
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user