Create social network

This commit is contained in:
dvirlabs 2025-12-19 15:18:25 +02:00
parent 9b95ba95b8
commit c3782810bf
8 changed files with 345 additions and 277 deletions

View File

@ -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

View File

@ -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()

View File

@ -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 */

View File

@ -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 */

View File

@ -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 <div className="groups-loading">Loading groups...</div>;
return <div className="groups-loading">טוען קבוצות...</div>;
}
return (
<div className="groups-container">
<div className="groups-sidebar">
<div className="groups-sidebar-header">
<h2>Recipe Groups</h2>
<h2>קבוצות מתכונים</h2>
<button onClick={() => setActiveTab("create")} className="btn-new-group">
+ New
+ חדש
</button>
</div>
<div className="groups-list">
{groups.length === 0 ? (
<p className="no-groups">No groups yet. Create one!</p>
<p className="no-groups">אין קבוצות עדיין. צור אחת!</p>
) : (
groups.map((group) => (
<div
@ -177,7 +177,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
{group.name}
</div>
<div className="group-stats">
{group.member_count} members · {group.recipe_count || 0} recipes
{group.member_count} חברים · {group.recipe_count || 0} מתכונים
</div>
</div>
))
@ -188,25 +188,25 @@ export default function Groups({ showToast, onRecipeSelect }) {
<div className="groups-main">
{activeTab === "create" ? (
<div className="create-group-form">
<h3>Create New Group</h3>
<h3>צור קבוצה חדשה</h3>
<form onSubmit={handleCreateGroup}>
<div className="form-field">
<label>Group Name *</label>
<label>שם הקבוצה *</label>
<input
type="text"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder="Family Recipes, Vegan Friends, etc."
placeholder="מתכוני משפחה, חברים טבעונים וכו'"
required
/>
</div>
<div className="form-field">
<label>Description</label>
<label>תיאור</label>
<textarea
value={newGroupDescription}
onChange={(e) => setNewGroupDescription(e.target.value)}
placeholder="What's this group about?"
placeholder="על מה הקבוצה הזאת?"
rows="3"
/>
</div>
@ -218,20 +218,20 @@ export default function Groups({ showToast, onRecipeSelect }) {
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
/>
<span>Private Group (invite only)</span>
<span>קבוצה פרטית (בהזמנה בלבד)</span>
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn-create">
Create Group
צור קבוצה
</button>
<button
type="button"
onClick={() => setActiveTab("groups")}
className="btn-cancel"
>
Cancel
ביטול
</button>
</div>
</form>
@ -255,23 +255,23 @@ export default function Groups({ showToast, onRecipeSelect }) {
className={activeTab === "recipes" ? "active" : ""}
onClick={() => setActiveTab("recipes")}
>
Recipes ({groupRecipes.length})
מתכונים ({groupRecipes.length})
</button>
<button
className={activeTab === "members" ? "active" : ""}
onClick={() => setActiveTab("members")}
>
Members ({groupDetails?.members?.length || 0})
חברים ({groupDetails?.members?.length || 0})
</button>
</div>
{activeTab === "recipes" && (
<div className="group-content">
<div className="content-header">
<h4>Shared Recipes</h4>
<h4>מתכונים משותפים</h4>
{groupDetails?.is_admin && (
<button onClick={handleShowShareRecipe} className="btn-share">
+ Share Recipe
+ שתף מתכון
</button>
)}
</div>
@ -279,17 +279,17 @@ export default function Groups({ showToast, onRecipeSelect }) {
{showShareRecipe && (
<div className="share-recipe-modal">
<div className="modal-content">
<h4>Share Recipe to Group</h4>
<h4>שתף מתכון לקבוצה</h4>
<div className="recipes-selection">
{myRecipes.map((recipe) => (
<div key={recipe.id} className="recipe-option">
<span>{recipe.name}</span>
<button onClick={() => handleShareRecipe(recipe.id)}>Share</button>
<button onClick={() => handleShareRecipe(recipe.id)}>שתף</button>
</div>
))}
</div>
<button onClick={() => setShowShareRecipe(false)} className="btn-close">
Cancel
ביטול
</button>
</div>
</div>
@ -297,7 +297,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
<div className="recipes-grid">
{groupRecipes.length === 0 ? (
<p className="empty-state">No recipes shared yet</p>
<p className="empty-state">עדיין לא שותפו מתכונים</p>
) : (
groupRecipes.map((recipe) => (
<div
@ -307,7 +307,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
>
<div className="recipe-name">{recipe.recipe_name}</div>
<div className="recipe-meta">
Shared by {recipe.shared_by_username || recipe.shared_by_email}
שותף על ידי {recipe.shared_by_username || recipe.shared_by_email}
</div>
</div>
))
@ -319,10 +319,10 @@ export default function Groups({ showToast, onRecipeSelect }) {
{activeTab === "members" && (
<div className="group-content">
<div className="content-header">
<h4>Members</h4>
<h4>חברים</h4>
{groupDetails?.is_admin && (
<button onClick={handleShowAddMember} className="btn-add-member">
+ Add Member
+ הוסף חבר
</button>
)}
</div>
@ -330,17 +330,17 @@ export default function Groups({ showToast, onRecipeSelect }) {
{showAddMember && (
<div className="add-member-modal">
<div className="modal-content">
<h4>Add Member</h4>
<h4>הוסף חבר</h4>
<div className="friends-selection">
{friends.map((friend) => (
<div key={friend.user_id} className="friend-option">
<span>{friend.username || friend.email}</span>
<button onClick={() => handleAddMember(friend.user_id)}>Add</button>
<button onClick={() => handleAddMember(friend.user_id)}>הוסף</button>
</div>
))}
</div>
<button onClick={() => setShowAddMember(false)} className="btn-close">
Cancel
ביטול
</button>
</div>
</div>
@ -351,14 +351,14 @@ export default function Groups({ showToast, onRecipeSelect }) {
<div key={member.user_id} className="member-item">
<div className="member-info">
<div className="member-name">{member.username || member.email}</div>
{member.is_admin && <span className="admin-badge">Admin</span>}
{member.is_admin && <span className="admin-badge">מנהל</span>}
</div>
{groupDetails.is_admin && !member.is_admin && (
<button
onClick={() => handleRemoveMember(member.user_id)}
className="btn-remove-member"
>
Remove
הסר
</button>
)}
</div>
@ -369,7 +369,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
</>
) : (
<div className="no-selection">
<p>Select a group or create a new one</p>
<p>בחר קבוצה או צור קבוצה חדשה</p>
</div>
)}
</div>

View File

@ -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;
}

View File

@ -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 }) {
<button
className="btn-icon-small"
onClick={() => handleMarkAsRead(notification.id)}
disabled={processingIds.has(notification.id)}
title="סמן כנקרא"
>
{processingIds.has(notification.id) ? "..." : "✓"}
</button>
)}
<button
@ -174,225 +202,6 @@ function NotificationBell({ onShowToast }) {
</div>
</div>
)}
<style jsx>{`
.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.delete {
color: #ef4444;
}
.btn-icon-small.delete:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
`}</style>
</div>
);
}

View File

@ -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() {