Compare commits
No commits in common. "r2-bkp" and "master" have entirely different histories.
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1 @@
|
||||
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.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
yq -i ".frontend.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.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
|
||||
yq -i ".backend.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,28 +4,3 @@ 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
|
||||
@ -1,28 +0,0 @@
|
||||
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
1
backend/.gitignore
vendored
@ -1 +0,0 @@
|
||||
__pycache__/
|
||||
@ -1,93 +0,0 @@
|
||||
# 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
|
||||
@ -1,164 +0,0 @@
|
||||
# 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! 🎉**
|
||||
@ -1,21 +0,0 @@
|
||||
# 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.
|
||||
@ -1,131 +0,0 @@
|
||||
# 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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,30 +0,0 @@
|
||||
-- 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;
|
||||
@ -1,5 +0,0 @@
|
||||
-- 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
|
||||
@ -1,96 +0,0 @@
|
||||
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
|
||||
@ -1,209 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@ -1,245 +0,0 @@
|
||||
"""
|
||||
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,12 +54,10 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
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
|
||||
SELECT id, name, meal_type, time_minutes,
|
||||
tags, ingredients, steps, image, made_by
|
||||
FROM recipes
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
@ -87,7 +85,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, user_id
|
||||
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by
|
||||
""",
|
||||
(
|
||||
recipe_data["name"],
|
||||
@ -135,9 +133,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, 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
|
||||
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
|
||||
""",
|
||||
(
|
||||
recipe_data["name"],
|
||||
@ -148,7 +146,6 @@ 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()
|
||||
@ -165,21 +162,19 @@ def get_recipes_by_filters_db(
|
||||
conn = get_conn()
|
||||
try:
|
||||
query = """
|
||||
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
|
||||
SELECT id, name, meal_type, time_minutes,
|
||||
tags, ingredients, steps, image, made_by
|
||||
FROM recipes
|
||||
WHERE 1=1
|
||||
"""
|
||||
params: List = []
|
||||
|
||||
if meal_type:
|
||||
query += " AND r.meal_type = %s"
|
||||
query += " AND meal_type = %s"
|
||||
params.append(meal_type.lower())
|
||||
|
||||
if max_time:
|
||||
query += " AND r.time_minutes <= %s"
|
||||
query += " AND time_minutes <= %s"
|
||||
params.append(max_time)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
|
||||
@ -1,211 +0,0 @@
|
||||
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]
|
||||
@ -1,275 +0,0 @@
|
||||
import os
|
||||
from typing import List, Optional, Dict, Any
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection"""
|
||||
return psycopg2.connect(
|
||||
host=os.getenv("DB_HOST", "localhost"),
|
||||
port=int(os.getenv("DB_PORT", "5432")),
|
||||
database=os.getenv("DB_NAME", "recipes_db"),
|
||||
user=os.getenv("DB_USER", "recipes_user"),
|
||||
password=os.getenv("DB_PASSWORD", "recipes_password"),
|
||||
)
|
||||
|
||||
|
||||
def create_grocery_list(owner_id: int, name: str, items: List[str] = None) -> Dict[str, Any]:
|
||||
"""Create a new grocery list"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
items = items or []
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO grocery_lists (owner_id, name, items)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id, name, items, owner_id, created_at, updated_at
|
||||
""",
|
||||
(owner_id, name, items)
|
||||
)
|
||||
grocery_list = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(grocery_list)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_grocery_lists(user_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all grocery lists owned by or shared with a user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
|
||||
u.display_name as owner_display_name,
|
||||
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
|
||||
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
|
||||
FROM grocery_lists gl
|
||||
LEFT JOIN users u ON gl.owner_id = u.id
|
||||
LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s
|
||||
WHERE gl.owner_id = %s OR gls.shared_with_user_id = %s
|
||||
ORDER BY gl.updated_at DESC
|
||||
""",
|
||||
(user_id, user_id, user_id, user_id, user_id)
|
||||
)
|
||||
lists = cur.fetchall()
|
||||
return [dict(row) for row in lists]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_grocery_list_by_id(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get a specific grocery list if user has access"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
|
||||
u.display_name as owner_display_name,
|
||||
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
|
||||
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
|
||||
FROM grocery_lists gl
|
||||
LEFT JOIN users u ON gl.owner_id = u.id
|
||||
LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s
|
||||
WHERE gl.id = %s AND (gl.owner_id = %s OR gls.shared_with_user_id = %s)
|
||||
""",
|
||||
(user_id, user_id, user_id, list_id, user_id, user_id)
|
||||
)
|
||||
grocery_list = cur.fetchone()
|
||||
return dict(grocery_list) if grocery_list else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_grocery_list(list_id: int, name: str = None, items: List[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Update a grocery list"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if name is not None:
|
||||
updates.append("name = %s")
|
||||
params.append(name)
|
||||
|
||||
if items is not None:
|
||||
updates.append("items = %s")
|
||||
params.append(items)
|
||||
|
||||
if not updates:
|
||||
return None
|
||||
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.append(list_id)
|
||||
|
||||
query = f"UPDATE grocery_lists SET {', '.join(updates)} WHERE id = %s RETURNING id, name, items, owner_id, created_at, updated_at"
|
||||
|
||||
cur.execute(query, params)
|
||||
grocery_list = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(grocery_list) if grocery_list else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_grocery_list(list_id: int) -> bool:
|
||||
"""Delete a grocery list"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("DELETE FROM grocery_lists WHERE id = %s", (list_id,))
|
||||
deleted = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def share_grocery_list(list_id: int, shared_with_user_id: int, can_edit: bool = False) -> Dict[str, Any]:
|
||||
"""Share a grocery list with another user or update existing share permissions"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO grocery_list_shares (list_id, shared_with_user_id, can_edit)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (list_id, shared_with_user_id)
|
||||
DO UPDATE SET can_edit = EXCLUDED.can_edit, shared_at = CURRENT_TIMESTAMP
|
||||
RETURNING id, list_id, shared_with_user_id, can_edit, shared_at
|
||||
""",
|
||||
(list_id, shared_with_user_id, can_edit)
|
||||
)
|
||||
share = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(share)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_share_permission(list_id: int, shared_with_user_id: int, can_edit: bool) -> Optional[Dict[str, Any]]:
|
||||
"""Update edit permission for an existing share"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE grocery_list_shares
|
||||
SET can_edit = %s
|
||||
WHERE list_id = %s AND shared_with_user_id = %s
|
||||
RETURNING id, list_id, shared_with_user_id, can_edit, shared_at
|
||||
""",
|
||||
(can_edit, list_id, shared_with_user_id)
|
||||
)
|
||||
share = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(share) if share else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def unshare_grocery_list(list_id: int, user_id: int) -> bool:
|
||||
"""Remove sharing access for a user"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"DELETE FROM grocery_list_shares WHERE list_id = %s AND shared_with_user_id = %s",
|
||||
(list_id, user_id)
|
||||
)
|
||||
deleted = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_grocery_list_shares(list_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all users a grocery list is shared with"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT gls.id, gls.list_id, gls.shared_with_user_id, gls.can_edit, gls.shared_at,
|
||||
u.username, u.display_name, u.email
|
||||
FROM grocery_list_shares gls
|
||||
JOIN users u ON gls.shared_with_user_id = u.id
|
||||
WHERE gls.list_id = %s
|
||||
ORDER BY gls.shared_at DESC
|
||||
""",
|
||||
(list_id,)
|
||||
)
|
||||
shares = cur.fetchall()
|
||||
return [dict(row) for row in shares]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def search_users(query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Search users by username or display_name for autocomplete"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, username, display_name, email
|
||||
FROM users
|
||||
WHERE username ILIKE %s OR display_name ILIKE %s
|
||||
ORDER BY username
|
||||
LIMIT %s
|
||||
""",
|
||||
(f"%{query}%", f"%{query}%", limit)
|
||||
)
|
||||
users = cur.fetchall()
|
||||
return [dict(row) for row in users]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def toggle_grocery_list_pin(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Toggle pin status for a grocery list (owner only)"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
# Check if user is owner
|
||||
cur.execute(
|
||||
"SELECT id, is_pinned FROM grocery_lists WHERE id = %s AND owner_id = %s",
|
||||
(list_id, user_id)
|
||||
)
|
||||
result = cur.fetchone()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
# Toggle pin status
|
||||
new_pin_status = not result["is_pinned"]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE grocery_lists
|
||||
SET is_pinned = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
RETURNING id, name, items, owner_id, is_pinned, created_at, updated_at
|
||||
""",
|
||||
(new_pin_status, list_id)
|
||||
)
|
||||
updated = cur.fetchone()
|
||||
conn.commit()
|
||||
return dict(updated) if updated else None
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
1041
backend/main.py
1041
backend/main.py
File diff suppressed because it is too large
Load Diff
@ -1,124 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@ -1,33 +0,0 @@
|
||||
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,24 +2,6 @@ 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
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
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()
|
||||
@ -1,218 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python backup_db.py >> backup.log 2>&1
|
||||
@ -1,32 +1,14 @@
|
||||
-- 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
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"]
|
||||
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"]
|
||||
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
|
||||
image TEXT -- Base64-encoded image or image URL
|
||||
);
|
||||
|
||||
-- Optional: index for filters
|
||||
@ -39,52 +21,9 @@ 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_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_tags_jsonb
|
||||
ON recipes USING GIN (tags);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
|
||||
ON recipes USING GIN (ingredients);
|
||||
|
||||
|
||||
@ -1,202 +0,0 @@
|
||||
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()
|
||||
@ -1,117 +0,0 @@
|
||||
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
178
demo-recipes.sql
@ -1,178 +0,0 @@
|
||||
-- Demo recipes for user dvir (id=3)
|
||||
|
||||
-- Recipe 1: שקשוקה
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'שקשוקה',
|
||||
'breakfast',
|
||||
25,
|
||||
'["מהיר", "בריא", "צמחוני"]'::jsonb,
|
||||
'["4 ביצים", "4 עגבניות", "1 בצל", "2 שיני שום", "פלפל חריף", "כמון", "מלח", "שמן זית"]'::jsonb,
|
||||
'[
|
||||
"לחתוך את הבצל והשום דק",
|
||||
"לחמם שמן בסיר ולטגן את הבצל עד שקוף",
|
||||
"להוסיף שום ופלפל חריף ולטגן דקה",
|
||||
"לקצוץ עגבניות ולהוסיף לסיר",
|
||||
"לתבל בכמון ומלח, לבשל 10 דקות",
|
||||
"לפתוח גומות ברוטב ולשבור ביצה בכל גומה",
|
||||
"לכסות ולבשל עד שהביצים מתקשות"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1525351484163-7529414344d8?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 2: פסטה ברוטב עגבניות
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'פסטה ברוטב עגבניות',
|
||||
'lunch',
|
||||
20,
|
||||
'["מהיר", "צמחוני", "ילדים אוהבים"]'::jsonb,
|
||||
'["500 גרם פסטה", "רסק עגבניות", "בזיליקום טרי", "3 שיני שום", "שמן זית", "מלח", "פלפל"]'::jsonb,
|
||||
'[
|
||||
"להרתיח מים מלוחים ולבשל את הפסטה לפי ההוראות",
|
||||
"בינתיים, לחמם שמן בסיר",
|
||||
"לטגן שום כתוש דקה",
|
||||
"להוסיף רסק עגבניות ולתבל",
|
||||
"לבשל על אש בינונית 10 דקות",
|
||||
"להוסיף בזיליקום קרוע",
|
||||
"לערבב את הפסטה המסוננת עם הרוטב"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 3: סלט ישראלי
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'סלט ישראלי',
|
||||
'snack',
|
||||
10,
|
||||
'["מהיר", "בריא", "טבעוני", "צמחוני"]'::jsonb,
|
||||
'["4 עגבניות", "2 מלפפונים", "1 בצל", "פטרוזיליה", "לימון", "שמן זית", "מלח"]'::jsonb,
|
||||
'[
|
||||
"לחתוך עגבניות ומלפפונים לקוביות קטנות",
|
||||
"לקצוץ בצל דק",
|
||||
"לקצוץ פטרוזיליה",
|
||||
"לערבב הכל בקערה",
|
||||
"להוסיף מיץ לימון ושמן זית",
|
||||
"לתבל במלח ולערבב היטב"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 4: חביתה עם ירקות
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'חביתה עם ירקות',
|
||||
'breakfast',
|
||||
15,
|
||||
'["מהיר", "בריא", "חלבוני", "צמחוני"]'::jsonb,
|
||||
'["3 ביצים", "1 בצל", "1 פלפל", "עגבניה", "גבינה צהובה", "מלח", "פלפל שחור", "שמן"]'::jsonb,
|
||||
'[
|
||||
"לקצוץ את הירקות לקוביות קטנות",
|
||||
"לטגן את הירקות בשמן עד שמתרככים",
|
||||
"להקציף את הביצים במזלג",
|
||||
"לשפוך את הביצים על הירקות",
|
||||
"לפזר גבינה קצוצה",
|
||||
"לבשל עד שהתחתית מוזהבת",
|
||||
"להפוך או לקפל לחצי ולהגיש"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1525351159099-122d10e7960e?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 5: עוף בתנור עם תפוחי אדמה
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'עוף בתנור עם תפוחי אדמה',
|
||||
'dinner',
|
||||
60,
|
||||
'["משפחתי", "חגיגי"]'::jsonb,
|
||||
'["1 עוף שלם", "1 ק״ג תפוחי אדמה", "פפריקה", "כורכום", "שום", "מלח", "פלפל", "שמן זית", "לימון"]'::jsonb,
|
||||
'[
|
||||
"לחמם תנור ל-200 מעלות",
|
||||
"לחתוך תפוחי אדמה לרבעים",
|
||||
"לשפשף את העוף בתבלינים, שמן ומיץ לימון",
|
||||
"לסדר תפוחי אדמה בתבנית",
|
||||
"להניח את העוף על התפוחי אדמה",
|
||||
"לאפות כשעה עד שהעוף מוזהב",
|
||||
"להוציא, לחתוך ולהגיש עם הירקות"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1598103442097-8b74394b95c6?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 6: סנדוויץ טונה
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'סנדוויץ טונה',
|
||||
'lunch',
|
||||
5,
|
||||
'["מהיר", "קר", "חלבוני"]'::jsonb,
|
||||
'["קופסת טונה", "2 פרוסות לחם", "מיונז", "חסה", "עגבניה", "מלפפון חמוץ", "מלח", "פלפל"]'::jsonb,
|
||||
'[
|
||||
"לסנן את הטונה",
|
||||
"לערבב את הטונה עם מיונז",
|
||||
"לתבל במלח ופלפל",
|
||||
"למרוח על פרוסת לחם",
|
||||
"להוסיף חסה, עגבניה ומלפפון",
|
||||
"לכסות בפרוסת לחם שנייה"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 7: בראוניז שוקולד
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'בראוניז שוקולד',
|
||||
'snack',
|
||||
35,
|
||||
'["קינוח", "שוקולד", "אפייה"]'::jsonb,
|
||||
'["200 גרם שוקולד מריר", "150 גרם חמאה", "3 ביצים", "כוס סוכר", "חצי כוס קמח", "אבקת קקאו", "וניל"]'::jsonb,
|
||||
'[
|
||||
"לחמם תנור ל-180 מעלות",
|
||||
"להמיס שוקולד וחמאה במיקרוגל",
|
||||
"להקציף ביצים וסוכר",
|
||||
"להוסיף את תערובת השוקולד",
|
||||
"להוסיף קמח וקקאו ולערבב",
|
||||
"לשפוך לתבנית משומנת",
|
||||
"לאפות 25 דקות",
|
||||
"להוציא ולהניח להתקרר לפני חיתוך"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
|
||||
-- Recipe 8: מרק עדשים
|
||||
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
|
||||
VALUES (
|
||||
'מרק עדשים',
|
||||
'dinner',
|
||||
40,
|
||||
'["בריא", "צמחוני", "טבעוני", "חם"]'::jsonb,
|
||||
'["2 כוסות עדשים כתומות", "בצל", "גזר", "3 שיני שום", "כמון", "כורכום", "מלח", "לימון"]'::jsonb,
|
||||
'[
|
||||
"לשטוף את העדשים",
|
||||
"לקצוץ בצל, גזר ושום",
|
||||
"לטגן את הבצל עד שקוף",
|
||||
"להוסיף שום ותבלינים",
|
||||
"להוסיף גזר ועדשים",
|
||||
"להוסיף 6 כוסות מים",
|
||||
"לבשל 30 דקות עד שהעדשים רכים",
|
||||
"לטחון חלק מהמרק לקבלת מרקם עבה",
|
||||
"להוסיף מיץ לימון לפני הגשה"
|
||||
]'::jsonb,
|
||||
'https://images.unsplash.com/photo-1547592166-23ac45744acd?w=500',
|
||||
'דביר',
|
||||
3
|
||||
);
|
||||
@ -1,14 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="he" dir="rtl">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
|
||||
<title>My Recipes | המתכונים שלי</title>
|
||||
<title>frontend</title>
|
||||
<!-- Load environment variables before app starts -->
|
||||
<script src="/env.js?v=20251219"></script>
|
||||
<script src="/env.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://my-recipes.dvirlabs.com/</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
1009
frontend/src/App.css
1009
frontend/src/App.css
File diff suppressed because it is too large
Load Diff
@ -5,34 +5,12 @@ 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);
|
||||
|
||||
@ -41,7 +19,7 @@ function App() {
|
||||
const [filterMealType, setFilterMealType] = useState("");
|
||||
const [filterMaxTime, setFilterMaxTime] = useState("");
|
||||
const [filterTags, setFilterTags] = useState([]);
|
||||
const [filterOwner, setFilterOwner] = useState("");
|
||||
const [filterMadeBy, setFilterMadeBy] = useState("");
|
||||
|
||||
// Random recipe filters
|
||||
const [mealTypeFilter, setMealTypeFilter] = useState("");
|
||||
@ -55,7 +33,6 @@ 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 {
|
||||
@ -64,86 +41,7 @@ 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();
|
||||
}, []);
|
||||
@ -198,8 +96,8 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by made_by (username)
|
||||
if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
|
||||
// Filter by made_by
|
||||
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -236,8 +134,7 @@ function App() {
|
||||
|
||||
const handleCreateRecipe = async (payload) => {
|
||||
try {
|
||||
const token = getToken();
|
||||
const created = await createRecipe(payload, token);
|
||||
const created = await createRecipe(payload);
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
await loadRecipes();
|
||||
@ -256,8 +153,7 @@ function App() {
|
||||
|
||||
const handleUpdateRecipe = async (payload) => {
|
||||
try {
|
||||
const token = getToken();
|
||||
await updateRecipe(editingRecipe.id, payload, token);
|
||||
await updateRecipe(editingRecipe.id, payload);
|
||||
setDrawerOpen(false);
|
||||
setEditingRecipe(null);
|
||||
await loadRecipes();
|
||||
@ -281,8 +177,7 @@ function App() {
|
||||
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
|
||||
|
||||
try {
|
||||
const token = getToken();
|
||||
await deleteRecipe(recipeId, token);
|
||||
await deleteRecipe(recipeId);
|
||||
await loadRecipes();
|
||||
setSelectedRecipe(null);
|
||||
addToast("המתכון נמחק בהצלחה!", "success");
|
||||
@ -313,204 +208,31 @@ 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} />
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
||||
|
||||
<main className="layout">
|
||||
{currentView === "admin" ? (
|
||||
<div className="admin-view">
|
||||
<AdminPanel onShowToast={addToast} />
|
||||
</div>
|
||||
) : currentView === "grocery-lists" ? (
|
||||
<GroceryLists
|
||||
user={user}
|
||||
onShowToast={addToast}
|
||||
selectedListIdFromNotification={selectedGroceryListId}
|
||||
onListSelected={() => setSelectedGroceryListId(null)}
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<aside
|
||||
className={`pinned-lists-sidebar ${showPinnedSidebar ? 'mobile-visible' : ''}`}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<button
|
||||
className="close-sidebar-btn mobile-only"
|
||||
onClick={() => setShowPinnedSidebar(false)}
|
||||
aria-label="סגור תזכירים"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<PinnedGroceryLists onShowToast={addToast} />
|
||||
</aside>
|
||||
{showPinnedSidebar && (
|
||||
<div
|
||||
className="sidebar-backdrop mobile-only"
|
||||
onClick={() => setShowPinnedSidebar(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<section className="content-wrapper">
|
||||
</section>
|
||||
|
||||
<section className="content">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
@ -528,7 +250,7 @@ function App() {
|
||||
<option value="breakfast">בוקר</option>
|
||||
<option value="lunch">צהריים</option>
|
||||
<option value="dinner">ערב</option>
|
||||
<option value="snack">קינוחים</option>
|
||||
<option value="snack">נשנוש</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -567,35 +289,10 @@ 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>
|
||||
|
||||
{isAuthenticated && (
|
||||
<RecipeFormDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
@ -604,10 +301,7 @@ function App() {
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
editingRecipe={editingRecipe}
|
||||
currentUser={user}
|
||||
allRecipes={recipes}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={deleteModal.isOpen}
|
||||
@ -620,17 +314,6 @@ 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
|
||||
export const getApiBase = () => {
|
||||
const getApiBase = () => {
|
||||
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
|
||||
return window.__ENV__.API_BASE;
|
||||
}
|
||||
@ -37,14 +37,10 @@ export async function getRandomRecipe(filters) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createRecipe(recipe, token) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
export async function createRecipe(recipe) {
|
||||
const res = await fetch(`${API_BASE}/recipes`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(recipe),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@ -53,14 +49,10 @@ export async function createRecipe(recipe, token) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateRecipe(id, payload, token) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
export async function updateRecipe(id, payload) {
|
||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@ -69,14 +61,9 @@ export async function updateRecipe(id, payload, token) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteRecipe(id, token) {
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
export async function deleteRecipe(id) {
|
||||
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
throw new Error("Failed to delete recipe");
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB |
@ -1,103 +0,0 @@
|
||||
// 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");
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@ -1,177 +0,0 @@
|
||||
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;
|
||||
@ -1,49 +0,0 @@
|
||||
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;
|
||||
@ -1,176 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,181 +0,0 @@
|
||||
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, children }) {
|
||||
function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מחק", cancelText = "ביטול", isDangerous = false }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@ -8,9 +8,8 @@ function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מ
|
||||
<h2>{title}</h2>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
{children || message}
|
||||
{message}
|
||||
</div>
|
||||
{!children && (
|
||||
<footer className="modal-footer">
|
||||
<button className="btn ghost" onClick={onCancel}>
|
||||
{cancelText}
|
||||
@ -22,7 +21,6 @@ function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מ
|
||||
{confirmText}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,229 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@ -1,202 +0,0 @@
|
||||
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;
|
||||
@ -1,222 +0,0 @@
|
||||
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,19 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import placeholderLight from "../assets/placeholder-light.png";
|
||||
import placeholderDark from "../assets/placeholder-dark.png";
|
||||
import placeholderImage from "../assets/placeholder.svg";
|
||||
|
||||
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;
|
||||
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) {
|
||||
if (!recipe) {
|
||||
return (
|
||||
<section className="panel placeholder">
|
||||
@ -26,15 +13,6 @@ 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 */}
|
||||
@ -48,8 +26,8 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
|
||||
<p className="recipe-subtitle">
|
||||
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
||||
</p>
|
||||
{(recipe.made_by || recipe.owner_display_name) && (
|
||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by || recipe.owner_display_name}</h4>
|
||||
{recipe.made_by && (
|
||||
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4>
|
||||
)}
|
||||
</div>
|
||||
<div className="pill-row">
|
||||
@ -88,7 +66,6 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
|
||||
</footer>
|
||||
)}
|
||||
|
||||
{isAuthenticated && currentUser && (Number(recipe.user_id) === Number(currentUser.id) || currentUser.is_admin) && (
|
||||
<div className="recipe-actions">
|
||||
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||
✏️ ערוך
|
||||
@ -97,7 +74,6 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal,
|
||||
🗑 מחק
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -111,7 +87,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,7 +1,6 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import AutocompleteInput from "./AutocompleteInput";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, currentUser = null, allRecipes = [] }) {
|
||||
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||
const [name, setName] = useState("");
|
||||
const [mealType, setMealType] = useState("lunch");
|
||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||
@ -12,19 +11,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
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(() => {
|
||||
@ -34,7 +20,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
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 || [""]);
|
||||
@ -42,23 +28,19 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
setName("");
|
||||
setMealType("lunch");
|
||||
setTimeMinutes(15);
|
||||
setMadeBy(currentUser?.username || "");
|
||||
setMadeBy("");
|
||||
setTags("");
|
||||
setImage("");
|
||||
setIngredients([""]);
|
||||
setSteps([""]);
|
||||
}
|
||||
}
|
||||
}, [open, editingRecipe, isEditMode, currentUser]);
|
||||
}, [open, editingRecipe, isEditMode]);
|
||||
|
||||
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) => {
|
||||
@ -71,10 +53,6 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
|
||||
const handleAddStep = () => {
|
||||
setSteps((prev) => [...prev, ""]);
|
||||
setTimeout(() => {
|
||||
lastStepRef.current?.focus();
|
||||
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleChangeStep = (idx, value) => {
|
||||
@ -106,7 +84,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
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);
|
||||
|
||||
@ -117,9 +95,12 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
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;
|
||||
}
|
||||
@ -155,7 +136,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
<option value="breakfast">בוקר</option>
|
||||
<option value="lunch">צהריים</option>
|
||||
<option value="dinner">ערב</option>
|
||||
<option value="snack">קינוחים</option>
|
||||
<option value="snack">נשנוש</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -173,10 +154,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
|
||||
<div className="field">
|
||||
<label>המתכון של:</label>
|
||||
<AutocompleteInput
|
||||
<input
|
||||
value={madeBy}
|
||||
onChange={(e) => setMadeBy(e.target.value)}
|
||||
suggestions={uniqueMadeBy}
|
||||
placeholder="שם האדם שיצר את הגרסה הזו..."
|
||||
/>
|
||||
</div>
|
||||
@ -211,11 +191,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>תגיות (מופרד ברווחים)</label>
|
||||
<label>תגיות (מופרד בפסיקים)</label>
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="מהיר טבעוני משפחתי..."
|
||||
placeholder="מהיר, טבעוני, משפחתי..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -224,17 +204,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
<div className="dynamic-list">
|
||||
{ingredients.map((val, idx) => (
|
||||
<div key={idx} className="dynamic-row">
|
||||
<AutocompleteInput
|
||||
inputRef={idx === ingredients.length - 1 ? lastIngredientRef : null}
|
||||
<input
|
||||
value={val}
|
||||
onChange={(e) => handleChangeIngredient(idx, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddIngredient();
|
||||
}
|
||||
}}
|
||||
suggestions={uniqueIngredients}
|
||||
placeholder="למשל: 2 ביצים"
|
||||
/>
|
||||
<button
|
||||
@ -262,15 +234,8 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, curre
|
||||
{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,6 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import placeholderLight from "../assets/placeholder-light.png";
|
||||
import placeholderDark from "../assets/placeholder-dark.png";
|
||||
import { useState } from "react";
|
||||
import placeholderImage from "../assets/placeholder.svg";
|
||||
|
||||
function RecipeSearchList({
|
||||
allRecipes,
|
||||
@ -15,21 +14,10 @@ function RecipeSearchList({
|
||||
onMaxTimeChange,
|
||||
filterTags,
|
||||
onTagsChange,
|
||||
filterOwner,
|
||||
onOwnerChange,
|
||||
filterMadeBy,
|
||||
onMadeByChange,
|
||||
}) {
|
||||
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(
|
||||
@ -39,19 +27,8 @@ 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 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 unique made_by from ALL recipes (not filtered)
|
||||
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).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);
|
||||
@ -70,10 +47,10 @@ function RecipeSearchList({
|
||||
onMealTypeChange("");
|
||||
onMaxTimeChange("");
|
||||
onTagsChange([]);
|
||||
onOwnerChange("");
|
||||
onMadeByChange("");
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterOwner;
|
||||
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy;
|
||||
|
||||
return (
|
||||
<section className="panel secondary recipe-search-list">
|
||||
@ -188,18 +165,18 @@ function RecipeSearchList({
|
||||
<label className="filter-label">המתכונים של:</label>
|
||||
<div className="filter-options">
|
||||
<button
|
||||
className={`filter-btn ${filterOwner === "" ? "active" : ""}`}
|
||||
onClick={() => onOwnerChange("")}
|
||||
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`}
|
||||
onClick={() => onMadeByChange("")}
|
||||
>
|
||||
הכל
|
||||
</button>
|
||||
{allMadeBy.map((madeBy) => (
|
||||
{allMadeBy.map((person) => (
|
||||
<button
|
||||
key={madeBy}
|
||||
className={`filter-btn ${filterOwner === madeBy ? "active" : ""}`}
|
||||
onClick={() => onOwnerChange(madeBy)}
|
||||
key={person}
|
||||
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`}
|
||||
onClick={() => onMadeByChange(person)}
|
||||
>
|
||||
{madeByMap.get(madeBy) || madeBy}
|
||||
{person}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -262,7 +239,7 @@ function translateMealType(type) {
|
||||
case "dinner":
|
||||
return "ערב";
|
||||
case "snack":
|
||||
return "קינוחים";
|
||||
return "נשנוש";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
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;
|
||||
@ -1,108 +0,0 @@
|
||||
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,6 +1,4 @@
|
||||
import NotificationBell from "./NotificationBell";
|
||||
|
||||
function TopBar({ onAddClick, user, onLogout, onShowToast, onNotificationClick, onAdminClick }) {
|
||||
function TopBar({ onAddClick }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
@ -13,26 +11,10 @@ function TopBar({ onAddClick, user, onLogout, onShowToast, onNotificationClick,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<button className="btn primary" onClick={onAddClick}>
|
||||
+ מתכון חדש
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
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();
|
||||
};
|
||||
@ -1,69 +0,0 @@
|
||||
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,8 +5,4 @@ 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