diff --git a/backend/main.py b/backend/main.py
index 84d0c3a..4349aa0 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -238,8 +238,9 @@ app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
- allow_methods=["*"],
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
+ expose_headers=["*"],
)
# Include social network routers
diff --git a/backend/social_db_utils.py b/backend/social_db_utils.py
index f29de4e..a8d0f1e 100644
--- a/backend/social_db_utils.py
+++ b/backend/social_db_utils.py
@@ -1,6 +1,7 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
+from psycopg2 import errors
def get_db_connection():
@@ -38,17 +39,29 @@ def send_friend_request(sender_id: int, receiver_id: int):
if existing:
return dict(existing)
- 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)
+ 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()
diff --git a/frontend/src/components/Chat.css b/frontend/src/components/Chat.css
index 3d681b2..27761a7 100644
--- a/frontend/src/components/Chat.css
+++ b/frontend/src/components/Chat.css
@@ -151,6 +151,14 @@
color: var(--text-muted);
font-size: 1.1rem;
background: var(--bg);
+ padding: 2rem;
+ text-align: center;
+}
+
+.no-selection p {
+ white-space: normal;
+ word-wrap: break-word;
+ max-width: 300px;
}
/* Messages */
diff --git a/frontend/src/components/Groups.css b/frontend/src/components/Groups.css
index f6ef3f6..86eadaf 100644
--- a/frontend/src/components/Groups.css
+++ b/frontend/src/components/Groups.css
@@ -105,6 +105,14 @@
justify-content: center;
color: #999;
font-size: 1.1rem;
+ padding: 2rem;
+ text-align: center;
+}
+
+.no-selection p {
+ white-space: normal;
+ word-wrap: break-word;
+ max-width: 300px;
}
/* Group Header */
diff --git a/frontend/src/components/Groups.jsx b/frontend/src/components/Groups.jsx
index 02aeb3f..50f858e 100644
--- a/frontend/src/components/Groups.jsx
+++ b/frontend/src/components/Groups.jsx
@@ -80,7 +80,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
try {
await createGroup(newGroupName, newGroupDescription, isPrivate);
- showToast("Group created!", "success");
+ showToast("הקבוצה נוצרה!", "success");
setNewGroupName("");
setNewGroupDescription("");
setIsPrivate(true);
@@ -104,7 +104,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
async function handleAddMember(friendId) {
try {
await addGroupMember(selectedGroup.group_id, friendId);
- showToast("Member added!", "success");
+ showToast("החבר נוסף!", "success");
setShowAddMember(false);
await loadGroupDetails();
} catch (error) {
@@ -113,11 +113,11 @@ export default function Groups({ showToast, onRecipeSelect }) {
}
async function handleRemoveMember(userId) {
- if (!confirm("Remove this member?")) return;
+ if (!confirm("להסיר את החבר הזה?")) return;
try {
await removeGroupMember(selectedGroup.group_id, userId);
- showToast("Member removed", "info");
+ showToast("החבר הוסר", "info");
await loadGroupDetails();
} catch (error) {
showToast(error.message, "error");
@@ -137,7 +137,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
async function handleShareRecipe(recipeId) {
try {
await shareRecipeToGroup(selectedGroup.group_id, recipeId);
- showToast("Recipe shared!", "success");
+ showToast("המתכון שותף!", "success");
setShowShareRecipe(false);
await loadGroupRecipes();
} catch (error) {
@@ -146,22 +146,22 @@ export default function Groups({ showToast, onRecipeSelect }) {
}
if (loading) {
- return
Loading groups...
;
+ return טוען קבוצות...
;
}
return (
-
Recipe Groups
+ קבוצות מתכונים
{groups.length === 0 ? (
-
No groups yet. Create one!
+
אין קבוצות עדיין. צור אחת!
) : (
groups.map((group) => (
- {group.member_count} members · {group.recipe_count || 0} recipes
+ {group.member_count} חברים · {group.recipe_count || 0} מתכונים
))
@@ -188,25 +188,25 @@ export default function Groups({ showToast, onRecipeSelect }) {
{activeTab === "create" ? (
-
Create New Group
+
צור קבוצה חדשה
@@ -255,23 +255,23 @@ export default function Groups({ showToast, onRecipeSelect }) {
className={activeTab === "recipes" ? "active" : ""}
onClick={() => setActiveTab("recipes")}
>
- Recipes ({groupRecipes.length})
+ מתכונים ({groupRecipes.length})
{activeTab === "recipes" && (
-
Shared Recipes
+ מתכונים משותפים
{groupDetails?.is_admin && (
)}
@@ -279,17 +279,17 @@ export default function Groups({ showToast, onRecipeSelect }) {
{showShareRecipe && (
-
Share Recipe to Group
+
שתף מתכון לקבוצה
{myRecipes.map((recipe) => (
{recipe.name}
-
+
))}
@@ -297,7 +297,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
{groupRecipes.length === 0 ? (
-
No recipes shared yet
+
עדיין לא שותפו מתכונים
) : (
groupRecipes.map((recipe) => (
{recipe.recipe_name}
- Shared by {recipe.shared_by_username || recipe.shared_by_email}
+ שותף על ידי {recipe.shared_by_username || recipe.shared_by_email}
))
@@ -319,10 +319,10 @@ export default function Groups({ showToast, onRecipeSelect }) {
{activeTab === "members" && (
-
Members
+ חברים
{groupDetails?.is_admin && (
)}
@@ -330,17 +330,17 @@ export default function Groups({ showToast, onRecipeSelect }) {
{showAddMember && (
-
Add Member
+
הוסף חבר
{friends.map((friend) => (
{friend.username || friend.email}
-
+
))}
@@ -351,14 +351,14 @@ export default function Groups({ showToast, onRecipeSelect }) {
{member.username || member.email}
- {member.is_admin &&
Admin}
+ {member.is_admin &&
מנהל}
{groupDetails.is_admin && !member.is_admin && (
)}
@@ -369,7 +369,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
>
) : (
-
Select a group or create a new one
+
בחר קבוצה או צור קבוצה חדשה
)}
diff --git a/frontend/src/components/NotificationBell.css b/frontend/src/components/NotificationBell.css
new file mode 100644
index 0000000..c01a1e5
--- /dev/null
+++ b/frontend/src/components/NotificationBell.css
@@ -0,0 +1,221 @@
+.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::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;
+}
diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx
index 493f164..e8878b1 100644
--- a/frontend/src/components/NotificationBell.jsx
+++ b/frontend/src/components/NotificationBell.jsx
@@ -5,11 +5,13 @@ import {
markAllNotificationsAsRead,
deleteNotification,
} from "../notificationApi";
+import "./NotificationBell.css";
function NotificationBell({ onShowToast }) {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showDropdown, setShowDropdown] = useState(false);
+ const [processingIds, setProcessingIds] = useState(new Set());
const dropdownRef = useRef(null);
useEffect(() => {
@@ -47,12 +49,25 @@ function NotificationBell({ onShowToast }) {
setUnreadCount(0);
return;
}
+ // Catch network errors (fetch failed)
+ if (error.message.includes("Failed to fetch") || error.message.includes("NetworkError") || error.message.includes("fetch")) {
+ setNotifications([]);
+ setUnreadCount(0);
+ return;
+ }
// Silent fail for other polling errors
console.error("Failed to load notifications", error);
}
};
const handleMarkAsRead = async (notificationId) => {
+ // Prevent duplicate calls
+ if (processingIds.has(notificationId)) {
+ return;
+ }
+
+ setProcessingIds(new Set(processingIds).add(notificationId));
+
try {
await markNotificationAsRead(notificationId);
setNotifications(
@@ -62,7 +77,19 @@ function NotificationBell({ onShowToast }) {
);
setUnreadCount(Math.max(0, unreadCount - 1));
} catch (error) {
- onShowToast?.(error.message, "error");
+ console.error("Error marking notification as read:", error);
+ const errorMessage = error.message.includes("Network error")
+ ? "שגיאת רשת: לא ניתן להתחבר לשרת"
+ : error.message.includes("Failed to fetch")
+ ? "שגיאה בסימון ההתראה - בדוק את החיבור לאינטרנט"
+ : error.message;
+ onShowToast?.(errorMessage, "error");
+ } finally {
+ setProcessingIds((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(notificationId);
+ return newSet;
+ });
}
};
@@ -155,9 +182,10 @@ function NotificationBell({ onShowToast }) {
)}
)}
-
-
);
}
diff --git a/frontend/src/notificationApi.js b/frontend/src/notificationApi.js
index ab8705f..fc3b2c5 100644
--- a/frontend/src/notificationApi.js
+++ b/frontend/src/notificationApi.js
@@ -27,17 +27,25 @@ export async function getNotifications(unreadOnly = false) {
}
export async function markNotificationAsRead(notificationId) {
- const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
- method: "PATCH",
- headers: getAuthHeaders(),
- });
+ try {
+ 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");
+ 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();
+ } catch (error) {
+ // If it's a network error (fetch failed), throw a more specific error
+ if (error.message === "Failed to fetch" || error.name === "TypeError") {
+ throw new Error("Network error: Unable to connect to server");
+ }
+ throw error;
}
-
- return response.json();
}
export async function markAllNotificationsAsRead() {