Compare commits

..

38 Commits

Author SHA1 Message Date
3d0bfbca2c Add DB Backup 2025-12-21 03:43:37 +02:00
d159cadacc Add feature to shared lists 2025-12-21 02:46:36 +02:00
c65cce9de7 Add auto complete 2025-12-20 23:26:35 +02:00
013d5692bf Add auto complete 2025-12-20 23:02:47 +02:00
9f781d784d Fix bugs 2025-12-20 22:51:57 +02:00
f2674c379c Add logo 2025-12-19 16:25:57 +02:00
653e4f0ea0 Add logo 2025-12-19 16:17:17 +02:00
3270788902 Adapt to mobile, handle forget password and add auth_provider to DB 2025-12-19 04:13:20 +02:00
70f8ce1a6b Remove temporary microsoft login 2025-12-17 05:45:01 +02:00
1d04352ed7 Add sitemap 2025-12-16 21:52:18 +02:00
2fcbcaa302 Add google verfication to index.html 2025-12-16 21:40:17 +02:00
a78053474b Set to work with prod env 2025-12-14 14:17:07 +02:00
ae9349ca7e Set to work with prod env 2025-12-14 14:08:52 +02:00
0afe014947 Set to work with prod env 2025-12-14 14:02:32 +02:00
dvirlabs
1da5dc0a30 Add google authentication 2025-12-14 13:44:00 +02:00
ba7d0c9121 Merge pull request 'test' (#3) from test into develop
Reviewed-on: #3
2025-12-14 04:05:38 +00:00
dvirlabs
b70411e1f1 Add google authenticate 2025-12-14 06:04:25 +02:00
01369a743d try to fix cahce problem 2025-12-14 02:24:37 +02:00
7c6703354e Fix production API URL to use window.__ENV__.API_BASE 2025-12-11 16:08:38 +02:00
080977cdb7 Add grocery-lists 2025-12-11 16:03:06 +02:00
df7510da2e Add grocery-lists 2025-12-11 15:51:54 +02:00
e1515442f4 Add groceries list and notifications 2025-12-11 05:21:06 +02:00
dvirlabs
6d5b8f2314 Fix pics size 2025-12-08 17:38:01 +02:00
dvirlabs
5841e7b9d4 Set the pics to the same size 2025-12-08 16:19:06 +02:00
dvirlabs
b2877877dd move the header 2025-12-08 16:10:20 +02:00
dvirlabs
0f3aa43b89 Update backend to setup admin 2025-12-08 15:04:37 +02:00
dvirlabs
e0b3102007 Update backend to setup admin 2025-12-08 14:54:27 +02:00
dvirlabs
a5d87b8e25 Add permission to admin 2025-12-08 14:46:34 +02:00
dvirlabs
22639a489a Fix cut off register page 2025-12-08 10:35:08 +02:00
8d81d16682 Add admin user 2025-12-08 09:24:56 +02:00
fa5ba578bb Mapping made_by to displayname 2025-12-08 09:08:32 +02:00
53ca792988 Style register and sign in and create confirmation when sign out 2025-12-08 08:12:35 +02:00
e160357256 Update schema.sql 2025-12-08 07:33:56 +02:00
c912663c3d Update requierments.txt 2025-12-08 07:13:16 +02:00
66d2aa0a66 Update index.html 2025-12-08 07:09:14 +02:00
1d33e52100 Manage users 2025-12-08 07:04:50 +02:00
b35100c92f Check frontend tag 2025-12-08 00:40:33 +02:00
81acc68aaa Fix scroll on add new recipe and change meal type 2025-12-07 22:14:45 +02:00
77 changed files with 9679 additions and 194 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
node_modules/ node_modules/
my-recipes/
my-recipes-chart/

View File

@ -63,7 +63,7 @@ steps:
- | - |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}" TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
echo "💡 Setting frontend tag to: $TAG" echo "💡 Setting frontend tag to: $TAG"
yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml yq -i ".frontend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
git add manifests/${CI_REPO_NAME}/values.yaml git add manifests/${CI_REPO_NAME}/values.yaml
git commit -m "frontend: update tag to $TAG" || echo "No changes" git commit -m "frontend: update tag to $TAG" || echo "No changes"
git push origin HEAD git push origin HEAD
@ -93,7 +93,7 @@ steps:
- | - |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}" TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
echo "💡 Setting backend tag to: $TAG" echo "💡 Setting backend tag to: $TAG"
yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml yq -i ".backend.image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
git add manifests/${CI_REPO_NAME}/values.yaml git add manifests/${CI_REPO_NAME}/values.yaml
git commit -m "backend: update tag to $TAG" || echo "No changes" git commit -m "backend: update tag to $TAG" || echo "No changes"
git push origin HEAD git push origin HEAD

View File

@ -4,3 +4,28 @@ DB_USER=recipes_user
DB_NAME=recipes_db DB_NAME=recipes_db
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=dvirlabs@gmail.com
SMTP_PASSWORD=agaanrhbbazbdytv
SMTP_FROM=dvirlabs@gmail.com
# Google OAuth
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
FRONTEND_URL=http://localhost:5174
# Microsoft Entra ID (Azure AD) OAuth
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
AZURE_TENANT_ID=consumers
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback
# Cloudflare R2 Backup Configuration
R2_ENDPOINT=https://d4704b8c40b2f95b2c7bf7ee4ecc52f8.r2.cloudflarestorage.com
R2_ACCESS_KEY=1997b1e48a337c0dbe1f7552a08631b5
R2_SECRET_KEY=369694e39fedfedb254158c147171f5760de84fa2346d5d5d5a961f1f517dbc6
R2_BUCKET=my-recipes-db-bkp

28
backend/.env.local Normal file
View File

@ -0,0 +1,28 @@
DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db
DB_PASSWORD=Aa123456
DB_USER=recipes_user
DB_NAME=recipes_db
DB_HOST=localhost
DB_PORT=5432
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=dvirlabs@gmail.com
SMTP_PASSWORD=agaanrhbbazbdytv
SMTP_FROM=dvirlabs@gmail.com
# Secret Key for sessions (OAuth state token)
SECRET_KEY=your-super-secret-key-min-32-chars-dev-only-change-in-prod
# Google OAuth (LOCAL - localhost redirect)
GOOGLE_CLIENT_ID=143092846986-hsi59m0on2c9rb5qrdoejfceieao2ioc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
FRONTEND_URL=http://localhost:5174
# Microsoft Entra ID (Azure AD) OAuth
AZURE_CLIENT_ID=db244cf5-eb11-4738-a2ea-5b0716c9ec0a
AZURE_CLIENT_SECRET=Zad8Q~qRBxaQq8up0lLXAq4pHzrVM2JFGFJhHaDp
AZURE_TENANT_ID=consumers
AZURE_REDIRECT_URI=http://localhost:8000/auth/azure/callback

1
backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__/

93
backend/BACKUP_README.md Normal file
View File

@ -0,0 +1,93 @@
# Database Backup & Restore Scripts
## Overview
Automated database backup system that exports PostgreSQL database, compresses it with gzip, and uploads to Cloudflare R2 storage.
## Requirements
```bash
pip install boto3
```
## Configuration
All configuration is stored in `.env` file:
- `R2_ENDPOINT`: Cloudflare R2 endpoint URL
- `R2_ACCESS_KEY`: R2 API access key
- `R2_SECRET_KEY`: R2 API secret key
- `R2_BUCKET`: R2 bucket name
- Database credentials (DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD)
## Usage
### Create Backup
```bash
cd backend
python backup_db.py
```
This will:
1. Export the database using `pg_dump`
2. Compress the dump with gzip (typically 80-90% reduction)
3. Upload to R2 with timestamp
4. List all backups in R2
5. Clean up old local backups (keeps last 3)
### Restore from Backup
```bash
cd backend
python restore_db.py
```
This will:
1. List all available backups in R2
2. Let you select which backup to restore
3. Download and decompress the backup
4. Restore to the database (with confirmation prompt)
**WARNING**: Restore will drop all existing tables and recreate them from backup!
## Automated Backups
### Linux/Mac (Cron)
Add to crontab:
```bash
# Daily backup at 2 AM
0 2 * * * cd /path/to/backend && python backup_db.py >> backup.log 2>&1
```
### Windows (Task Scheduler)
Create a scheduled task:
1. Open Task Scheduler
2. Create Basic Task
3. Name: "Recipe DB Backup"
4. Trigger: Daily at 2:00 AM
5. Action: Start a program
- Program: `python`
- Arguments: `backup_db.py`
- Start in: `C:\path\to\backend`
## Backup File Format
Files are named: `recipes_db_YYYYMMDD_HHMMSS.sql.gz`
Example: `recipes_db_20251221_140530.sql.gz`
## Storage
- Local backups stored in: `backend/backups/`
- R2 backups stored in: `my-recipes-db-bkp` bucket
- Local backups auto-cleanup (keeps last 3)
- R2 backups are never auto-deleted (manual cleanup if needed)
## Troubleshooting
### pg_dump not found
Install PostgreSQL client tools:
- **Windows**: Install PostgreSQL and add to PATH
- **Linux**: `sudo apt install postgresql-client`
- **Mac**: `brew install postgresql`
### Connection errors
Verify database credentials in `.env` file
### R2 upload errors
- Check R2 credentials
- Verify bucket exists
- Ensure API token has "Edit" permissions

View File

@ -0,0 +1,164 @@
# Database Backup System - Complete Setup
## ✅ What's Been Implemented
### 1. **Backend API Endpoints** (Admin Only)
- `POST /admin/backup` - Trigger manual backup
- `GET /admin/backups` - List all available backups
- `POST /admin/restore?filename=<name>` - Restore from backup
### 2. **Frontend Admin Panel**
- New "ניהול" (Management) tab in navigation (visible to admin users only)
- 🛡️ Admin button in top bar
- Full backup management interface:
- Create new backups instantly
- View all backups with dates and sizes
- Restore from any backup with confirmation
### 3. **Automated Weekly Backups**
- Batch script: `run_backup.bat`
- Full setup guide: `WEEKLY_BACKUP_SETUP.md`
- Configured for Windows Task Scheduler
## 🚀 How to Use
### **Manual Backup (Admin User)**
1. Login with admin account
2. Click 🛡️ "ניהול" button in top bar (or use the ניהול tab)
3. Click "צור גיבוי חדש" (Create New Backup)
4. Backup is created, compressed, and uploaded to R2
5. See confirmation toast: "גיבוי נוצר בהצלחה! 📦"
### **Restore from Backup (Admin User)**
1. Go to Admin Panel (🛡️ ניהול)
2. View all available backups in the table
3. Click "שחזר" (Restore) button for desired backup
4. Confirm the warning (this will delete current data!)
5. Page will refresh automatically after restore
### **Setup Weekly Automatic Backups**
Follow the instructions in `WEEKLY_BACKUP_SETUP.md`:
**Quick Steps:**
1. Open Task Scheduler (`Win + R``taskschd.msc`)
2. Create Task → "Recipe DB Weekly Backup"
3. Set trigger: Weekly, Sunday, 2:00 AM
4. Set action: Run `C:\Path\To\backend\run_backup.bat`
5. Configure to run even when not logged in
## 📁 Files Created/Modified
### Backend
- ✅ `backup_restore_api.py` - Core backup/restore functions
- ✅ `main.py` - Added admin endpoints
- ✅ `requirements.txt` - Added boto3 dependency
- ✅ `.env` - Added R2 credentials
- ✅ `run_backup.bat` - Windows batch script for scheduled tasks
- ✅ `BACKUP_README.md` - Complete documentation
- ✅ `WEEKLY_BACKUP_SETUP.md` - Task Scheduler setup guide
### Frontend
- ✅ `backupApi.js` - API calls for backup operations
- ✅ `components/AdminPanel.jsx` - Admin UI component
- ✅ `components/TopBar.jsx` - Added admin button
- ✅ `App.jsx` - Added admin view and navigation
- ✅ `App.css` - Added admin panel styles
## 🔐 Security
- **Admin-only access**: All backup endpoints check `is_admin` flag
- **Non-admin users**: Cannot see the admin button or access backup endpoints
- **403 Forbidden**: Returned if non-admin tries to access admin endpoints
## 💾 Backup Details
### What's Backed Up
- Complete PostgreSQL database (recipes_db)
- All tables: users, recipes, grocery lists, shares, notifications
### Backup Process
1. Uses `pg_dump` to export database
2. Compresses with gzip (typically 80-90% size reduction)
3. Uploads to Cloudflare R2 with timestamp
4. Filename format: `recipes_db_YYYYMMDD_HHMMSS.sql.gz`
5. Local backups auto-cleanup (keeps last 3)
### Restore Process
1. Downloads from R2
2. Decompresses file
3. **Drops all existing tables** (CASCADE)
4. Restores from SQL file
5. Cleans up temporary files
## 🧪 Testing
### Test Manual Backup
```bash
cd backend
python backup_db.py
```
### Test Manual Restore
```bash
cd backend
python restore_db.py
```
### Test via Web UI
1. Login as admin
2. Navigate to Admin Panel
3. Click "צור גיבוי חדש"
4. Check R2 bucket for new file
## ⚠️ Important Notes
1. **Restore is destructive**: It deletes ALL current data
2. **Admin access required**: Set user's `is_admin = true` in database
3. **R2 credentials**: Already configured in `.env`
4. **Weekly backups**: Manual setup required (follow WEEKLY_BACKUP_SETUP.md)
5. **PostgreSQL tools**: Must have `pg_dump` and `psql` in system PATH
## 🔧 Troubleshooting
### "Admin access required" error
- Check if user has `is_admin = true` in database
- Run: `SELECT username, is_admin FROM users;` in psql
### Backup fails
- Check `backend/backup.log` for errors
- Verify R2 credentials in `.env`
- Verify database credentials in `.env`
- Test: `python backup_db.py` manually
### Can't see admin button
- Verify user's `is_admin` flag in database
- Refresh page after changing admin status
- Check browser console for errors
### Scheduled backup doesn't run
- Check Task Scheduler → Task History
- Verify `run_backup.bat` path is correct
- Check `backend/backup.log` for errors
- Test batch file manually first
## 📊 What Admins Can Do
✅ Create manual backups anytime
✅ View all backups with dates and sizes
✅ Restore from any backup point
✅ See backup history in table format
✅ All regular user features (recipes, grocery lists, etc.)
## Next Steps
1. **✅ Test the system**: Create a manual backup from Admin Panel
2. **📅 Setup weekly backups**: Follow WEEKLY_BACKUP_SETUP.md
3. **🔒 Secure admin access**: Only give admin rights to trusted users
4. **📝 Document your backup strategy**: When/how often you back up
---
**Your database is now protected with automated backups! 🎉**

View File

@ -0,0 +1,21 @@
# Database Migration Instructions
## Add auth_provider column to users table
Run this command in your backend directory:
```bash
# Windows (PowerShell)
$env:PGPASSWORD="recipes_password"; psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
# Or using psql directly
psql -h localhost -U recipes_user -d recipes_db -f add_auth_provider_column.sql
```
This will:
1. Add the `auth_provider` column to the users table (default: 'local')
2. Update all existing users to have 'local' as their auth_provider
3. Create an index for faster lookups
4. Display the updated table structure
After running the migration, restart your backend server.

View File

@ -0,0 +1,131 @@
# Weekly Backup Setup - Windows Task Scheduler
This guide will help you set up automatic weekly backups of your database.
## Setup Instructions
### 1. Create Batch Script
Create a file `run_backup.bat` in the `backend` folder:
```batch
@echo off
cd /d "%~dp0"
python backup_db.py >> backup.log 2>&1
```
### 2. Open Task Scheduler
1. Press `Win + R`
2. Type `taskschd.msc`
3. Press Enter
### 3. Create New Task
1. Click "Create Task" (not "Create Basic Task")
2. In **General** tab:
- Name: `Recipe DB Weekly Backup`
- Description: `Automatic weekly database backup to Cloudflare R2`
- Select "Run whether user is logged on or not"
- Check "Run with highest privileges"
### 4. Configure Trigger
1. Go to **Triggers** tab
2. Click "New..."
3. Configure:
- Begin the task: `On a schedule`
- Settings: `Weekly`
- Recur every: `1 weeks`
- Days: Select `Sunday` (or your preferred day)
- Time: `02:00:00` (2 AM)
- Check "Enabled"
4. Click OK
### 5. Configure Action
1. Go to **Actions** tab
2. Click "New..."
3. Configure:
- Action: `Start a program`
- Program/script: `C:\Path\To\backend\run_backup.bat`
*(Replace with your actual path)*
- Start in: `C:\Path\To\backend\`
*(Replace with your actual path)*
4. Click OK
### 6. Additional Settings
1. Go to **Conditions** tab:
- Uncheck "Start the task only if the computer is on AC power"
2. Go to **Settings** tab:
- Check "Run task as soon as possible after a scheduled start is missed"
- If the task fails, restart every: `10 minutes`
- Attempt to restart up to: `3 times`
3. Click OK
### 7. Enter Password
- You'll be prompted to enter your Windows password
- This allows the task to run even when you're not logged in
## Verify Setup
### Test the Task
1. In Task Scheduler, find your task
2. Right-click → "Run"
3. Check `backend/backup.log` for results
### View Scheduled Runs
- In Task Scheduler, select your task
- Check the "History" tab to see past runs
## Troubleshooting
### Task doesn't run
- Check Task Scheduler → Task History for errors
- Verify Python is in system PATH
- Try running `run_backup.bat` manually first
### No log file created
- Check file permissions in backend folder
- Verify the "Start in" path is correct
### Backup fails
- Check `backend/backup.log` for error messages
- Verify database credentials in `.env`
- Verify R2 credentials in `.env`
- Test by running `python backup_db.py` manually
## Change Backup Schedule
1. Open Task Scheduler
2. Find "Recipe DB Weekly Backup"
3. Right-click → Properties
4. Go to Triggers tab
5. Edit the trigger to change day/time
6. Click OK
## Disable Automatic Backups
1. Open Task Scheduler
2. Find "Recipe DB Weekly Backup"
3. Right-click → Disable
## View Backup Log
Check `backend/backup.log` to see backup history:
```batch
cd backend
type backup.log
```
Or open it in Notepad.

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.

View File

@ -0,0 +1,30 @@
-- Add auth_provider column to users table
-- This tracks whether the user is local or uses OAuth (google, microsoft, etc.)
-- Add the column if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name='users' AND column_name='auth_provider'
) THEN
ALTER TABLE users ADD COLUMN auth_provider VARCHAR(50) DEFAULT 'local' NOT NULL;
END IF;
END $$;
-- Update existing users to have 'local' as their auth_provider
UPDATE users SET auth_provider = 'local' WHERE auth_provider IS NULL;
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_users_auth_provider ON users(auth_provider);
-- Display the updated users table structure
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'users'
ORDER BY ordinal_position;

View File

@ -0,0 +1,5 @@
-- Add is_pinned column to grocery_lists table
ALTER TABLE grocery_lists ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE;
-- Verify the column was added
\d grocery_lists

96
backend/auth_utils.py Normal file
View File

@ -0,0 +1,96 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import os
# Secret key for JWT (use environment variable in production)
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
security = HTTPBearer()
def hash_password(password: str) -> str:
"""Hash a password for storing."""
# Bcrypt has a 72 byte limit, truncate if necessary
password_bytes = password.encode('utf-8')[:72]
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a stored password against one provided by user"""
# Bcrypt has a 72 byte limit, truncate if necessary
password_bytes = plain_password.encode('utf-8')[:72]
hashed_bytes = hashed_password.encode('utf-8')
return bcrypt.checkpw(password_bytes, hashed_bytes)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create JWT access token"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> dict:
"""Decode and verify JWT token"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""Get current user from JWT token (for protected routes)"""
from user_db_utils import get_user_by_id
token = credentials.credentials
payload = decode_token(token)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
# Get full user info from database to include is_admin
user = get_user_by_id(int(user_id))
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return {
"user_id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"is_admin": user.get("is_admin", False)
}
# Optional dependency - returns None if no token provided
def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[dict]:
"""Get current user if authenticated, otherwise None"""
if not credentials:
return None
try:
return get_current_user(credentials)
except HTTPException:
return None

209
backend/backup_db.py Normal file
View File

@ -0,0 +1,209 @@
"""
Database backup script for R2 storage
Exports PostgreSQL database, compresses it, and uploads to Cloudflare R2
"""
import os
import subprocess
import gzip
import shutil
from datetime import datetime
from pathlib import Path
import boto3
from botocore.config import Config
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# R2 Configuration
R2_ENDPOINT = os.getenv("R2_ENDPOINT")
R2_ACCESS_KEY = os.getenv("R2_ACCESS_KEY")
R2_SECRET_KEY = os.getenv("R2_SECRET_KEY")
R2_BUCKET = os.getenv("R2_BUCKET")
# Database Configuration
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "recipes_db")
DB_USER = os.getenv("DB_USER", "recipes_user")
DB_PASSWORD = os.getenv("DB_PASSWORD", "recipes_password")
# Backup directory
BACKUP_DIR = Path(__file__).parent / "backups"
BACKUP_DIR.mkdir(exist_ok=True)
def create_db_dump():
"""Create PostgreSQL database dump"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dump_file = BACKUP_DIR / f"recipes_db_{timestamp}.sql"
print(f"Creating database dump: {dump_file}")
# Set PGPASSWORD environment variable for pg_dump
env = os.environ.copy()
env['PGPASSWORD'] = DB_PASSWORD
# Run pg_dump
cmd = [
"pg_dump",
"-h", DB_HOST,
"-p", DB_PORT,
"-U", DB_USER,
"-d", DB_NAME,
"-f", str(dump_file),
"--no-owner", # Don't include ownership commands
"--no-acl", # Don't include access privileges
]
try:
subprocess.run(cmd, env=env, check=True, capture_output=True, text=True)
print(f"✓ Database dump created: {dump_file}")
return dump_file
except subprocess.CalledProcessError as e:
print(f"✗ Error creating database dump: {e.stderr}")
raise
def compress_file(file_path):
"""Compress file using gzip"""
compressed_file = Path(str(file_path) + ".gz")
print(f"Compressing {file_path.name}...")
with open(file_path, 'rb') as f_in:
with gzip.open(compressed_file, 'wb', compresslevel=9) as f_out:
shutil.copyfileobj(f_in, f_out)
# Remove uncompressed file
file_path.unlink()
# Get compression ratio
original_size = file_path.stat().st_size if file_path.exists() else 0
compressed_size = compressed_file.stat().st_size
ratio = (1 - compressed_size / max(original_size, 1)) * 100 if original_size > 0 else 0
print(f"✓ Compressed to {compressed_file.name}")
print(f" Original: {original_size / 1024:.2f} KB")
print(f" Compressed: {compressed_size / 1024:.2f} KB")
print(f" Ratio: {ratio:.1f}% reduction")
return compressed_file
def upload_to_r2(file_path):
"""Upload file to Cloudflare R2"""
print(f"Uploading {file_path.name} to R2...")
# Configure S3 client for R2
s3_client = boto3.client(
's3',
endpoint_url=R2_ENDPOINT,
aws_access_key_id=R2_ACCESS_KEY,
aws_secret_access_key=R2_SECRET_KEY,
config=Config(signature_version='s3v4'),
region_name='auto'
)
# Upload file
try:
s3_client.upload_file(
str(file_path),
R2_BUCKET,
file_path.name,
ExtraArgs={
'Metadata': {
'backup-date': datetime.now().isoformat(),
'db-name': DB_NAME,
}
}
)
print(f"✓ Uploaded to R2: s3://{R2_BUCKET}/{file_path.name}")
return True
except Exception as e:
print(f"✗ Error uploading to R2: {e}")
raise
def list_r2_backups():
"""List all backups in R2 bucket"""
print(f"\nListing backups in R2 bucket: {R2_BUCKET}")
s3_client = boto3.client(
's3',
endpoint_url=R2_ENDPOINT,
aws_access_key_id=R2_ACCESS_KEY,
aws_secret_access_key=R2_SECRET_KEY,
config=Config(signature_version='s3v4'),
region_name='auto'
)
try:
response = s3_client.list_objects_v2(Bucket=R2_BUCKET)
if 'Contents' not in response:
print("No backups found")
return
print(f"\nFound {len(response['Contents'])} backup(s):")
for obj in sorted(response['Contents'], key=lambda x: x['LastModified'], reverse=True):
size_mb = obj['Size'] / (1024 * 1024)
print(f" - {obj['Key']}")
print(f" Size: {size_mb:.2f} MB")
print(f" Date: {obj['LastModified']}")
except Exception as e:
print(f"✗ Error listing backups: {e}")
def cleanup_old_local_backups(keep_last=3):
"""Keep only the last N local backups"""
backups = sorted(BACKUP_DIR.glob("*.sql.gz"), key=lambda x: x.stat().st_mtime, reverse=True)
if len(backups) > keep_last:
print(f"\nCleaning up old local backups (keeping last {keep_last})...")
for backup in backups[keep_last:]:
print(f" Removing: {backup.name}")
backup.unlink()
def main():
"""Main backup process"""
print("=" * 60)
print("Database Backup to Cloudflare R2")
print("=" * 60)
print()
try:
# Verify R2 credentials
if not all([R2_ENDPOINT, R2_ACCESS_KEY, R2_SECRET_KEY, R2_BUCKET]):
raise ValueError("Missing R2 credentials in environment variables")
# Create database dump
dump_file = create_db_dump()
# Compress the dump
compressed_file = compress_file(dump_file)
# Upload to R2
upload_to_r2(compressed_file)
# List all backups
list_r2_backups()
# Cleanup old local backups
cleanup_old_local_backups(keep_last=3)
print("\n" + "=" * 60)
print("✓ Backup completed successfully!")
print("=" * 60)
except Exception as e:
print("\n" + "=" * 60)
print(f"✗ Backup failed: {e}")
print("=" * 60)
raise
if __name__ == "__main__":
main()

View File

@ -0,0 +1,245 @@
"""
Backup and Restore API endpoints for database management.
Admin-only access required.
"""
import os
import subprocess
import gzip
import shutil
from datetime import datetime
from typing import List
import boto3
from botocore.exceptions import ClientError
from dotenv import load_dotenv
load_dotenv()
def get_r2_client():
"""Get configured R2 client"""
return boto3.client(
's3',
endpoint_url=os.getenv('R2_ENDPOINT'),
aws_access_key_id=os.getenv('R2_ACCESS_KEY'),
aws_secret_access_key=os.getenv('R2_SECRET_KEY'),
region_name='auto'
)
def create_db_dump() -> str:
"""Create a database dump file"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_dir = os.path.join(os.path.dirname(__file__), 'backups')
os.makedirs(backup_dir, exist_ok=True)
dump_file = os.path.join(backup_dir, f'recipes_db_{timestamp}.sql')
db_host = os.getenv('DB_HOST', 'localhost')
db_port = os.getenv('DB_PORT', '5432')
db_name = os.getenv('DB_NAME', 'recipes_db')
db_user = os.getenv('DB_USER', 'postgres')
db_password = os.getenv('DB_PASSWORD', 'postgres')
env = os.environ.copy()
env['PGPASSWORD'] = db_password
cmd = [
'pg_dump',
'-h', db_host,
'-p', db_port,
'-U', db_user,
'-d', db_name,
'--no-owner',
'--no-acl',
'-f', dump_file
]
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
raise Exception(f"pg_dump failed: {result.stderr}")
return dump_file
def compress_file(file_path: str) -> str:
"""Compress a file with gzip"""
compressed_path = f"{file_path}.gz"
with open(file_path, 'rb') as f_in:
with gzip.open(compressed_path, 'wb', compresslevel=9) as f_out:
shutil.copyfileobj(f_in, f_out)
os.remove(file_path)
return compressed_path
def upload_to_r2(file_path: str) -> str:
"""Upload file to R2"""
s3_client = get_r2_client()
bucket_name = os.getenv('R2_BUCKET')
file_name = os.path.basename(file_path)
try:
s3_client.upload_file(file_path, bucket_name, file_name)
return file_name
except ClientError as e:
raise Exception(f"R2 upload failed: {str(e)}")
def list_r2_backups() -> List[dict]:
"""List all backups in R2"""
s3_client = get_r2_client()
bucket_name = os.getenv('R2_BUCKET')
try:
response = s3_client.list_objects_v2(Bucket=bucket_name)
if 'Contents' not in response:
return []
backups = []
for obj in response['Contents']:
backups.append({
'filename': obj['Key'],
'size': obj['Size'],
'last_modified': obj['LastModified'].isoformat()
})
backups.sort(key=lambda x: x['last_modified'], reverse=True)
return backups
except ClientError as e:
raise Exception(f"Failed to list R2 backups: {str(e)}")
def download_from_r2(filename: str) -> str:
"""Download a backup from R2"""
s3_client = get_r2_client()
bucket_name = os.getenv('R2_BUCKET')
backup_dir = os.path.join(os.path.dirname(__file__), 'backups')
os.makedirs(backup_dir, exist_ok=True)
local_path = os.path.join(backup_dir, filename)
try:
s3_client.download_file(bucket_name, filename, local_path)
return local_path
except ClientError as e:
raise Exception(f"R2 download failed: {str(e)}")
def decompress_file(compressed_path: str) -> str:
"""Decompress a gzipped file"""
if not compressed_path.endswith('.gz'):
raise ValueError("File must be gzipped (.gz)")
decompressed_path = compressed_path[:-3]
with gzip.open(compressed_path, 'rb') as f_in:
with open(decompressed_path, 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
return decompressed_path
def restore_database(sql_file: str) -> None:
"""Restore database from SQL file"""
db_host = os.getenv('DB_HOST', 'localhost')
db_port = os.getenv('DB_PORT', '5432')
db_name = os.getenv('DB_NAME', 'recipes_db')
db_user = os.getenv('DB_USER', 'postgres')
db_password = os.getenv('DB_PASSWORD', 'postgres')
env = os.environ.copy()
env['PGPASSWORD'] = db_password
# Drop all tables first
drop_cmd = [
'psql',
'-h', db_host,
'-p', db_port,
'-U', db_user,
'-d', db_name,
'-c', 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
]
drop_result = subprocess.run(drop_cmd, env=env, capture_output=True, text=True)
if drop_result.returncode != 0:
raise Exception(f"Failed to drop schema: {drop_result.stderr}")
# Restore from backup
restore_cmd = [
'psql',
'-h', db_host,
'-p', db_port,
'-U', db_user,
'-d', db_name,
'-f', sql_file
]
restore_result = subprocess.run(restore_cmd, env=env, capture_output=True, text=True)
if restore_result.returncode != 0:
raise Exception(f"Database restore failed: {restore_result.stderr}")
def perform_backup() -> dict:
"""Perform complete backup process"""
try:
# Create dump
dump_file = create_db_dump()
# Compress
compressed_file = compress_file(dump_file)
# Upload to R2
r2_filename = upload_to_r2(compressed_file)
# Get file size
file_size = os.path.getsize(compressed_file)
# Clean up local file
os.remove(compressed_file)
return {
'success': True,
'filename': r2_filename,
'size': file_size,
'timestamp': datetime.now().isoformat()
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def perform_restore(filename: str) -> dict:
"""Perform complete restore process"""
try:
# Download from R2
compressed_file = download_from_r2(filename)
# Decompress
sql_file = decompress_file(compressed_file)
# Restore database
restore_database(sql_file)
# Clean up
os.remove(compressed_file)
os.remove(sql_file)
return {
'success': True,
'filename': filename,
'timestamp': datetime.now().isoformat()
}
except Exception as e:
return {
'success': False,
'error': str(e)
}

View File

@ -54,10 +54,12 @@ def list_recipes_db() -> List[Dict[str, Any]]:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
""" """
SELECT id, name, meal_type, time_minutes, SELECT r.id, r.name, r.meal_type, r.time_minutes,
tags, ingredients, steps, image, made_by r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
FROM recipes u.display_name as owner_display_name
ORDER BY id FROM recipes r
LEFT JOIN users u ON r.user_id = u.id
ORDER BY r.id
""" """
) )
rows = cur.fetchall() rows = cur.fetchall()
@ -85,7 +87,7 @@ def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Di
image = %s, image = %s,
made_by = %s made_by = %s
WHERE id = %s WHERE id = %s
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
""", """,
( (
recipe_data["name"], recipe_data["name"],
@ -133,9 +135,9 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
""" """
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by) INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id
""", """,
( (
recipe_data["name"], recipe_data["name"],
@ -146,6 +148,7 @@ def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
json.dumps(recipe_data.get("steps", [])), json.dumps(recipe_data.get("steps", [])),
recipe_data.get("image"), recipe_data.get("image"),
recipe_data.get("made_by"), recipe_data.get("made_by"),
recipe_data.get("user_id"),
), ),
) )
row = cur.fetchone() row = cur.fetchone()
@ -162,19 +165,21 @@ def get_recipes_by_filters_db(
conn = get_conn() conn = get_conn()
try: try:
query = """ query = """
SELECT id, name, meal_type, time_minutes, SELECT r.id, r.name, r.meal_type, r.time_minutes,
tags, ingredients, steps, image, made_by r.tags, r.ingredients, r.steps, r.image, r.made_by, r.user_id,
FROM recipes u.display_name as owner_display_name
FROM recipes r
LEFT JOIN users u ON r.user_id = u.id
WHERE 1=1 WHERE 1=1
""" """
params: List = [] params: List = []
if meal_type: if meal_type:
query += " AND meal_type = %s" query += " AND r.meal_type = %s"
params.append(meal_type.lower()) params.append(meal_type.lower())
if max_time: if max_time:
query += " AND time_minutes <= %s" query += " AND r.time_minutes <= %s"
params.append(max_time) params.append(max_time)
with conn.cursor() as cur: with conn.cursor() as cur:

211
backend/email_utils.py Normal file
View File

@ -0,0 +1,211 @@
import os
import random
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from dotenv import load_dotenv
load_dotenv()
# In-memory storage for verification codes (in production, use Redis or database)
verification_codes = {}
password_reset_tokens = {}
def generate_verification_code():
"""Generate a 6-digit verification code"""
return str(random.randint(100000, 999999))
async def send_verification_email(email: str, code: str, purpose: str = "password_change"):
"""Send verification code via email"""
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_user = os.getenv("SMTP_USER")
smtp_password = os.getenv("SMTP_PASSWORD")
smtp_from = os.getenv("SMTP_FROM", smtp_user)
if not smtp_user or not smtp_password:
raise Exception("SMTP credentials not configured")
# Create message
message = MIMEMultipart("alternative")
message["Subject"] = "קוד אימות - מתכונים שלי"
message["From"] = smtp_from
message["To"] = email
# Email content
if purpose == "password_change":
text = f"""
שלום,
קוד האימות שלך לשינוי סיסמה הוא: {code}
הקוד תקף ל-10 דקות.
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
בברכה,
צוות מתכונים שלי
"""
html = f"""
<html dir="rtl">
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
<h2>שינוי סיסמה</h2>
<p>קוד האימות שלך הוא:</p>
<h1 style="color: #22c55e; font-size: 32px; letter-spacing: 5px;">{code}</h1>
<p>הקוד תקף ל-<strong>10 דקות</strong>.</p>
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 14px;">
אם לא ביקשת לשנות את הסיסמה, התעלם מהודעה זו.
</p>
</body>
</html>
"""
part1 = MIMEText(text, "plain")
part2 = MIMEText(html, "html")
message.attach(part1)
message.attach(part2)
# Send email
await aiosmtplib.send(
message,
hostname=smtp_host,
port=smtp_port,
username=smtp_user,
password=smtp_password,
start_tls=True,
)
def store_verification_code(user_id: int, code: str):
"""Store verification code with expiry"""
expiry = datetime.now() + timedelta(minutes=10)
verification_codes[user_id] = {
"code": code,
"expiry": expiry
}
def verify_code(user_id: int, code: str) -> bool:
"""Verify if code is correct and not expired"""
if user_id not in verification_codes:
return False
stored = verification_codes[user_id]
# Check if expired
if datetime.now() > stored["expiry"]:
del verification_codes[user_id]
return False
# Check if code matches
if stored["code"] != code:
return False
# Code is valid, remove it
del verification_codes[user_id]
return True
async def send_password_reset_email(email: str, token: str, frontend_url: str):
"""Send password reset link via email"""
smtp_host = os.getenv("SMTP_HOST", "smtp.gmail.com")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_user = os.getenv("SMTP_USER")
smtp_password = os.getenv("SMTP_PASSWORD")
smtp_from = os.getenv("SMTP_FROM", smtp_user)
if not smtp_user or not smtp_password:
raise Exception("SMTP credentials not configured")
reset_link = f"{frontend_url}?reset_token={token}"
# Create message
message = MIMEMultipart("alternative")
message["Subject"] = "איפוס סיסמה - מתכונים שלי"
message["From"] = smtp_from
message["To"] = email
text = f"""
שלום,
קיבלנו בקשה לאיפוס הסיסמה שלך.
לחץ על הקישור הבא כדי לאפס את הסיסמה (תקף ל-30 דקות):
{reset_link}
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
בברכה,
צוות מתכונים שלי
"""
html = f"""
<html dir="rtl">
<body style="font-family: Arial, sans-serif; direction: rtl; text-align: right;">
<h2>איפוס סיסמה</h2>
<p>קיבלנו בקשה לאיפוס הסיסמה שלך.</p>
<p>לחץ על הכפתור למטה כדי לאפס את הסיסמה:</p>
<div style="margin: 30px 0; text-align: center;">
<a href="{reset_link}"
style="background-color: #22c55e; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 6px; display: inline-block;
font-weight: bold;">
איפוס סיסמה
</a>
</div>
<p style="color: #6b7280; font-size: 14px;">
הקישור תקף ל-<strong>30 דקות</strong>.
</p>
<hr style="border: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 14px;">
אם לא ביקשת לאפס את הסיסמה, התעלם מהודעה זו.
</p>
</body>
</html>
"""
part1 = MIMEText(text, "plain")
part2 = MIMEText(html, "html")
message.attach(part1)
message.attach(part2)
# Send email
await aiosmtplib.send(
message,
hostname=smtp_host,
port=smtp_port,
username=smtp_user,
password=smtp_password,
start_tls=True,
)
def store_password_reset_token(email: str, token: str):
"""Store password reset token with expiry"""
expiry = datetime.now() + timedelta(minutes=30)
password_reset_tokens[token] = {
"email": email,
"expiry": expiry
}
def verify_reset_token(token: str) -> str:
"""Verify reset token and return email if valid"""
if token not in password_reset_tokens:
return None
stored = password_reset_tokens[token]
# Check if expired
if datetime.now() > stored["expiry"]:
del password_reset_tokens[token]
return None
return stored["email"]
def consume_reset_token(token: str):
"""Remove token after use"""
if token in password_reset_tokens:
del password_reset_tokens[token]

275
backend/grocery_db_utils.py Normal file
View File

@ -0,0 +1,275 @@
import os
from typing import List, Optional, Dict, Any
import psycopg2
from psycopg2.extras import RealDictCursor
def get_db_connection():
"""Get database connection"""
return psycopg2.connect(
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", "5432")),
database=os.getenv("DB_NAME", "recipes_db"),
user=os.getenv("DB_USER", "recipes_user"),
password=os.getenv("DB_PASSWORD", "recipes_password"),
)
def create_grocery_list(owner_id: int, name: str, items: List[str] = None) -> Dict[str, Any]:
"""Create a new grocery list"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
items = items or []
cur.execute(
"""
INSERT INTO grocery_lists (owner_id, name, items)
VALUES (%s, %s, %s)
RETURNING id, name, items, owner_id, created_at, updated_at
""",
(owner_id, name, items)
)
grocery_list = cur.fetchone()
conn.commit()
return dict(grocery_list)
finally:
cur.close()
conn.close()
def get_user_grocery_lists(user_id: int) -> List[Dict[str, Any]]:
"""Get all grocery lists owned by or shared with a user"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
u.display_name as owner_display_name,
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
FROM grocery_lists gl
LEFT JOIN users u ON gl.owner_id = u.id
LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s
WHERE gl.owner_id = %s OR gls.shared_with_user_id = %s
ORDER BY gl.updated_at DESC
""",
(user_id, user_id, user_id, user_id, user_id)
)
lists = cur.fetchall()
return [dict(row) for row in lists]
finally:
cur.close()
conn.close()
def get_grocery_list_by_id(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
"""Get a specific grocery list if user has access"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
u.display_name as owner_display_name,
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
FROM grocery_lists gl
LEFT JOIN users u ON gl.owner_id = u.id
LEFT JOIN grocery_list_shares gls ON gl.id = gls.list_id AND gls.shared_with_user_id = %s
WHERE gl.id = %s AND (gl.owner_id = %s OR gls.shared_with_user_id = %s)
""",
(user_id, user_id, user_id, list_id, user_id, user_id)
)
grocery_list = cur.fetchone()
return dict(grocery_list) if grocery_list else None
finally:
cur.close()
conn.close()
def update_grocery_list(list_id: int, name: str = None, items: List[str] = None) -> Optional[Dict[str, Any]]:
"""Update a grocery list"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
updates = []
params = []
if name is not None:
updates.append("name = %s")
params.append(name)
if items is not None:
updates.append("items = %s")
params.append(items)
if not updates:
return None
updates.append("updated_at = CURRENT_TIMESTAMP")
params.append(list_id)
query = f"UPDATE grocery_lists SET {', '.join(updates)} WHERE id = %s RETURNING id, name, items, owner_id, created_at, updated_at"
cur.execute(query, params)
grocery_list = cur.fetchone()
conn.commit()
return dict(grocery_list) if grocery_list else None
finally:
cur.close()
conn.close()
def delete_grocery_list(list_id: int) -> bool:
"""Delete a grocery list"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("DELETE FROM grocery_lists WHERE id = %s", (list_id,))
deleted = cur.rowcount > 0
conn.commit()
return deleted
finally:
cur.close()
conn.close()
def share_grocery_list(list_id: int, shared_with_user_id: int, can_edit: bool = False) -> Dict[str, Any]:
"""Share a grocery list with another user or update existing share permissions"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
INSERT INTO grocery_list_shares (list_id, shared_with_user_id, can_edit)
VALUES (%s, %s, %s)
ON CONFLICT (list_id, shared_with_user_id)
DO UPDATE SET can_edit = EXCLUDED.can_edit, shared_at = CURRENT_TIMESTAMP
RETURNING id, list_id, shared_with_user_id, can_edit, shared_at
""",
(list_id, shared_with_user_id, can_edit)
)
share = cur.fetchone()
conn.commit()
return dict(share)
finally:
cur.close()
conn.close()
def update_share_permission(list_id: int, shared_with_user_id: int, can_edit: bool) -> Optional[Dict[str, Any]]:
"""Update edit permission for an existing share"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
UPDATE grocery_list_shares
SET can_edit = %s
WHERE list_id = %s AND shared_with_user_id = %s
RETURNING id, list_id, shared_with_user_id, can_edit, shared_at
""",
(can_edit, list_id, shared_with_user_id)
)
share = cur.fetchone()
conn.commit()
return dict(share) if share else None
finally:
cur.close()
conn.close()
def unshare_grocery_list(list_id: int, user_id: int) -> bool:
"""Remove sharing access for a user"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"DELETE FROM grocery_list_shares WHERE list_id = %s AND shared_with_user_id = %s",
(list_id, user_id)
)
deleted = cur.rowcount > 0
conn.commit()
return deleted
finally:
cur.close()
conn.close()
def get_grocery_list_shares(list_id: int) -> List[Dict[str, Any]]:
"""Get all users a grocery list is shared with"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT gls.id, gls.list_id, gls.shared_with_user_id, gls.can_edit, gls.shared_at,
u.username, u.display_name, u.email
FROM grocery_list_shares gls
JOIN users u ON gls.shared_with_user_id = u.id
WHERE gls.list_id = %s
ORDER BY gls.shared_at DESC
""",
(list_id,)
)
shares = cur.fetchall()
return [dict(row) for row in shares]
finally:
cur.close()
conn.close()
def search_users(query: str, limit: int = 10) -> List[Dict[str, Any]]:
"""Search users by username or display_name for autocomplete"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT id, username, display_name, email
FROM users
WHERE username ILIKE %s OR display_name ILIKE %s
ORDER BY username
LIMIT %s
""",
(f"%{query}%", f"%{query}%", limit)
)
users = cur.fetchall()
return [dict(row) for row in users]
finally:
cur.close()
conn.close()
def toggle_grocery_list_pin(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
"""Toggle pin status for a grocery list (owner only)"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Check if user is owner
cur.execute(
"SELECT id, is_pinned FROM grocery_lists WHERE id = %s AND owner_id = %s",
(list_id, user_id)
)
result = cur.fetchone()
if not result:
return None
# Toggle pin status
new_pin_status = not result["is_pinned"]
cur.execute(
"""
UPDATE grocery_lists
SET is_pinned = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, name, items, owner_id, is_pinned, created_at, updated_at
""",
(new_pin_status, list_id)
)
updated = cur.fetchone()
conn.commit()
return dict(updated) if updated else None
finally:
cur.close()
conn.close()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,124 @@
"""
Database utilities for managing notifications.
"""
from db_utils import get_conn
def create_notification(user_id: int, type: str, message: str, related_id: int = None):
"""Create a new notification for a user."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
INSERT INTO notifications (user_id, type, message, related_id)
VALUES (%s, %s, %s, %s)
RETURNING id, user_id, type, message, related_id, is_read, created_at
""",
(user_id, type, message, related_id)
)
row = cur.fetchone()
conn.commit()
cur.close()
conn.close()
if row:
return {
"id": row["id"],
"user_id": row["user_id"],
"type": row["type"],
"message": row["message"],
"related_id": row["related_id"],
"is_read": row["is_read"],
"created_at": row["created_at"]
}
return None
def get_user_notifications(user_id: int, unread_only: bool = False):
"""Get all notifications for a user."""
conn = get_conn()
cur = conn.cursor()
query = """
SELECT id, user_id, type, message, related_id, is_read, created_at
FROM notifications
WHERE user_id = %s
"""
if unread_only:
query += " AND is_read = FALSE"
query += " ORDER BY created_at DESC"
cur.execute(query, (user_id,))
rows = cur.fetchall()
cur.close()
conn.close()
notifications = []
for row in rows:
notifications.append({
"id": row["id"],
"user_id": row["user_id"],
"type": row["type"],
"message": row["message"],
"related_id": row["related_id"],
"is_read": row["is_read"],
"created_at": row["created_at"]
})
return notifications
def mark_notification_as_read(notification_id: int, user_id: int):
"""Mark a notification as read."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
UPDATE notifications
SET is_read = TRUE
WHERE id = %s AND user_id = %s
""",
(notification_id, user_id)
)
conn.commit()
cur.close()
conn.close()
return True
def mark_all_notifications_as_read(user_id: int):
"""Mark all notifications for a user as read."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
UPDATE notifications
SET is_read = TRUE
WHERE user_id = %s AND is_read = FALSE
""",
(user_id,)
)
conn.commit()
cur.close()
conn.close()
return True
def delete_notification(notification_id: int, user_id: int):
"""Delete a notification."""
conn = get_conn()
cur = conn.cursor()
cur.execute(
"""
DELETE FROM notifications
WHERE id = %s AND user_id = %s
""",
(notification_id, user_id)
)
conn.commit()
cur.close()
conn.close()
return True

33
backend/oauth_utils.py Normal file
View File

@ -0,0 +1,33 @@
import os
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
# Load config
config = Config('.env')
# Initialize OAuth
oauth = OAuth(config)
# Register Google OAuth
oauth.register(
name='google',
client_id=os.getenv('GOOGLE_CLIENT_ID'),
client_secret=os.getenv('GOOGLE_CLIENT_SECRET'),
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile'
}
)
# Register Microsoft Entra ID (Azure AD) OAuth
# Use 'common' for multi-tenant + personal accounts, or 'consumers' for personal accounts only
tenant_id = os.getenv('AZURE_TENANT_ID', 'common')
oauth.register(
name='azure',
client_id=os.getenv('AZURE_CLIENT_ID'),
client_secret=os.getenv('AZURE_CLIENT_SECRET'),
server_metadata_url=f'https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile'
}
)

View File

@ -2,6 +2,24 @@ fastapi==0.115.0
uvicorn[standard]==0.30.1 uvicorn[standard]==0.30.1
pydantic==2.7.4 pydantic==2.7.4
pydantic[email]==2.7.4
python-dotenv==1.0.1 python-dotenv==1.0.1
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
bcrypt==4.1.2
# Email
aiosmtplib==3.0.2
# OAuth
authlib==1.3.0
httpx==0.27.0
itsdangerous==2.1.2
# Backup to R2
boto3==1.34.17

View File

@ -0,0 +1,41 @@
import psycopg2
import bcrypt
import os
from dotenv import load_dotenv
load_dotenv()
# New password for admin
new_password = "admin123" # Change this to whatever you want
# Hash the password
salt = bcrypt.gensalt()
password_hash = bcrypt.hashpw(new_password.encode('utf-8'), salt).decode('utf-8')
# Update in database
conn = psycopg2.connect(os.getenv('DATABASE_URL'))
cur = conn.cursor()
# Update admin password
cur.execute(
"UPDATE users SET password_hash = %s WHERE username = %s",
(password_hash, 'admin')
)
conn.commit()
# Verify
cur.execute("SELECT username, email, is_admin FROM users WHERE username = 'admin'")
user = cur.fetchone()
if user:
print(f"✓ Admin password updated successfully!")
print(f" Username: {user[0]}")
print(f" Email: {user[1]}")
print(f" Is Admin: {user[2]}")
print(f"\nYou can now login with:")
print(f" Username: admin")
print(f" Password: {new_password}")
else:
print("✗ Admin user not found!")
cur.close()
conn.close()

218
backend/restore_db.py Normal file
View File

@ -0,0 +1,218 @@
"""
Database restore script from R2 storage
Downloads compressed backup from R2 and restores to PostgreSQL
"""
import os
import subprocess
import gzip
from pathlib import Path
import boto3
from botocore.config import Config
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# R2 Configuration
R2_ENDPOINT = os.getenv("R2_ENDPOINT")
R2_ACCESS_KEY = os.getenv("R2_ACCESS_KEY")
R2_SECRET_KEY = os.getenv("R2_SECRET_KEY")
R2_BUCKET = os.getenv("R2_BUCKET")
# Database Configuration
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "5432")
DB_NAME = os.getenv("DB_NAME", "recipes_db")
DB_USER = os.getenv("DB_USER", "recipes_user")
DB_PASSWORD = os.getenv("DB_PASSWORD", "recipes_password")
# Restore directory
RESTORE_DIR = Path(__file__).parent / "restores"
RESTORE_DIR.mkdir(exist_ok=True)
def list_r2_backups():
"""List all available backups in R2"""
s3_client = boto3.client(
's3',
endpoint_url=R2_ENDPOINT,
aws_access_key_id=R2_ACCESS_KEY,
aws_secret_access_key=R2_SECRET_KEY,
config=Config(signature_version='s3v4'),
region_name='auto'
)
try:
response = s3_client.list_objects_v2(Bucket=R2_BUCKET)
if 'Contents' not in response:
return []
backups = sorted(response['Contents'], key=lambda x: x['LastModified'], reverse=True)
return backups
except Exception as e:
print(f"✗ Error listing backups: {e}")
return []
def download_from_r2(backup_name):
"""Download backup file from R2"""
local_file = RESTORE_DIR / backup_name
print(f"Downloading {backup_name} from R2...")
s3_client = boto3.client(
's3',
endpoint_url=R2_ENDPOINT,
aws_access_key_id=R2_ACCESS_KEY,
aws_secret_access_key=R2_SECRET_KEY,
config=Config(signature_version='s3v4'),
region_name='auto'
)
try:
s3_client.download_file(R2_BUCKET, backup_name, str(local_file))
size_mb = local_file.stat().st_size / (1024 * 1024)
print(f"✓ Downloaded: {local_file.name} ({size_mb:.2f} MB)")
return local_file
except Exception as e:
print(f"✗ Error downloading from R2: {e}")
raise
def decompress_file(compressed_file):
"""Decompress gzip file"""
decompressed_file = Path(str(compressed_file).replace('.gz', ''))
print(f"Decompressing {compressed_file.name}...")
with gzip.open(compressed_file, 'rb') as f_in:
with open(decompressed_file, 'wb') as f_out:
f_out.write(f_in.read())
compressed_size = compressed_file.stat().st_size
decompressed_size = decompressed_file.stat().st_size
print(f"✓ Decompressed to {decompressed_file.name}")
print(f" Compressed: {compressed_size / 1024:.2f} KB")
print(f" Decompressed: {decompressed_size / 1024:.2f} KB")
return decompressed_file
def restore_database(sql_file):
"""Restore PostgreSQL database from SQL file"""
print(f"\nRestoring database from {sql_file.name}...")
print("WARNING: This will overwrite the current database!")
response = input("Are you sure you want to continue? (yes/no): ")
if response.lower() != 'yes':
print("Restore cancelled")
return False
# Set PGPASSWORD environment variable
env = os.environ.copy()
env['PGPASSWORD'] = DB_PASSWORD
# Drop and recreate database (optional, comment out if you want to merge)
print("Dropping existing tables...")
drop_cmd = [
"psql",
"-h", DB_HOST,
"-p", DB_PORT,
"-U", DB_USER,
"-d", DB_NAME,
"-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
]
try:
subprocess.run(drop_cmd, env=env, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
print(f"Warning: Could not drop schema: {e.stderr}")
# Restore from backup
print("Restoring database...")
restore_cmd = [
"psql",
"-h", DB_HOST,
"-p", DB_PORT,
"-U", DB_USER,
"-d", DB_NAME,
"-f", str(sql_file)
]
try:
subprocess.run(restore_cmd, env=env, check=True, capture_output=True, text=True)
print("✓ Database restored successfully!")
return True
except subprocess.CalledProcessError as e:
print(f"✗ Error restoring database: {e.stderr}")
raise
def main():
"""Main restore process"""
print("=" * 60)
print("Database Restore from Cloudflare R2")
print("=" * 60)
print()
try:
# Verify R2 credentials
if not all([R2_ENDPOINT, R2_ACCESS_KEY, R2_SECRET_KEY, R2_BUCKET]):
raise ValueError("Missing R2 credentials in environment variables")
# List available backups
print("Available backups:")
backups = list_r2_backups()
if not backups:
print("No backups found in R2")
return
for i, backup in enumerate(backups, 1):
size_mb = backup['Size'] / (1024 * 1024)
print(f"{i}. {backup['Key']}")
print(f" Size: {size_mb:.2f} MB, Date: {backup['LastModified']}")
print()
# Select backup
choice = input(f"Select backup to restore (1-{len(backups)}) or 'q' to quit: ")
if choice.lower() == 'q':
print("Restore cancelled")
return
try:
backup_index = int(choice) - 1
if backup_index < 0 or backup_index >= len(backups):
raise ValueError()
except ValueError:
print("Invalid selection")
return
selected_backup = backups[backup_index]['Key']
# Download backup
compressed_file = download_from_r2(selected_backup)
# Decompress backup
sql_file = decompress_file(compressed_file)
# Restore database
restore_database(sql_file)
print("\n" + "=" * 60)
print("✓ Restore completed successfully!")
print("=" * 60)
except Exception as e:
print("\n" + "=" * 60)
print(f"✗ Restore failed: {e}")
print("=" * 60)
raise
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

Binary file not shown.

3
backend/run_backup.bat Normal file
View File

@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
python backup_db.py >> backup.log 2>&1

View File

@ -1,14 +1,32 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
display_name TEXT UNIQUE NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users (username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
-- Create recipes table -- Create recipes table
CREATE TABLE IF NOT EXISTS recipes ( CREATE TABLE IF NOT EXISTS recipes (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack meal_type TEXT NOT NULL, -- breakfast / lunch / dinner / snack
time_minutes INTEGER NOT NULL, time_minutes INTEGER NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}', -- {"מהיר", "בריא"}
ingredients TEXT[] NOT NULL DEFAULT '{}', -- {"ביצה", "עגבניה", "מלח"}
steps TEXT[] NOT NULL DEFAULT '{}', -- {"לחתוך", "לבשל", ...}
image TEXT, -- Base64-encoded image or image URL
made_by TEXT, -- Person who created this recipe version made_by TEXT, -- Person who created this recipe version
tags JSONB NOT NULL DEFAULT '[]', -- ["מהיר", "בריא"] user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, -- Recipe owner
ingredients JSONB NOT NULL DEFAULT '[]', -- ["ביצה", "עגבניה", "מלח"] created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
steps JSONB NOT NULL DEFAULT '[]', -- ["לחתוך", "לבשל", ...]
image TEXT -- Base64-encoded image or image URL
); );
-- Optional: index for filters -- Optional: index for filters
@ -21,9 +39,52 @@ CREATE INDEX IF NOT EXISTS idx_recipes_time_minutes
CREATE INDEX IF NOT EXISTS idx_recipes_made_by CREATE INDEX IF NOT EXISTS idx_recipes_made_by
ON recipes (made_by); ON recipes (made_by);
CREATE INDEX IF NOT EXISTS idx_recipes_tags_jsonb CREATE INDEX IF NOT EXISTS idx_recipes_user_id
ON recipes USING GIN (tags); ON recipes (user_id);
-- Create grocery lists table
CREATE TABLE IF NOT EXISTS grocery_lists (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
items TEXT[] NOT NULL DEFAULT '{}', -- Array of grocery items
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_pinned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create grocery list shares table
CREATE TABLE IF NOT EXISTS grocery_list_shares (
id SERIAL PRIMARY KEY,
list_id INTEGER NOT NULL REFERENCES grocery_lists(id) ON DELETE CASCADE,
shared_with_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
can_edit BOOLEAN DEFAULT FALSE,
shared_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(list_id, shared_with_user_id)
);
CREATE INDEX IF NOT EXISTS idx_grocery_lists_owner_id ON grocery_lists (owner_id);
CREATE INDEX IF NOT EXISTS idx_grocery_list_shares_list_id ON grocery_list_shares (list_id);
CREATE INDEX IF NOT EXISTS idx_grocery_list_shares_user_id ON grocery_list_shares (shared_with_user_id);
-- Create notifications table
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- 'grocery_share', etc.
message TEXT NOT NULL,
related_id INTEGER, -- Related entity ID (e.g., list_id)
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications (user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications (is_read);
-- Create default admin user (password: admin123)
-- Password hash generated with bcrypt for 'admin123'
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin)
VALUES ('admin', 'admin@myrecipes.local', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5lE7UGf3rCvHC', 'Admin', 'User', 'מנהל', TRUE)
ON CONFLICT (username) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_recipes_ingredients_jsonb
ON recipes USING GIN (ingredients);

202
backend/social_db_utils.py Normal file
View File

@ -0,0 +1,202 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
from psycopg2 import errors
def get_db_connection():
"""Get database connection"""
return psycopg2.connect(
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", "5432")),
database=os.getenv("DB_NAME", "recipes_db"),
user=os.getenv("DB_USER", "recipes_user"),
password=os.getenv("DB_PASSWORD", "recipes_password"),
)
# ============= Friends System =============
def send_friend_request(sender_id: int, receiver_id: int):
"""Send a friend request"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Check if already friends
cur.execute(
"SELECT 1 FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
(sender_id, receiver_id, receiver_id, sender_id)
)
if cur.fetchone():
return {"error": "Already friends"}
# Check if request already exists
cur.execute(
"SELECT * FROM friend_requests WHERE sender_id = %s AND receiver_id = %s AND status = 'pending'",
(sender_id, receiver_id)
)
existing = cur.fetchone()
if existing:
return dict(existing)
try:
cur.execute(
"""
INSERT INTO friend_requests (sender_id, receiver_id)
VALUES (%s, %s)
RETURNING id, sender_id, receiver_id, status, created_at
""",
(sender_id, receiver_id)
)
request = cur.fetchone()
conn.commit()
return dict(request)
except errors.UniqueViolation:
# Request already exists, fetch and return it
conn.rollback()
cur.execute(
"SELECT id, sender_id, receiver_id, status, created_at FROM friend_requests WHERE sender_id = %s AND receiver_id = %s",
(sender_id, receiver_id)
)
existing_request = cur.fetchone()
if existing_request:
return dict(existing_request)
return {"error": "Friend request already exists"}
finally:
cur.close()
conn.close()
def accept_friend_request(request_id: int):
"""Accept a friend request and create friendship"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Get request details
cur.execute(
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
(request_id,)
)
request = cur.fetchone()
if not request:
return {"error": "Request not found or already processed"}
sender_id = request["sender_id"]
receiver_id = request["receiver_id"]
# Create bidirectional friendship
cur.execute(
"INSERT INTO friendships (user_id, friend_id) VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING",
(sender_id, receiver_id, receiver_id, sender_id)
)
# Update request status
cur.execute(
"UPDATE friend_requests SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(request_id,)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def reject_friend_request(request_id: int):
"""Reject a friend request"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"UPDATE friend_requests SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND status = 'pending'",
(request_id,)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def get_friend_requests(user_id: int):
"""Get pending friend requests for a user"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT fr.id AS request_id, fr.sender_id, fr.receiver_id, fr.status, fr.created_at,
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email
FROM friend_requests fr
JOIN users u ON u.id = fr.sender_id
WHERE fr.receiver_id = %s AND fr.status = 'pending'
ORDER BY fr.created_at DESC
""",
(user_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()
def get_friends(user_id: int):
"""Get list of user's friends"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT u.id, u.username, u.display_name, u.email, f.created_at AS friends_since
FROM friendships f
JOIN users u ON u.id = f.friend_id
WHERE f.user_id = %s
ORDER BY u.display_name
""",
(user_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()
def remove_friend(user_id: int, friend_id: int):
"""Remove a friend"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"DELETE FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
(user_id, friend_id, friend_id, user_id)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def search_users(query: str, current_user_id: int, limit: int = 20):
"""Search for users by username or display name"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
search_pattern = f"%{query}%"
cur.execute(
"""
SELECT u.id, u.username, u.display_name, u.email,
EXISTS(SELECT 1 FROM friendships WHERE user_id = %s AND friend_id = u.id) AS is_friend,
EXISTS(SELECT 1 FROM friend_requests WHERE sender_id = %s AND receiver_id = u.id AND status = 'pending') AS request_sent
FROM users u
WHERE (u.username ILIKE %s OR u.display_name ILIKE %s) AND u.id != %s
ORDER BY u.display_name
LIMIT %s
""",
(current_user_id, current_user_id, search_pattern, search_pattern, current_user_id, limit)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()

117
backend/user_db_utils.py Normal file
View File

@ -0,0 +1,117 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
def get_db_connection():
"""Get database connection"""
return psycopg2.connect(
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", "5432")),
database=os.getenv("DB_NAME", "recipes_db"),
user=os.getenv("DB_USER", "recipes_user"),
password=os.getenv("DB_PASSWORD", "recipes_password"),
)
def create_user(username: str, email: str, password_hash: str, first_name: str = None, last_name: str = None, display_name: str = None, is_admin: bool = False, auth_provider: str = "local"):
"""Create a new user"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Use display_name if provided, otherwise use username
final_display_name = display_name if display_name else username
cur.execute(
"""
INSERT INTO users (username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, username, email, first_name, last_name, display_name, is_admin, auth_provider, created_at
""",
(username, email, password_hash, first_name, last_name, final_display_name, is_admin, auth_provider)
)
user = cur.fetchone()
conn.commit()
return dict(user)
finally:
cur.close()
conn.close()
def get_user_by_username(username: str):
"""Get user by username"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE username = %s",
(username,)
)
user = cur.fetchone()
return dict(user) if user else None
finally:
cur.close()
conn.close()
def get_user_by_email(email: str):
"""Get user by email"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE email = %s",
(email,)
)
user = cur.fetchone()
return dict(user) if user else None
finally:
cur.close()
conn.close()
def get_user_by_id(user_id: int):
"""Get user by ID"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT id, username, email, password_hash, first_name, last_name, display_name, is_admin, auth_provider, created_at FROM users WHERE id = %s",
(user_id,)
)
user = cur.fetchone()
return dict(user) if user else None
finally:
cur.close()
conn.close()
def get_user_by_display_name(display_name: str):
"""Get user by display name"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"SELECT id, username, email, display_name, is_admin, created_at FROM users WHERE display_name = %s",
(display_name,)
)
user = cur.fetchone()
return dict(user) if user else None
finally:
cur.close()
conn.close()
def update_user_auth_provider(user_id: int, auth_provider: str):
"""Update user's auth provider"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"UPDATE users SET auth_provider = %s WHERE id = %s",
(auth_provider, user_id)
)
conn.commit()
finally:
cur.close()
conn.close()

178
demo-recipes.sql Normal file
View File

@ -0,0 +1,178 @@
-- Demo recipes for user dvir (id=3)
-- Recipe 1: שקשוקה
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'שקשוקה',
'breakfast',
25,
'["מהיר", "בריא", "צמחוני"]'::jsonb,
'["4 ביצים", "4 עגבניות", "1 בצל", "2 שיני שום", "פלפל חריף", "כמון", "מלח", "שמן זית"]'::jsonb,
'[
"לחתוך את הבצל והשום דק",
"לחמם שמן בסיר ולטגן את הבצל עד שקוף",
"להוסיף שום ופלפל חריף ולטגן דקה",
"לקצוץ עגבניות ולהוסיף לסיר",
"לתבל בכמון ומלח, לבשל 10 דקות",
"לפתוח גומות ברוטב ולשבור ביצה בכל גומה",
"לכסות ולבשל עד שהביצים מתקשות"
]'::jsonb,
'https://images.unsplash.com/photo-1525351484163-7529414344d8?w=500',
'דביר',
3
);
-- Recipe 2: פסטה ברוטב עגבניות
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'פסטה ברוטב עגבניות',
'lunch',
20,
'["מהיר", "צמחוני", "ילדים אוהבים"]'::jsonb,
'["500 גרם פסטה", "רסק עגבניות", "בזיליקום טרי", "3 שיני שום", "שמן זית", "מלח", "פלפל"]'::jsonb,
'[
"להרתיח מים מלוחים ולבשל את הפסטה לפי ההוראות",
"בינתיים, לחמם שמן בסיר",
"לטגן שום כתוש דקה",
"להוסיף רסק עגבניות ולתבל",
"לבשל על אש בינונית 10 דקות",
"להוסיף בזיליקום קרוע",
"לערבב את הפסטה המסוננת עם הרוטב"
]'::jsonb,
'https://images.unsplash.com/photo-1621996346565-e3dbc646d9a9?w=500',
'דביר',
3
);
-- Recipe 3: סלט ישראלי
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'סלט ישראלי',
'snack',
10,
'["מהיר", "בריא", "טבעוני", "צמחוני"]'::jsonb,
'["4 עגבניות", "2 מלפפונים", "1 בצל", "פטרוזיליה", "לימון", "שמן זית", "מלח"]'::jsonb,
'[
"לחתוך עגבניות ומלפפונים לקוביות קטנות",
"לקצוץ בצל דק",
"לקצוץ פטרוזיליה",
"לערבב הכל בקערה",
"להוסיף מיץ לימון ושמן זית",
"לתבל במלח ולערבב היטב"
]'::jsonb,
'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?w=500',
'דביר',
3
);
-- Recipe 4: חביתה עם ירקות
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'חביתה עם ירקות',
'breakfast',
15,
'["מהיר", "בריא", "חלבוני", "צמחוני"]'::jsonb,
'["3 ביצים", "1 בצל", "1 פלפל", "עגבניה", "גבינה צהובה", "מלח", "פלפל שחור", "שמן"]'::jsonb,
'[
"לקצוץ את הירקות לקוביות קטנות",
"לטגן את הירקות בשמן עד שמתרככים",
"להקציף את הביצים במזלג",
"לשפוך את הביצים על הירקות",
"לפזר גבינה קצוצה",
"לבשל עד שהתחתית מוזהבת",
"להפוך או לקפל לחצי ולהגיש"
]'::jsonb,
'https://images.unsplash.com/photo-1525351159099-122d10e7960e?w=500',
'דביר',
3
);
-- Recipe 5: עוף בתנור עם תפוחי אדמה
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'עוף בתנור עם תפוחי אדמה',
'dinner',
60,
'["משפחתי", "חגיגי"]'::jsonb,
'["1 עוף שלם", "1 ק״ג תפוחי אדמה", "פפריקה", "כורכום", "שום", "מלח", "פלפל", "שמן זית", "לימון"]'::jsonb,
'[
"לחמם תנור ל-200 מעלות",
"לחתוך תפוחי אדמה לרבעים",
"לשפשף את העוף בתבלינים, שמן ומיץ לימון",
"לסדר תפוחי אדמה בתבנית",
"להניח את העוף על התפוחי אדמה",
"לאפות כשעה עד שהעוף מוזהב",
"להוציא, לחתוך ולהגיש עם הירקות"
]'::jsonb,
'https://images.unsplash.com/photo-1598103442097-8b74394b95c6?w=500',
'דביר',
3
);
-- Recipe 6: סנדוויץ טונה
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'סנדוויץ טונה',
'lunch',
5,
'["מהיר", "קר", "חלבוני"]'::jsonb,
'["קופסת טונה", "2 פרוסות לחם", "מיונז", "חסה", "עגבניה", "מלפפון חמוץ", "מלח", "פלפל"]'::jsonb,
'[
"לסנן את הטונה",
"לערבב את הטונה עם מיונז",
"לתבל במלח ופלפל",
"למרוח על פרוסת לחם",
"להוסיף חסה, עגבניה ומלפפון",
"לכסות בפרוסת לחם שנייה"
]'::jsonb,
'https://images.unsplash.com/photo-1528735602780-2552fd46c7af?w=500',
'דביר',
3
);
-- Recipe 7: בראוניז שוקולד
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'בראוניז שוקולד',
'snack',
35,
'["קינוח", "שוקולד", "אפייה"]'::jsonb,
'["200 גרם שוקולד מריר", "150 גרם חמאה", "3 ביצים", "כוס סוכר", "חצי כוס קמח", "אבקת קקאו", "וניל"]'::jsonb,
'[
"לחמם תנור ל-180 מעלות",
"להמיס שוקולד וחמאה במיקרוגל",
"להקציף ביצים וסוכר",
"להוסיף את תערובת השוקולד",
"להוסיף קמח וקקאו ולערבב",
"לשפוך לתבנית משומנת",
"לאפות 25 דקות",
"להוציא ולהניח להתקרר לפני חיתוך"
]'::jsonb,
'https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=500',
'דביר',
3
);
-- Recipe 8: מרק עדשים
INSERT INTO recipes (name, meal_type, time_minutes, tags, ingredients, steps, image, made_by, user_id)
VALUES (
'מרק עדשים',
'dinner',
40,
'["בריא", "צמחוני", "טבעוני", "חם"]'::jsonb,
'["2 כוסות עדשים כתומות", "בצל", "גזר", "3 שיני שום", "כמון", "כורכום", "מלח", "לימון"]'::jsonb,
'[
"לשטוף את העדשים",
"לקצוץ בצל, גזר ושום",
"לטגן את הבצל עד שקוף",
"להוסיף שום ותבלינים",
"להוסיף גזר ועדשים",
"להוסיף 6 כוסות מים",
"לבשל 30 דקות עד שהעדשים רכים",
"לטחון חלק מהמרק לקבלת מרקם עבה",
"להוסיף מיץ לימון לפני הגשה"
]'::jsonb,
'https://images.unsplash.com/photo-1547592166-23ac45744acd?w=500',
'דביר',
3
);

View File

@ -1,12 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="he" dir="rtl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/src/assets/my-recipes-logo-light.png" />
<link rel="apple-touch-icon" href="/src/assets/my-recipes-logo-light.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
<title>My Recipes | המתכונים שלי</title>
<!-- Load environment variables before app starts --> <!-- Load environment variables before app starts -->
<script src="/env.js"></script> <script src="/env.js?v=20251219"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://my-recipes.dvirlabs.com/</loc>
</url>
</urlset>

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,34 @@ import TopBar from "./components/TopBar";
import RecipeSearchList from "./components/RecipeSearchList"; import RecipeSearchList from "./components/RecipeSearchList";
import RecipeDetails from "./components/RecipeDetails"; import RecipeDetails from "./components/RecipeDetails";
import RecipeFormDrawer from "./components/RecipeFormDrawer"; import RecipeFormDrawer from "./components/RecipeFormDrawer";
import GroceryLists from "./components/GroceryLists";
import PinnedGroceryLists from "./components/PinnedGroceryLists";
import AdminPanel from "./components/AdminPanel";
import Modal from "./components/Modal"; import Modal from "./components/Modal";
import ToastContainer from "./components/ToastContainer"; import ToastContainer from "./components/ToastContainer";
import ThemeToggle from "./components/ThemeToggle"; import ThemeToggle from "./components/ThemeToggle";
import Login from "./components/Login";
import Register from "./components/Register";
import ResetPassword from "./components/ResetPassword";
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api"; import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
import { getToken, removeToken, getMe } from "./authApi";
function App() { function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [authView, setAuthView] = useState("login"); // "login" or "register"
const [loadingAuth, setLoadingAuth] = useState(true);
const [resetToken, setResetToken] = useState(null);
const [currentView, setCurrentView] = useState(() => {
try {
return localStorage.getItem("currentView") || "recipes";
} catch {
return "recipes";
}
}); // "recipes", "grocery-lists", or "admin"
const [selectedGroceryListId, setSelectedGroceryListId] = useState(null);
const [recipes, setRecipes] = useState([]); const [recipes, setRecipes] = useState([]);
const [selectedRecipe, setSelectedRecipe] = useState(null); const [selectedRecipe, setSelectedRecipe] = useState(null);
@ -19,7 +41,7 @@ function App() {
const [filterMealType, setFilterMealType] = useState(""); const [filterMealType, setFilterMealType] = useState("");
const [filterMaxTime, setFilterMaxTime] = useState(""); const [filterMaxTime, setFilterMaxTime] = useState("");
const [filterTags, setFilterTags] = useState([]); const [filterTags, setFilterTags] = useState([]);
const [filterMadeBy, setFilterMadeBy] = useState(""); const [filterOwner, setFilterOwner] = useState("");
// Random recipe filters // Random recipe filters
const [mealTypeFilter, setMealTypeFilter] = useState(""); const [mealTypeFilter, setMealTypeFilter] = useState("");
@ -33,6 +55,7 @@ function App() {
const [editingRecipe, setEditingRecipe] = useState(null); const [editingRecipe, setEditingRecipe] = useState(null);
const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" }); const [deleteModal, setDeleteModal] = useState({ isOpen: false, recipeId: null, recipeName: "" });
const [logoutModal, setLogoutModal] = useState(false);
const [toasts, setToasts] = useState([]); const [toasts, setToasts] = useState([]);
const [theme, setTheme] = useState(() => { const [theme, setTheme] = useState(() => {
try { try {
@ -41,7 +64,86 @@ function App() {
return "dark"; return "dark";
} }
}); });
const [showPinnedSidebar, setShowPinnedSidebar] = useState(false);
// Swipe gesture handling for mobile sidebar
const [touchStart, setTouchStart] = useState(null);
const [touchEnd, setTouchEnd] = useState(null);
// Minimum swipe distance (in px)
const minSwipeDistance = 50;
const onTouchStart = (e) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
};
const onTouchMove = (e) => {
setTouchEnd(e.targetTouches[0].clientX);
};
const onTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
if (isLeftSwipe) {
setShowPinnedSidebar(false);
}
setTouchStart(null);
setTouchEnd(null);
};
// Check authentication on mount
useEffect(() => {
const checkAuth = async () => {
// Check for reset token in URL
const urlParams = new URLSearchParams(window.location.search);
const resetTokenParam = urlParams.get('reset_token');
if (resetTokenParam) {
setResetToken(resetTokenParam);
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
setLoadingAuth(false);
return;
}
const token = getToken();
if (token) {
try {
const userData = await getMe(token);
setUser(userData);
setIsAuthenticated(true);
} catch (err) {
// Only remove token on authentication errors (401), not network errors
if (err.status === 401) {
console.log("Token invalid or expired, logging out");
removeToken();
setIsAuthenticated(false);
} else {
// Network error or server error - keep user logged in
console.warn("Auth check failed but keeping session:", err.message);
setIsAuthenticated(true); // Assume still authenticated
}
}
}
setLoadingAuth(false);
};
checkAuth();
}, []);
// Save currentView to localStorage
useEffect(() => {
try {
localStorage.setItem("currentView", currentView);
} catch (err) {
console.error("Unable to save view", err);
}
}, [currentView]);
// Load recipes for everyone (readonly for non-authenticated)
useEffect(() => { useEffect(() => {
loadRecipes(); loadRecipes();
}, []); }, []);
@ -96,8 +198,8 @@ function App() {
} }
} }
// Filter by made_by // Filter by made_by (username)
if (filterMadeBy && (!recipe.made_by || recipe.made_by !== filterMadeBy)) { if (filterOwner && (!recipe.made_by || recipe.made_by !== filterOwner)) {
return false; return false;
} }
@ -134,7 +236,8 @@ function App() {
const handleCreateRecipe = async (payload) => { const handleCreateRecipe = async (payload) => {
try { try {
const created = await createRecipe(payload); const token = getToken();
const created = await createRecipe(payload, token);
setDrawerOpen(false); setDrawerOpen(false);
setEditingRecipe(null); setEditingRecipe(null);
await loadRecipes(); await loadRecipes();
@ -153,7 +256,8 @@ function App() {
const handleUpdateRecipe = async (payload) => { const handleUpdateRecipe = async (payload) => {
try { try {
await updateRecipe(editingRecipe.id, payload); const token = getToken();
await updateRecipe(editingRecipe.id, payload, token);
setDrawerOpen(false); setDrawerOpen(false);
setEditingRecipe(null); setEditingRecipe(null);
await loadRecipes(); await loadRecipes();
@ -177,7 +281,8 @@ function App() {
setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" }); setDeleteModal({ isOpen: false, recipeId: null, recipeName: "" });
try { try {
await deleteRecipe(recipeId); const token = getToken();
await deleteRecipe(recipeId, token);
await loadRecipes(); await loadRecipes();
setSelectedRecipe(null); setSelectedRecipe(null);
addToast("המתכון נמחק בהצלחה!", "success"); addToast("המתכון נמחק בהצלחה!", "success");
@ -208,31 +313,204 @@ function App() {
} }
}; };
const handleLoginSuccess = async () => {
const token = getToken();
const userData = await getMe(token);
setUser(userData);
setIsAuthenticated(true);
await loadRecipes();
};
const handleLogout = () => {
setLogoutModal(true);
};
const confirmLogout = () => {
removeToken();
setUser(null);
setIsAuthenticated(false);
setRecipes([]);
setSelectedRecipe(null);
setLogoutModal(false);
addToast('התנתקת בהצלחה', 'success');
};
// Show loading state while checking auth
if (loadingAuth) {
return (
<div className="app-root">
<div style={{ textAlign: "center", padding: "3rem", color: "var(--text-muted)" }}>
טוען...
</div>
</div>
);
}
// Show main app (readonly if not authenticated)
return ( return (
<div className="app-root"> <div className="app-root">
<ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} /> <ThemeToggle theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} hidden={drawerOpen} />
<TopBar onAddClick={() => setDrawerOpen(true)} />
{/* Pinned notes toggle button - only visible on recipes view for authenticated users */}
{isAuthenticated && currentView === "recipes" && (
<button
className="pinned-toggle-btn mobile-only"
onClick={() => setShowPinnedSidebar(!showPinnedSidebar)}
aria-label="הצג תזכירים"
title="תזכירים נעוצים"
>
<span className="note-icon-lines">
<span></span>
<span></span>
<span></span>
</span>
</button>
)}
{/* User greeting above TopBar */}
{isAuthenticated && user && (
<div className="user-greeting-header">
שלום, {user.display_name || user.username} 👋
</div>
)}
{/* Show login/register option in TopBar if not authenticated */}
{!isAuthenticated ? (
<header className="topbar">
<div className="topbar-left">
<span className="logo-emoji" role="img" aria-label="plate">🍽</span>
<div className="brand">
<div className="brand-title">מה לבשל היום?</div>
<div className="brand-subtitle">מנהל המתכונים האישי שלך</div>
</div>
</div>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
<button className="btn ghost" onClick={() => setAuthView("login")}>
התחבר
</button>
<button className="btn primary" onClick={() => setAuthView("register")}>
הירשם
</button>
</div>
</header>
) : (
<TopBar
onAddClick={() => setDrawerOpen(true)}
user={user}
onLogout={handleLogout}
onShowToast={addToast}
onNotificationClick={(listId) => {
setCurrentView("grocery-lists");
setSelectedGroceryListId(listId);
}}
onAdminClick={() => setCurrentView("admin")}
/>
)}
{/* Show auth modal if needed */}
{!isAuthenticated && authView !== null && !resetToken && (
<div className="drawer-backdrop" onClick={() => setAuthView(null)}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
{authView === "login" ? (
<Login
onSuccess={handleLoginSuccess}
onSwitchToRegister={() => setAuthView("register")}
/>
) : (
<Register
onSuccess={handleLoginSuccess}
onSwitchToLogin={() => setAuthView("login")}
/>
)}
</div>
</div>
)}
{/* Show reset password if token present */}
{!isAuthenticated && resetToken && (
<div className="drawer-backdrop">
<div className="auth-modal">
<ResetPassword
token={resetToken}
onSuccess={() => {
setResetToken(null);
setAuthView("login");
addToast("הסיסמה עודכנה בהצלחה! כעת תוכל להתחבר עם הסיסמה החדשה", "success");
}}
onBack={() => {
setResetToken(null);
setAuthView("login");
}}
/>
</div>
</div>
)}
{isAuthenticated && (
<nav className="main-navigation">
<button
className={`nav-tab ${currentView === "recipes" ? "active" : ""}`}
onClick={() => setCurrentView("recipes")}
>
📖 מתכונים
</button>
<button
className={`nav-tab ${currentView === "grocery-lists" ? "active" : ""}`}
onClick={() => setCurrentView("grocery-lists")}
>
🛒 רשימות קניות
</button>
{user?.is_admin && (
<button
className={`nav-tab ${currentView === "admin" ? "active" : ""}`}
onClick={() => setCurrentView("admin")}
>
🛡 ניהול
</button>
)}
</nav>
)}
<main className="layout"> <main className="layout">
<section className="sidebar"> {currentView === "admin" ? (
<RecipeSearchList <div className="admin-view">
allRecipes={recipes} <AdminPanel onShowToast={addToast} />
recipes={getFilteredRecipes()} </div>
selectedId={selectedRecipe?.id} ) : currentView === "grocery-lists" ? (
onSelect={setSelectedRecipe} <GroceryLists
searchQuery={searchQuery} user={user}
onSearchChange={setSearchQuery} onShowToast={addToast}
filterMealType={filterMealType} selectedListIdFromNotification={selectedGroceryListId}
onMealTypeChange={setFilterMealType} onListSelected={() => setSelectedGroceryListId(null)}
filterMaxTime={filterMaxTime}
onMaxTimeChange={setFilterMaxTime}
filterTags={filterTags}
onTagsChange={setFilterTags}
filterMadeBy={filterMadeBy}
onMadeByChange={setFilterMadeBy}
/> />
</section> ) : (
<>
{isAuthenticated && (
<>
<aside
className={`pinned-lists-sidebar ${showPinnedSidebar ? 'mobile-visible' : ''}`}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
<button
className="close-sidebar-btn mobile-only"
onClick={() => setShowPinnedSidebar(false)}
aria-label="סגור תזכירים"
>
</button>
<PinnedGroceryLists onShowToast={addToast} />
</aside>
{showPinnedSidebar && (
<div
className="sidebar-backdrop mobile-only"
onClick={() => setShowPinnedSidebar(false)}
/>
)}
</>
)}
<section className="content-wrapper">
<section className="content"> <section className="content">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
@ -250,7 +528,7 @@ function App() {
<option value="breakfast">בוקר</option> <option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option> <option value="lunch">צהריים</option>
<option value="dinner">ערב</option> <option value="dinner">ערב</option>
<option value="snack">נשנוש</option> <option value="snack">קינוחים</option>
</select> </select>
</div> </div>
@ -289,10 +567,35 @@ function App() {
recipe={selectedRecipe} recipe={selectedRecipe}
onEditClick={handleEditRecipe} onEditClick={handleEditRecipe}
onShowDeleteModal={handleShowDeleteModal} onShowDeleteModal={handleShowDeleteModal}
isAuthenticated={isAuthenticated}
currentUser={user}
/> />
</section> </section>
<section className="sidebar">
<RecipeSearchList
allRecipes={recipes}
recipes={getFilteredRecipes()}
selectedId={selectedRecipe?.id}
onSelect={setSelectedRecipe}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filterMealType={filterMealType}
onMealTypeChange={setFilterMealType}
filterMaxTime={filterMaxTime}
onMaxTimeChange={setFilterMaxTime}
filterTags={filterTags}
onTagsChange={setFilterTags}
filterOwner={filterOwner}
onOwnerChange={setFilterOwner}
/>
</section>
</section>
</>
)}
</main> </main>
{isAuthenticated && (
<RecipeFormDrawer <RecipeFormDrawer
open={drawerOpen} open={drawerOpen}
onClose={() => { onClose={() => {
@ -301,7 +604,10 @@ function App() {
}} }}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
editingRecipe={editingRecipe} editingRecipe={editingRecipe}
currentUser={user}
allRecipes={recipes}
/> />
)}
<Modal <Modal
isOpen={deleteModal.isOpen} isOpen={deleteModal.isOpen}
@ -314,6 +620,17 @@ function App() {
onCancel={handleCancelDelete} onCancel={handleCancelDelete}
/> />
<Modal
isOpen={logoutModal}
title="התנתקות"
message="האם אתה בטוח שברצונך להתנתק?"
confirmText="התנתק"
cancelText="ביטול"
isDangerous={false}
onConfirm={confirmLogout}
onCancel={() => setLogoutModal(false)}
/>
<ToastContainer toasts={toasts} onRemove={removeToast} /> <ToastContainer toasts={toasts} onRemove={removeToast} />
</div> </div>
); );

View File

@ -1,5 +1,5 @@
// Get API base from injected env.js or fallback to /api relative path // Get API base from injected env.js or fallback to /api relative path
const getApiBase = () => { export const getApiBase = () => {
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) { if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
return window.__ENV__.API_BASE; return window.__ENV__.API_BASE;
} }
@ -37,10 +37,14 @@ export async function getRandomRecipe(filters) {
return res.json(); return res.json();
} }
export async function createRecipe(recipe) { export async function createRecipe(recipe, token) {
const headers = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}/recipes`, { const res = await fetch(`${API_BASE}/recipes`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers,
body: JSON.stringify(recipe), body: JSON.stringify(recipe),
}); });
if (!res.ok) { if (!res.ok) {
@ -49,10 +53,14 @@ export async function createRecipe(recipe) {
return res.json(); return res.json();
} }
export async function updateRecipe(id, payload) { export async function updateRecipe(id, payload, token) {
const headers = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}/recipes/${id}`, { const res = await fetch(`${API_BASE}/recipes/${id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers,
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!res.ok) { if (!res.ok) {
@ -61,9 +69,14 @@ export async function updateRecipe(id, payload) {
return res.json(); return res.json();
} }
export async function deleteRecipe(id) { export async function deleteRecipe(id, token) {
const headers = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}/recipes/${id}`, { const res = await fetch(`${API_BASE}/recipes/${id}`, {
method: "DELETE", method: "DELETE",
headers,
}); });
if (!res.ok && res.status !== 204) { if (!res.ok && res.status !== 204) {
throw new Error("Failed to delete recipe"); throw new Error("Failed to delete recipe");

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

103
frontend/src/authApi.js Normal file
View File

@ -0,0 +1,103 @@
// Get API base from injected env.js or fallback to /api relative path
const getApiBase = () => {
if (typeof window !== "undefined" && window.__ENV__ && window.__ENV__.API_BASE) {
return window.__ENV__.API_BASE;
}
return "/api";
};
const API_BASE = getApiBase();
export async function register(username, email, password, firstName, lastName, displayName) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
email,
password,
first_name: firstName,
last_name: lastName,
display_name: displayName
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to register");
}
return res.json();
}
export async function login(username, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to login");
}
return res.json();
}
export async function getMe(token) {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
const error = new Error("Failed to get user info");
error.status = res.status;
throw error;
}
return res.json();
}
export async function requestPasswordChangeCode(token) {
const res = await fetch(`${API_BASE}/auth/request-password-change-code`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to send verification code");
}
return res.json();
}
export async function changePassword(verificationCode, currentPassword, newPassword, token) {
const res = await fetch(`${API_BASE}/auth/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
verification_code: verificationCode,
current_password: currentPassword,
new_password: newPassword,
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to change password");
}
return res.json();
}
// Auth helpers
export function saveToken(token) {
localStorage.setItem("auth_token", token);
}
export function getToken() {
return localStorage.getItem("auth_token");
}
export function removeToken() {
localStorage.removeItem("auth_token");
}

66
frontend/src/backupApi.js Normal file
View File

@ -0,0 +1,66 @@
import { getToken } from './authApi';
const API_BASE_URL = window.ENV?.API_URL || 'http://localhost:8000';
/**
* Trigger a manual database backup (admin only)
*/
export async function triggerBackup() {
const token = getToken();
const response = await fetch(`${API_BASE_URL}/admin/backup`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create backup');
}
return response.json();
}
/**
* List all available backups (admin only)
*/
export async function listBackups() {
const token = getToken();
const response = await fetch(`${API_BASE_URL}/admin/backups`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to list backups');
}
return response.json();
}
/**
* Restore database from a backup (admin only)
*/
export async function restoreBackup(filename) {
const token = getToken();
const response = await fetch(`${API_BASE_URL}/admin/restore?filename=${encodeURIComponent(filename)}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to restore backup');
}
return response.json();
}

View File

@ -0,0 +1,177 @@
import { useState, useEffect } from 'react';
import { triggerBackup, listBackups, restoreBackup } from '../backupApi';
import Modal from './Modal';
function AdminPanel({ onShowToast }) {
const [backups, setBackups] = useState([]);
const [loading, setLoading] = useState(false);
const [restoreModal, setRestoreModal] = useState({ isOpen: false, filename: '' });
const [restoring, setRestoring] = useState(false);
useEffect(() => {
loadBackups();
}, []);
const loadBackups = async () => {
try {
setLoading(true);
const data = await listBackups();
setBackups(data.backups || []);
} catch (error) {
onShowToast(error.message || 'שגיאה בטעינת גיבויים', 'error');
} finally {
setLoading(false);
}
};
const handleCreateBackup = async () => {
try {
setLoading(true);
const result = await triggerBackup();
onShowToast('גיבוי נוצר בהצלחה! 📦', 'success');
loadBackups(); // Refresh list
} catch (error) {
onShowToast(error.message || 'שגיאה ביצירת גיבוי', 'error');
} finally {
setLoading(false);
}
};
const handleRestoreClick = (filename) => {
setRestoreModal({ isOpen: true, filename });
};
const handleRestoreConfirm = async () => {
console.log('Restore confirm clicked, filename:', restoreModal.filename);
setRestoreModal({ isOpen: false, filename: '' });
setRestoring(true);
try {
console.log('Starting restore...');
const result = await restoreBackup(restoreModal.filename);
console.log('Restore result:', result);
onShowToast('שחזור הושלם בהצלחה! ♻️ מרענן את הדף...', 'success');
// Refresh page after 2 seconds to reload all data
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (error) {
console.error('Restore error:', error);
onShowToast(error.message || 'שגיאה בשחזור גיבוי', 'error');
setRestoring(false);
}
};
const formatBytes = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (isoString) => {
const date = new Date(isoString);
return date.toLocaleString('he-IL', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="admin-panel">
<div className="admin-header">
<h2>ניהול גיבויים 🛡</h2>
<button
className="btn primary"
onClick={handleCreateBackup}
disabled={loading}
>
{loading ? 'יוצר גיבוי...' : 'צור גיבוי חדש'}
</button>
</div>
{loading && backups.length === 0 ? (
<div className="loading">טוען גיבויים...</div>
) : backups.length === 0 ? (
<div className="empty-state">אין גיבויים זמינים</div>
) : (
<div className="backups-list">
<table className="backups-table">
<thead>
<tr>
<th className="col-filename">קובץ</th>
<th className="col-date">תאריך</th>
<th className="col-size">גודל</th>
<th className="col-actions">פעולות</th>
</tr>
</thead>
<tbody>
{backups.map((backup) => (
<tr key={backup.filename}>
<td className="filename">{backup.filename}</td>
<td className="date">{formatDate(backup.last_modified)}</td>
<td className="size">{formatBytes(backup.size)}</td>
<td className="actions">
<button
className="btn ghost small"
onClick={() => handleRestoreClick(backup.filename)}
disabled={loading}
>
שחזר
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Modal
isOpen={restoreModal.isOpen || restoring}
onClose={() => !restoring && setRestoreModal({ isOpen: false, filename: '' })}
title={restoring ? "⏳ משחזר גיבוי..." : "⚠️ אישור שחזור גיבוי"}
>
{restoring ? (
<div className="restore-progress">
<div className="progress-bar-container">
<div className="progress-bar-fill"></div>
</div>
<p className="progress-text">מוריד גיבוי...</p>
<p className="progress-text">משחזר מסד נתונים...</p>
<p className="progress-text-muted">אנא המתן, התהליך עשוי לקחת מספר דקות</p>
</div>
) : (
<div className="restore-warning">
<p>פעולה זו תמחק את כל הנתונים הנוכחיים!</p>
<p>האם אתה בטוח שברצונך לשחזר מהגיבוי:</p>
<p className="filename-highlight">{restoreModal.filename}</p>
<div className="modal-actions">
<button
className="btn ghost"
onClick={() => setRestoreModal({ isOpen: false, filename: '' })}
disabled={loading}
>
ביטול
</button>
<button
className="btn danger"
onClick={handleRestoreConfirm}
disabled={loading}
>
שחזר
</button>
</div>
</div>
)}
</Modal>
</div>
);
}
export default AdminPanel;

View File

@ -0,0 +1,49 @@
import { useState, useEffect, useRef } from "react";
function AutocompleteInput({ value, onChange, onKeyDown, suggestions = [], placeholder, inputRef, ...props }) {
const [suggestion, setSuggestion] = useState("");
const localRef = useRef(null);
const ref = inputRef || localRef;
useEffect(() => {
if (value && suggestions.length > 0) {
const match = suggestions.find(s =>
s.toLowerCase().startsWith(value.toLowerCase()) && s.toLowerCase() !== value.toLowerCase()
);
setSuggestion(match || "");
} else {
setSuggestion("");
}
}, [value, suggestions]);
const handleKeyDown = (e) => {
if (e.key === 'Tab' && suggestion) {
e.preventDefault();
onChange({ target: { value: suggestion } });
setSuggestion("");
} else if (onKeyDown) {
onKeyDown(e);
}
};
return (
<div className="autocomplete-wrapper">
<input
ref={ref}
value={value}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
{suggestion && (
<div className="autocomplete-suggestion">
<span className="autocomplete-typed">{value}</span>
<span className="autocomplete-rest">{suggestion.slice(value.length)}</span>
</div>
)}
</div>
);
}
export default AutocompleteInput;

View File

@ -0,0 +1,176 @@
import { useState } from "react";
import { changePassword, requestPasswordChangeCode } from "../authApi";
export default function ChangePassword({ token, onClose, onSuccess }) {
const [step, setStep] = useState(1); // 1: request code, 2: enter code & passwords
const [verificationCode, setVerificationCode] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [codeSent, setCodeSent] = useState(false);
const handleRequestCode = async () => {
setError("");
setLoading(true);
try {
await requestPasswordChangeCode(token);
setCodeSent(true);
setStep(2);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
// Validation
if (!verificationCode || !currentPassword || !newPassword || !confirmPassword) {
setError("נא למלא את כל השדות");
return;
}
if (verificationCode.length !== 6) {
setError("קוד האימות חייב להכיל 6 ספרות");
return;
}
if (newPassword !== confirmPassword) {
setError("הסיסמאות החדשות אינן תואמות");
return;
}
if (newPassword.length < 6) {
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
return;
}
setLoading(true);
try {
await changePassword(verificationCode, currentPassword, newPassword, token);
onSuccess?.();
onClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>שינוי סיסמה</h2>
<button className="close-btn" onClick={onClose}>
×
</button>
</div>
<div className="modal-body">
{error && <div className="error-message">{error}</div>}
{step === 1 && (
<div>
<p style={{ marginBottom: "1rem", color: "var(--text-muted)" }}>
קוד אימות יישלח לכתובת המייל שלך. הקוד תקף ל-10 דקות.
</p>
<button
className="btn btn-primary full"
onClick={handleRequestCode}
disabled={loading}
>
{loading ? "שולח..." : "שלח קוד אימות"}
</button>
</div>
)}
{step === 2 && (
<form onSubmit={handleSubmit}>
{codeSent && (
<div style={{
padding: "0.75rem",
background: "rgba(34, 197, 94, 0.1)",
borderRadius: "8px",
marginBottom: "1rem",
color: "var(--accent)"
}}>
קוד אימות נשלח לכתובת המייל שלך
</div>
)}
<div className="field">
<label>קוד אימות (6 ספרות)</label>
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
disabled={loading}
autoFocus
placeholder="123456"
maxLength={6}
style={{ fontSize: "1.2rem", letterSpacing: "0.3rem", textAlign: "center" }}
/>
</div>
<div className="field">
<label>סיסמה נוכחית</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={loading}
/>
</div>
<div className="field">
<label>סיסמה חדשה</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading}
/>
</div>
<div className="field">
<label>אימות סיסמה חדשה</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
/>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
>
ביטול
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
>
{loading ? "משנה..." : "שמור סיסמה"}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,100 @@
import { useState } from "react";
import { getApiBase } from "../api";
function ForgotPassword({ onBack }) {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setMessage("");
setLoading(true);
try {
const response = await fetch(`${getApiBase()}/forgot-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setMessage(data.message);
setEmail("");
} else {
setError(data.detail || "שגיאה בשליחת הבקשה");
}
} catch (err) {
setError("שגיאה בשליחת הבקשה");
} finally {
setLoading(false);
}
};
return (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">שכחת סיסמה?</h1>
<p className="auth-subtitle">
הזן את כתובת המייל שלך ונשלח לך קישור לאיפוס הסיסמה
</p>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="error-banner">{error}</div>}
{message && (
<div
style={{
padding: "1rem",
background: "var(--success-bg, #dcfce7)",
border: "1px solid var(--success-border, #22c55e)",
borderRadius: "6px",
color: "var(--success-text, #166534)",
marginBottom: "1rem",
textAlign: "center",
}}
>
{message}
</div>
)}
<div className="field">
<label>כתובת מייל</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="הזן כתובת מייל"
autoComplete="email"
/>
</div>
<button
type="submit"
className="btn primary full-width"
disabled={loading}
>
{loading ? "שולח..." : "שלח קישור לאיפוס"}
</button>
</form>
<div className="auth-footer">
<p>
נזכרת בסיסמה?{" "}
<button className="link-btn" onClick={onBack}>
חזור להתחברות
</button>
</p>
</div>
</div>
</div>
);
}
export default ForgotPassword;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,181 @@
import { useState, useEffect } from "react";
import { login, saveToken } from "../authApi";
import ForgotPassword from "./ForgotPassword";
function Login({ onSuccess, onSwitchToRegister }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [showForgotPassword, setShowForgotPassword] = useState(false);
// Check for token in URL (from Google OAuth redirect)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
saveToken(token);
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname);
onSuccess();
}
}, [onSuccess]);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const data = await login(username, password);
saveToken(data.access_token);
onSuccess();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleGoogleLogin = () => {
const apiBase = window.__ENV__?.API_BASE || "http://localhost:8000";
window.location.href = `${apiBase}/auth/google/login`;
};
const handleAzureLogin = () => {
const apiBase = window.__ENV__?.API_BASE || "http://localhost:8000";
window.location.href = `${apiBase}/auth/azure/login`;
};
if (showForgotPassword) {
return <ForgotPassword onBack={() => setShowForgotPassword(false)} />;
}
return (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">התחברות</h1>
<p className="auth-subtitle">ברוכים השבים למתכונים שלכם</p>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="error-banner">{error}</div>}
<div className="field">
<label>שם משתמש</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
placeholder="הזן שם משתמש"
autoComplete="username"
/>
</div>
<div className="field">
<label>סיסמה</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="הזן סיסמה"
autoComplete="current-password"
/>
</div>
<button type="submit" className="btn primary full-width" disabled={loading}>
{loading ? "מתחבר..." : "התחבר"}
</button>
<div style={{ textAlign: "center", marginTop: "0.75rem" }}>
<button
type="button"
className="link-btn"
style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}
onClick={() => setShowForgotPassword(true)}
>
שכחת סיסמה?
</button>
</div>
</form>
<div style={{
margin: "1rem 0",
textAlign: "center",
color: "var(--text-muted)",
position: "relative"
}}>
<div style={{
position: "absolute",
top: "50%",
left: 0,
right: 0,
borderTop: "1px solid var(--border-subtle)",
zIndex: 0
}}></div>
<span style={{
background: "var(--card)",
padding: "0 1rem",
position: "relative",
zIndex: 1
}}>או</span>
</div>
<button
type="button"
onClick={handleGoogleLogin}
className="btn ghost full-width"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
border: "1px solid var(--border-subtle)"
}}
>
<svg width="18" height="18" viewBox="0 0 18 18">
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/>
<path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707 0-.593.102-1.17.282-1.709V4.958H.957C.347 6.173 0 7.548 0 9c0 1.452.348 2.827.957 4.042l3.007-2.335z"/>
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
</svg>
המשך עם Google
</button>
{/* <button
type="button"
onClick={handleAzureLogin}
className="btn ghost full-width"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
border: "1px solid var(--border-subtle)",
marginTop: "0.5rem"
}}
>
<svg width="18" height="18" viewBox="0 0 23 23">
<path fill="#f25022" d="M1 1h10v10H1z"/>
<path fill="#00a4ef" d="M12 1h10v10H12z"/>
<path fill="#7fba00" d="M1 12h10v10H1z"/>
<path fill="#ffb900" d="M12 12h10v10H12z"/>
</svg>
המשך עם Microsoft
</button> */}
<div className="auth-footer">
<p>
עדיין אין לך חשבון?{" "}
<button className="link-btn" onClick={onSwitchToRegister}>
הירשם עכשיו
</button>
</p>
</div>
</div>
</div>
);
}
export default Login;

View File

@ -1,4 +1,4 @@
function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מחק", cancelText = "ביטול", isDangerous = false }) { function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מחק", cancelText = "ביטול", isDangerous = false, children }) {
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@ -8,8 +8,9 @@ function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מ
<h2>{title}</h2> <h2>{title}</h2>
</header> </header>
<div className="modal-body"> <div className="modal-body">
{message} {children || message}
</div> </div>
{!children && (
<footer className="modal-footer"> <footer className="modal-footer">
<button className="btn ghost" onClick={onCancel}> <button className="btn ghost" onClick={onCancel}>
{cancelText} {cancelText}
@ -21,6 +22,7 @@ function Modal({ isOpen, title, message, onConfirm, onCancel, confirmText = "מ
{confirmText} {confirmText}
</button> </button>
</footer> </footer>
)}
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,229 @@
.notification-bell-container {
position: relative;
}
.notification-bell-btn {
position: relative;
background: var(--card-soft);
border: 1px solid var(--border-color);
padding: 0.5rem 0.75rem;
cursor: pointer;
border-radius: 8px;
font-size: 1.25rem;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.notification-bell-btn:hover {
background: var(--hover-bg);
transform: scale(1.05);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.notification-badge {
position: absolute;
top: -6px;
right: -6px;
background: #ef4444;
color: white;
font-size: 0.7rem;
font-weight: bold;
padding: 0.2rem 0.45rem;
border-radius: 12px;
min-width: 20px;
text-align: center;
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3);
}
.notification-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 420px;
max-height: 550px;
background: var(--panel-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0.98;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--card-soft);
}
.notification-header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.btn-link {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: all 0.2s;
font-weight: 500;
}
.btn-link:hover {
background: var(--accent-soft);
color: var(--accent);
}
.notification-list {
overflow-y: auto;
max-height: 450px;
}
.notification-list::-webkit-scrollbar {
width: 8px;
}
.notification-list::-webkit-scrollbar-track {
background: var(--panel-bg);
}
.notification-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.notification-list::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.notification-empty {
text-align: center;
padding: 3rem 2rem;
color: var(--text-muted);
font-size: 0.95rem;
}
.notification-item {
display: flex;
gap: 1rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
transition: all 0.2s;
position: relative;
}
.notification-item.clickable {
cursor: pointer;
}
.notification-item.clickable:hover {
background: var(--hover-bg);
}
.notification-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: transparent;
transition: background 0.2s;
}
.notification-item.unread::before {
background: var(--accent);
}
.notification-item:hover {
background: var(--hover-bg);
}
.notification-item:last-child {
border-bottom: none;
}
.notification-item.unread {
background: var(--accent-soft);
}
.notification-item.unread:hover {
background: var(--hover-bg);
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-message {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
line-height: 1.5;
color: var(--text-primary);
font-weight: 500;
}
.notification-item.read .notification-message {
font-weight: 400;
color: var(--text-secondary);
}
.notification-time {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 400;
}
.notification-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
align-items: flex-start;
}
.btn-icon-small {
background: var(--panel-bg);
border: 1px solid var(--border-color);
padding: 0.4rem 0.65rem;
cursor: pointer;
border-radius: 6px;
font-size: 0.9rem;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.btn-icon-small:hover {
background: var(--hover-bg);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-icon-small:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-icon-small.delete {
color: #ef4444;
}
.btn-icon-small.delete:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}

View File

@ -0,0 +1,202 @@
import { useState, useEffect, useRef } from "react";
import {
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
} from "../notificationApi";
import "./NotificationBell.css";
function NotificationBell({ onShowToast, onNotificationClick }) {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showDropdown, setShowDropdown] = useState(false);
const dropdownRef = useRef(null);
useEffect(() => {
loadNotifications();
// Poll for new notifications every 30 seconds
const interval = setInterval(loadNotifications, 30000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setShowDropdown(false);
}
}
if (showDropdown) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showDropdown]);
const loadNotifications = async () => {
try {
const data = await getNotifications();
setNotifications(data);
setUnreadCount(data.filter((n) => !n.is_read).length);
} catch (error) {
// If unauthorized (401), user is not logged in - don't show errors
if (error.message.includes("401") || error.message.includes("Unauthorized") || error.message.includes("User not found")) {
setNotifications([]);
setUnreadCount(0);
return;
}
// Silent fail for other polling errors
console.error("Failed to load notifications", error);
}
};
const handleMarkAsRead = async (notificationId) => {
try {
await markNotificationAsRead(notificationId);
setNotifications(
notifications.map((n) =>
n.id === notificationId ? { ...n, is_read: true } : n
)
);
setUnreadCount(Math.max(0, unreadCount - 1));
} catch (error) {
onShowToast?.(error.message, "error");
}
};
const handleNotificationClick = async (notification) => {
// Mark as read
if (!notification.is_read) {
await handleMarkAsRead(notification.id);
}
// Handle grocery share notifications
if (notification.type === "grocery_share" && notification.related_id) {
setShowDropdown(false);
onNotificationClick?.(notification.related_id);
}
};
const handleMarkAllAsRead = async () => {
try {
await markAllNotificationsAsRead();
setNotifications(notifications.map((n) => ({ ...n, is_read: true })));
setUnreadCount(0);
onShowToast?.("כל ההתראות סומנו כנקראו", "success");
} catch (error) {
onShowToast?.(error.message, "error");
}
};
const handleDelete = async (notificationId) => {
try {
await deleteNotification(notificationId);
const notification = notifications.find((n) => n.id === notificationId);
setNotifications(notifications.filter((n) => n.id !== notificationId));
if (notification && !notification.is_read) {
setUnreadCount(Math.max(0, unreadCount - 1));
}
} catch (error) {
onShowToast?.(error.message, "error");
}
};
const formatTime = (timestamp) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "עכשיו";
if (minutes < 60) return `לפני ${minutes} דקות`;
if (hours < 24) return `לפני ${hours} שעות`;
return `לפני ${days} ימים`;
};
return (
<div className="notification-bell-container" ref={dropdownRef}>
<button
className="notification-bell-btn"
onClick={() => setShowDropdown(!showDropdown)}
title="התראות"
>
🔔
{unreadCount > 0 && (
<span className="notification-badge">{unreadCount}</span>
)}
</button>
{showDropdown && (
<div className="notification-dropdown">
<div className="notification-header">
<h3>התראות</h3>
{unreadCount > 0 && (
<button
className="btn-link"
onClick={handleMarkAllAsRead}
>
סמן הכל כנקרא
</button>
)}
</div>
<div className="notification-list">
{notifications.length === 0 ? (
<div className="notification-empty">אין התראות חדשות</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={`notification-item ${
notification.is_read ? "read" : "unread"
} ${notification.type === "grocery_share" ? "clickable" : ""}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="notification-content">
<p className="notification-message">
{notification.message}
</p>
<span className="notification-time">
{formatTime(notification.created_at)}
</span>
</div>
<div className="notification-actions">
{!notification.is_read && (
<button
className="btn-icon-small"
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(notification.id);
}}
title="סמן כנקרא"
>
</button>
)}
<button
className="btn-icon-small delete"
onClick={(e) => {
e.stopPropagation();
handleDelete(notification.id);
}}
title="מחק"
>
</button>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
export default NotificationBell;

View File

@ -0,0 +1,222 @@
import { useState, useEffect } from "react";
import { getGroceryLists, updateGroceryList } from "../groceryApi";
function PinnedGroceryLists({ onShowToast }) {
const [pinnedLists, setPinnedLists] = useState([]);
useEffect(() => {
loadPinnedLists();
}, []);
const loadPinnedLists = async () => {
try {
const allLists = await getGroceryLists();
const pinned = allLists.filter((list) => list.is_pinned);
setPinnedLists(pinned);
} catch (error) {
console.error("Failed to load pinned lists", error);
}
};
const handleToggleItem = async (listId, itemIndex) => {
const list = pinnedLists.find((l) => l.id === listId);
if (!list || !list.can_edit) return;
const updatedItems = [...list.items];
const item = updatedItems[itemIndex];
if (item.startsWith("✓ ")) {
updatedItems[itemIndex] = item.substring(2);
} else {
updatedItems[itemIndex] = "✓ " + item;
}
try {
await updateGroceryList(listId, { items: updatedItems });
setPinnedLists(
pinnedLists.map((l) =>
l.id === listId ? { ...l, items: updatedItems } : l
)
);
} catch (error) {
onShowToast?.(error.message, "error");
}
};
if (pinnedLists.length === 0) {
return null;
}
return (
<div className="pinned-grocery-lists">
{pinnedLists.map((list) => (
<div key={list.id} className="pinned-note">
<div className="pin-icon">📌</div>
<h3 className="note-title">{list.name}</h3>
<ul className="note-items">
{list.items.length === 0 ? (
<li className="empty-note">הרשימה ריקה</li>
) : (
list.items.map((item, index) => {
const isChecked = item.startsWith("✓ ");
const itemText = isChecked ? item.substring(2) : item;
return (
<li
key={index}
className={`note-item ${isChecked ? "checked" : ""}`}
onClick={() =>
list.can_edit && handleToggleItem(list.id, index)
}
style={{ cursor: list.can_edit ? "pointer" : "default" }}
>
<span className="checkbox">{isChecked ? "☑" : "☐"}</span>
<span className="item-text">{itemText}</span>
</li>
);
})
)}
</ul>
</div>
))}
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap');
.pinned-grocery-lists {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
max-width: 320px;
}
.pinned-note {
position: relative;
background: linear-gradient(135deg, #fff9e6 0%, #fffaed 100%);
padding: 2rem 1.5rem 1.5rem;
border-radius: 4px;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.1),
0 4px 12px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
border: 1px solid #f5e6c8;
transform: rotate(-1deg);
transition: all 0.3s ease;
font-family: 'Caveat', cursive;
}
.pinned-note:nth-child(even) {
transform: rotate(1deg);
background: linear-gradient(135deg, #fff5e1 0%, #fff9eb 100%);
}
.pinned-note:hover {
transform: rotate(0deg) scale(1.02);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.15),
0 8px 20px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.pin-icon {
position: absolute;
top: -8px;
right: 20px;
font-size: 2rem;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2));
transform: rotate(25deg);
}
.note-title {
margin: 0 0 1rem 0;
font-size: 1.8rem;
color: #5a4a2a;
font-weight: 700;
text-align: center;
border-bottom: 2px solid rgba(90, 74, 42, 0.2);
padding-bottom: 0.5rem;
letter-spacing: 0.5px;
}
.note-items {
list-style: none;
padding: 0;
margin: 0;
}
.empty-note {
text-align: center;
color: #9a8a6a;
font-size: 1.3rem;
padding: 1rem;
font-style: italic;
}
.note-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem 0;
font-size: 1.4rem;
color: #4a3a1a;
line-height: 1.6;
transition: all 0.2s;
}
.note-item:hover {
color: #2a1a0a;
}
.note-item.checked .item-text {
text-decoration: line-through;
opacity: 0.5;
}
.checkbox {
font-size: 1.3rem;
flex-shrink: 0;
margin-top: 2px;
}
.item-text {
flex: 1;
word-break: break-word;
}
/* Paper texture overlay */
.pinned-note::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 31px,
rgba(90, 74, 42, 0.03) 31px,
rgba(90, 74, 42, 0.03) 32px
);
pointer-events: none;
border-radius: 4px;
}
/* Light mode specific adjustments */
@media (prefers-color-scheme: light) {
.pinned-note {
background: linear-gradient(135deg, #fffbf0 0%, #fffef8 100%);
border-color: #f0e0c0;
}
.pinned-note:nth-child(even) {
background: linear-gradient(135deg, #fff8e8 0%, #fffcf3 100%);
}
}
`}</style>
</div>
);
}
export default PinnedGroceryLists;

View File

@ -1,6 +1,19 @@
import placeholderImage from "../assets/placeholder.svg"; import { useEffect, useState } from "react";
import placeholderLight from "../assets/placeholder-light.png";
import placeholderDark from "../assets/placeholder-dark.png";
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }) { function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser }) {
const [theme, setTheme] = useState(document.documentElement.getAttribute('data-theme') || 'dark');
useEffect(() => {
const observer = new MutationObserver(() => {
setTheme(document.documentElement.getAttribute('data-theme') || 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
const placeholderImage = theme === 'dark' ? placeholderDark : placeholderLight;
if (!recipe) { if (!recipe) {
return ( return (
<section className="panel placeholder"> <section className="panel placeholder">
@ -13,6 +26,15 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
onShowDeleteModal(recipe.id, recipe.name); onShowDeleteModal(recipe.id, recipe.name);
}; };
// Debug ownership check
console.log('Recipe ownership check:', {
recipeUserId: recipe.user_id,
recipeUserIdType: typeof recipe.user_id,
currentUserId: currentUser?.id,
currentUserIdType: typeof currentUser?.id,
isEqual: recipe.user_id === currentUser?.id
});
return ( return (
<section className="panel recipe-card"> <section className="panel recipe-card">
{/* Recipe Image */} {/* Recipe Image */}
@ -26,8 +48,8 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
<p className="recipe-subtitle"> <p className="recipe-subtitle">
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה {translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
</p> </p>
{recipe.made_by && ( {(recipe.made_by || recipe.owner_display_name) && (
<h4 className="recipe-made-by">המתכון של: {recipe.made_by}</h4> <h4 className="recipe-made-by">המתכון של: {recipe.made_by || recipe.owner_display_name}</h4>
)} )}
</div> </div>
<div className="pill-row"> <div className="pill-row">
@ -66,6 +88,7 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
</footer> </footer>
)} )}
{isAuthenticated && currentUser && (Number(recipe.user_id) === Number(currentUser.id) || currentUser.is_admin) && (
<div className="recipe-actions"> <div className="recipe-actions">
<button className="btn ghost small" onClick={() => onEditClick(recipe)}> <button className="btn ghost small" onClick={() => onEditClick(recipe)}>
ערוך ערוך
@ -74,6 +97,7 @@ function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal }
🗑 מחק 🗑 מחק
</button> </button>
</div> </div>
)}
</section> </section>
); );
} }
@ -87,7 +111,7 @@ function translateMealType(type) {
case "dinner": case "dinner":
return "ערב"; return "ערב";
case "snack": case "snack":
return "נשנוש"; return "קינוחים";
default: default:
return type; return type;
} }

View File

@ -148,7 +148,7 @@ function translateMealType(type) {
case "dinner": case "dinner":
return "ערב"; return "ערב";
case "snack": case "snack":
return "נשנוש"; return "קינוחים";
default: default:
return type; return type;
} }

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import AutocompleteInput from "./AutocompleteInput";
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) { function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null, currentUser = null, allRecipes = [] }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [mealType, setMealType] = useState("lunch"); const [mealType, setMealType] = useState("lunch");
const [timeMinutes, setTimeMinutes] = useState(15); const [timeMinutes, setTimeMinutes] = useState(15);
@ -11,6 +12,19 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
const [ingredients, setIngredients] = useState([""]); const [ingredients, setIngredients] = useState([""]);
const [steps, setSteps] = useState([""]); const [steps, setSteps] = useState([""]);
// Extract unique made_by values for autocomplete
const uniqueMadeBy = Array.from(
new Set(allRecipes.map(r => r.made_by).filter(Boolean))
).sort();
// Extract unique ingredients for autocomplete
const uniqueIngredients = Array.from(
new Set(allRecipes.flatMap(r => r.ingredients || []).filter(Boolean))
).sort();
const lastIngredientRef = useRef(null);
const lastStepRef = useRef(null);
const isEditMode = !!editingRecipe; const isEditMode = !!editingRecipe;
useEffect(() => { useEffect(() => {
@ -20,7 +34,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
setMealType(editingRecipe.meal_type || "lunch"); setMealType(editingRecipe.meal_type || "lunch");
setTimeMinutes(editingRecipe.time_minutes || 15); setTimeMinutes(editingRecipe.time_minutes || 15);
setMadeBy(editingRecipe.made_by || ""); setMadeBy(editingRecipe.made_by || "");
setTags((editingRecipe.tags || []).join(", ")); setTags((editingRecipe.tags || []).join(" "));
setImage(editingRecipe.image || ""); setImage(editingRecipe.image || "");
setIngredients(editingRecipe.ingredients || [""]); setIngredients(editingRecipe.ingredients || [""]);
setSteps(editingRecipe.steps || [""]); setSteps(editingRecipe.steps || [""]);
@ -28,19 +42,23 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
setName(""); setName("");
setMealType("lunch"); setMealType("lunch");
setTimeMinutes(15); setTimeMinutes(15);
setMadeBy(""); setMadeBy(currentUser?.username || "");
setTags(""); setTags("");
setImage(""); setImage("");
setIngredients([""]); setIngredients([""]);
setSteps([""]); setSteps([""]);
} }
} }
}, [open, editingRecipe, isEditMode]); }, [open, editingRecipe, isEditMode, currentUser]);
if (!open) return null; if (!open) return null;
const handleAddIngredient = () => { const handleAddIngredient = () => {
setIngredients((prev) => [...prev, ""]); setIngredients((prev) => [...prev, ""]);
setTimeout(() => {
lastIngredientRef.current?.focus();
lastIngredientRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 0);
}; };
const handleChangeIngredient = (idx, value) => { const handleChangeIngredient = (idx, value) => {
@ -53,6 +71,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
const handleAddStep = () => { const handleAddStep = () => {
setSteps((prev) => [...prev, ""]); setSteps((prev) => [...prev, ""]);
setTimeout(() => {
lastStepRef.current?.focus();
lastStepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 0);
}; };
const handleChangeStep = (idx, value) => { const handleChangeStep = (idx, value) => {
@ -84,7 +106,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean); const cleanIngredients = ingredients.map((s) => s.trim()).filter(Boolean);
const cleanSteps = steps.map((s) => s.trim()).filter(Boolean); const cleanSteps = steps.map((s) => s.trim()).filter(Boolean);
const tagsArr = tags const tagsArr = tags
.split(",") .split(" ")
.map((t) => t.trim()) .map((t) => t.trim())
.filter(Boolean); .filter(Boolean);
@ -95,12 +117,9 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
tags: tagsArr, tags: tagsArr,
ingredients: cleanIngredients, ingredients: cleanIngredients,
steps: cleanSteps, steps: cleanSteps,
made_by: madeBy.trim() || currentUser?.username || "",
}; };
if (madeBy.trim()) {
payload.made_by = madeBy.trim();
}
if (image) { if (image) {
payload.image = image; payload.image = image;
} }
@ -136,7 +155,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
<option value="breakfast">בוקר</option> <option value="breakfast">בוקר</option>
<option value="lunch">צהריים</option> <option value="lunch">צהריים</option>
<option value="dinner">ערב</option> <option value="dinner">ערב</option>
<option value="snack">נשנוש</option> <option value="snack">קינוחים</option>
</select> </select>
</div> </div>
@ -154,9 +173,10 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
<div className="field"> <div className="field">
<label>המתכון של:</label> <label>המתכון של:</label>
<input <AutocompleteInput
value={madeBy} value={madeBy}
onChange={(e) => setMadeBy(e.target.value)} onChange={(e) => setMadeBy(e.target.value)}
suggestions={uniqueMadeBy}
placeholder="שם האדם שיצר את הגרסה הזו..." placeholder="שם האדם שיצר את הגרסה הזו..."
/> />
</div> </div>
@ -191,11 +211,11 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
</div> </div>
<div className="field"> <div className="field">
<label>תגיות (מופרד בפסיקים)</label> <label>תגיות (מופרד ברווחים)</label>
<input <input
value={tags} value={tags}
onChange={(e) => setTags(e.target.value)} onChange={(e) => setTags(e.target.value)}
placeholder="מהיר, טבעוני, משפחתי..." placeholder="מהיר טבעוני משפחתי..."
/> />
</div> </div>
@ -204,9 +224,17 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
<div className="dynamic-list"> <div className="dynamic-list">
{ingredients.map((val, idx) => ( {ingredients.map((val, idx) => (
<div key={idx} className="dynamic-row"> <div key={idx} className="dynamic-row">
<input <AutocompleteInput
inputRef={idx === ingredients.length - 1 ? lastIngredientRef : null}
value={val} value={val}
onChange={(e) => handleChangeIngredient(idx, e.target.value)} onChange={(e) => handleChangeIngredient(idx, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddIngredient();
}
}}
suggestions={uniqueIngredients}
placeholder="למשל: 2 ביצים" placeholder="למשל: 2 ביצים"
/> />
<button <button
@ -234,8 +262,15 @@ function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
{steps.map((val, idx) => ( {steps.map((val, idx) => (
<div key={idx} className="dynamic-row"> <div key={idx} className="dynamic-row">
<input <input
ref={idx === steps.length - 1 ? lastStepRef : null}
value={val} value={val}
onChange={(e) => handleChangeStep(idx, e.target.value)} onChange={(e) => handleChangeStep(idx, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddStep();
}
}}
placeholder="למשל: לחמם את התנור ל־180 מעלות" placeholder="למשל: לחמם את התנור ל־180 מעלות"
/> />
<button <button

View File

@ -42,7 +42,7 @@ function translateMealType(type) {
case "dinner": case "dinner":
return "ערב"; return "ערב";
case "snack": case "snack":
return "נשנוש"; return "קינוחים";
default: default:
return type; return type;
} }

View File

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import placeholderImage from "../assets/placeholder.svg"; import placeholderLight from "../assets/placeholder-light.png";
import placeholderDark from "../assets/placeholder-dark.png";
function RecipeSearchList({ function RecipeSearchList({
allRecipes, allRecipes,
@ -14,10 +15,21 @@ function RecipeSearchList({
onMaxTimeChange, onMaxTimeChange,
filterTags, filterTags,
onTagsChange, onTagsChange,
filterMadeBy, filterOwner,
onMadeByChange, onOwnerChange,
}) { }) {
const [expandFilters, setExpandFilters] = useState(false); const [expandFilters, setExpandFilters] = useState(false);
const [theme, setTheme] = useState(document.documentElement.getAttribute('data-theme') || 'dark');
useEffect(() => {
const observer = new MutationObserver(() => {
setTheme(document.documentElement.getAttribute('data-theme') || 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
const placeholderImage = theme === 'dark' ? placeholderDark : placeholderLight;
// Extract unique tags from ALL recipes (not filtered) // Extract unique tags from ALL recipes (not filtered)
const allTags = Array.from( const allTags = Array.from(
@ -27,8 +39,19 @@ function RecipeSearchList({
// Extract unique meal types from ALL recipes (not filtered) // Extract unique meal types from ALL recipes (not filtered)
const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort(); const mealTypes = Array.from(new Set(allRecipes.map((r) => r.meal_type))).sort();
// Extract unique made_by from ALL recipes (not filtered) // Extract unique made_by values from ALL recipes
const allMadeBy = Array.from(new Set(allRecipes.map((r) => r.made_by).filter(Boolean))).sort(); // The made_by field is what the user defined when creating the recipe,
// so we use it for both filtering and display
const madeByMap = new Map();
allRecipes.forEach((r) => {
if (r.made_by) {
// Always use made_by as the display name (it's the custom name the user entered)
if (!madeByMap.has(r.made_by)) {
madeByMap.set(r.made_by, r.made_by);
}
}
});
const allMadeBy = Array.from(madeByMap.keys()).sort();
// Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes // Extract max time for slider from ALL recipes (not filtered) - add 10 to make it comfortable to select max time recipes
const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0); const maxTimeInRecipes = Math.max(...allRecipes.map((r) => r.time_minutes), 0);
@ -47,10 +70,10 @@ function RecipeSearchList({
onMealTypeChange(""); onMealTypeChange("");
onMaxTimeChange(""); onMaxTimeChange("");
onTagsChange([]); onTagsChange([]);
onMadeByChange(""); onOwnerChange("");
}; };
const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterMadeBy; const hasActiveFilters = searchQuery || filterMealType || filterMaxTime || filterTags.length > 0 || filterOwner;
return ( return (
<section className="panel secondary recipe-search-list"> <section className="panel secondary recipe-search-list">
@ -165,18 +188,18 @@ function RecipeSearchList({
<label className="filter-label">המתכונים של:</label> <label className="filter-label">המתכונים של:</label>
<div className="filter-options"> <div className="filter-options">
<button <button
className={`filter-btn ${filterMadeBy === "" ? "active" : ""}`} className={`filter-btn ${filterOwner === "" ? "active" : ""}`}
onClick={() => onMadeByChange("")} onClick={() => onOwnerChange("")}
> >
הכל הכל
</button> </button>
{allMadeBy.map((person) => ( {allMadeBy.map((madeBy) => (
<button <button
key={person} key={madeBy}
className={`filter-btn ${filterMadeBy === person ? "active" : ""}`} className={`filter-btn ${filterOwner === madeBy ? "active" : ""}`}
onClick={() => onMadeByChange(person)} onClick={() => onOwnerChange(madeBy)}
> >
{person} {madeByMap.get(madeBy) || madeBy}
</button> </button>
))} ))}
</div> </div>
@ -239,7 +262,7 @@ function translateMealType(type) {
case "dinner": case "dinner":
return "ערב"; return "ערב";
case "snack": case "snack":
return "נשנוש"; return "קינוחים";
default: default:
return type; return type;
} }

View File

@ -0,0 +1,164 @@
import { useState } from "react";
import { register, login, saveToken } from "../authApi";
function Register({ onSuccess, onSwitchToLogin }) {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
// Validation
if (password !== confirmPassword) {
setError("הסיסמאות אינן תואמות");
return;
}
if (password.length < 6) {
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
return;
}
if (!displayName.trim()) {
setError("שם תצוגה הוא שדה חובה");
return;
}
setLoading(true);
try {
// Register the user
await register(username, email, password, firstName, lastName, displayName);
// Automatically login after successful registration
const response = await login(username, password);
saveToken(response.access_token);
onSuccess();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">הרשמה</h1>
<p className="auth-subtitle">צור חשבון חדש והתחל לנהל את המתכונים שלך</p>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="error-banner">{error}</div>}
<div className="field">
<label>שם פרטי</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="שם פרטי (אופציונלי)"
/>
</div>
<div className="field">
<label>שם משפחה</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="שם משפחה (אופציונלי)"
/>
</div>
<div className="field">
<label>שם תצוגה *</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
placeholder="איך תרצה שיופיע שמך?"
minLength={2}
/>
</div>
<div className="field">
<label>שם משתמש * (אנגלית בלבד)</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
placeholder="username (English only)"
autoComplete="username"
minLength={3}
pattern="[a-zA-Z0-9_-]+"
title="שם משתמש יכול להכיל רק אותיות באנגלית, מספרים, _ ו-"
/>
</div>
<div className="field">
<label>אימייל *</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<div className="field">
<label>סיסמה *</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="בחר סיסמה חזקה"
autoComplete="new-password"
minLength={6}
/>
</div>
<div className="field">
<label>אימות סיסמה *</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder="הזן סיסמה שוב"
autoComplete="new-password"
minLength={6}
/>
</div>
<button type="submit" className="btn primary full-width" disabled={loading}>
{loading ? "נרשם..." : "הירשם"}
</button>
</form>
<div className="auth-footer">
<p>
כבר יש לך חשבון?{" "}
<button className="link-btn" onClick={onSwitchToLogin}>
התחבר
</button>
</p>
</div>
</div>
</div>
);
}
export default Register;

View File

@ -0,0 +1,108 @@
import { useState, useEffect } from "react";
import { getApiBase } from "../api";
function ResetPassword({ token, onSuccess, onBack }) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("הסיסמאות אינן תואמות");
return;
}
if (password.length < 6) {
setError("הסיסמה חייבת להכיל לפחות 6 תווים");
return;
}
setLoading(true);
try {
const response = await fetch(`${getApiBase()}/reset-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token: token,
new_password: password,
}),
});
const data = await response.json();
if (response.ok) {
onSuccess();
} else {
setError(data.detail || "שגיאה באיפוס הסיסמה");
}
} catch (err) {
setError("שגיאה באיפוס הסיסמה");
} finally {
setLoading(false);
}
};
return (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">איפוס סיסמה</h1>
<p className="auth-subtitle">הזן את הסיסמה החדשה שלך</p>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="error-banner">{error}</div>}
<div className="field">
<label>סיסמה חדשה</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="הזן סיסמה חדשה"
autoComplete="new-password"
minLength={6}
/>
</div>
<div className="field">
<label>אימות סיסמה</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder="הזן שוב את הסיסמה"
autoComplete="new-password"
minLength={6}
/>
</div>
<button
type="submit"
className="btn primary full-width"
disabled={loading}
>
{loading ? "מאפס..." : "איפוס סיסמה"}
</button>
</form>
<div className="auth-footer">
<p>
<button className="link-btn" onClick={onBack}>
חזור להתחברות
</button>
</p>
</div>
</div>
</div>
);
}
export default ResetPassword;

View File

@ -1,4 +1,6 @@
function TopBar({ onAddClick }) { import NotificationBell from "./NotificationBell";
function TopBar({ onAddClick, user, onLogout, onShowToast, onNotificationClick, onAdminClick }) {
return ( return (
<header className="topbar"> <header className="topbar">
<div className="topbar-left"> <div className="topbar-left">
@ -11,10 +13,26 @@ function TopBar({ onAddClick }) {
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}> <div className="topbar-actions">
<button className="btn primary" onClick={onAddClick}> {user && <NotificationBell onShowToast={onShowToast} onNotificationClick={onNotificationClick} />}
+ מתכון חדש {user?.is_admin && (
<button className="btn ghost btn-mobile-compact" onClick={onAdminClick}>
<span className="btn-text-desktop">🛡 ניהול</span>
<span className="btn-text-mobile">🛡</span>
</button> </button>
)}
{user && (
<button className="btn primary btn-mobile-compact" onClick={onAddClick}>
<span className="btn-text-desktop">+ מתכון חדש</span>
<span className="btn-text-mobile">+</span>
</button>
)}
{onLogout && (
<button className="btn ghost btn-mobile-compact" onClick={onLogout}>
<span className="btn-text-desktop">יציאה</span>
<span className="btn-text-mobile"></span>
</button>
)}
</div> </div>
</header> </header>
); );

145
frontend/src/groceryApi.js Normal file
View File

@ -0,0 +1,145 @@
const API_URL = window.__ENV__?.API_BASE || window.ENV?.VITE_API_URL || "http://192.168.1.100:8000";
// Get auth token from localStorage
const getAuthHeaders = () => {
const token = localStorage.getItem("auth_token");
return {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
};
};
// Get all grocery lists
export const getGroceryLists = async () => {
const res = await fetch(`${API_URL}/grocery-lists`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to fetch grocery lists");
return res.json();
};
// Create a new grocery list
export const createGroceryList = async (data) => {
const res = await fetch(`${API_URL}/grocery-lists`, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to create grocery list");
}
return res.json();
};
// Get a specific grocery list
export const getGroceryList = async (id) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to fetch grocery list");
return res.json();
};
// Update a grocery list
export const updateGroceryList = async (id, data) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to update grocery list");
}
return res.json();
};
// Delete a grocery list
export const deleteGroceryList = async (id) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to delete grocery list");
}
};
// Toggle pin status for a grocery list
export const togglePinGroceryList = async (id) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, {
method: "PATCH",
headers: getAuthHeaders(),
});
if (!res.ok) {
let errorMessage = "Failed to toggle pin status";
try {
const error = await res.json();
errorMessage = error.detail || errorMessage;
} catch (e) {
errorMessage = `HTTP ${res.status}: ${res.statusText}`;
}
throw new Error(errorMessage);
}
return res.json();
};
// Share a grocery list
export const shareGroceryList = async (listId, data) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/share`, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to share grocery list");
}
return res.json();
};
// Get grocery list shares
export const getGroceryListShares = async (listId) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to fetch shares");
return res.json();
};
// Unshare a grocery list
export const unshareGroceryList = async (listId, userId) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares/${userId}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to unshare grocery list");
}
};
// Update share permissions
export const updateSharePermission = async (listId, userId, canEdit) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares/${userId}`, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify({ can_edit: canEdit }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to update share permission");
}
return res.json();
};
// Search users
export const searchUsers = async (query) => {
const res = await fetch(`${API_URL}/users/search?q=${encodeURIComponent(query)}`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to search users");
return res.json();
};

View File

@ -0,0 +1,69 @@
const API_BASE_URL = window.__ENV__?.API_BASE || window._env_?.VITE_API_URL || import.meta.env.VITE_API_URL || "http://192.168.1.100:8000";
function getAuthHeaders() {
const token = localStorage.getItem("auth_token");
return {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
};
}
export async function getNotifications(unreadOnly = false) {
const url = `${API_BASE_URL}/notifications${unreadOnly ? '?unread_only=true' : ''}`;
const response = await fetch(url, {
method: "GET",
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401) {
throw new Error("Unauthorized");
}
const errorData = await response.json().catch(() => ({ detail: "Failed to fetch notifications" }));
throw new Error(errorData.detail || "Failed to fetch notifications");
}
return response.json();
}
export async function markNotificationAsRead(notificationId) {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
method: "PATCH",
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to mark notification as read" }));
throw new Error(errorData.detail || "Failed to mark notification as read");
}
return response.json();
}
export async function markAllNotificationsAsRead() {
const response = await fetch(`${API_BASE_URL}/notifications/read-all`, {
method: "PATCH",
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to mark all notifications as read" }));
throw new Error(errorData.detail || "Failed to mark all notifications as read");
}
return response.json();
}
export async function deleteNotification(notificationId) {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Failed to delete notification" }));
throw new Error(errorData.detail || "Failed to delete notification");
}
return response.json();
}

View File

@ -5,4 +5,8 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
assetsInclude: ['**/*.svg'], assetsInclude: ['**/*.svg'],
server: {
port: 5174,
// port: 5173, // Default port - uncomment to switch back
},
}) })