Create social network
This commit is contained in:
parent
9b95ba95b8
commit
c3782810bf
@ -238,8 +238,9 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
expose_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Include social network routers
|
# Include social network routers
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from psycopg2 import errors
|
||||||
|
|
||||||
|
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
@ -38,6 +39,7 @@ def send_friend_request(sender_id: int, receiver_id: int):
|
|||||||
if existing:
|
if existing:
|
||||||
return dict(existing)
|
return dict(existing)
|
||||||
|
|
||||||
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO friend_requests (sender_id, receiver_id)
|
INSERT INTO friend_requests (sender_id, receiver_id)
|
||||||
@ -49,6 +51,17 @@ def send_friend_request(sender_id: int, receiver_id: int):
|
|||||||
request = cur.fetchone()
|
request = cur.fetchone()
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return dict(request)
|
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:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@ -151,6 +151,14 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection p {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Messages */
|
/* Messages */
|
||||||
|
|||||||
@ -105,6 +105,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection p {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Group Header */
|
/* Group Header */
|
||||||
|
|||||||
@ -80,7 +80,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await createGroup(newGroupName, newGroupDescription, isPrivate);
|
await createGroup(newGroupName, newGroupDescription, isPrivate);
|
||||||
showToast("Group created!", "success");
|
showToast("הקבוצה נוצרה!", "success");
|
||||||
setNewGroupName("");
|
setNewGroupName("");
|
||||||
setNewGroupDescription("");
|
setNewGroupDescription("");
|
||||||
setIsPrivate(true);
|
setIsPrivate(true);
|
||||||
@ -104,7 +104,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
async function handleAddMember(friendId) {
|
async function handleAddMember(friendId) {
|
||||||
try {
|
try {
|
||||||
await addGroupMember(selectedGroup.group_id, friendId);
|
await addGroupMember(selectedGroup.group_id, friendId);
|
||||||
showToast("Member added!", "success");
|
showToast("החבר נוסף!", "success");
|
||||||
setShowAddMember(false);
|
setShowAddMember(false);
|
||||||
await loadGroupDetails();
|
await loadGroupDetails();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -113,11 +113,11 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveMember(userId) {
|
async function handleRemoveMember(userId) {
|
||||||
if (!confirm("Remove this member?")) return;
|
if (!confirm("להסיר את החבר הזה?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await removeGroupMember(selectedGroup.group_id, userId);
|
await removeGroupMember(selectedGroup.group_id, userId);
|
||||||
showToast("Member removed", "info");
|
showToast("החבר הוסר", "info");
|
||||||
await loadGroupDetails();
|
await loadGroupDetails();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message, "error");
|
showToast(error.message, "error");
|
||||||
@ -137,7 +137,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
async function handleShareRecipe(recipeId) {
|
async function handleShareRecipe(recipeId) {
|
||||||
try {
|
try {
|
||||||
await shareRecipeToGroup(selectedGroup.group_id, recipeId);
|
await shareRecipeToGroup(selectedGroup.group_id, recipeId);
|
||||||
showToast("Recipe shared!", "success");
|
showToast("המתכון שותף!", "success");
|
||||||
setShowShareRecipe(false);
|
setShowShareRecipe(false);
|
||||||
await loadGroupRecipes();
|
await loadGroupRecipes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -146,22 +146,22 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="groups-loading">Loading groups...</div>;
|
return <div className="groups-loading">טוען קבוצות...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="groups-container">
|
<div className="groups-container">
|
||||||
<div className="groups-sidebar">
|
<div className="groups-sidebar">
|
||||||
<div className="groups-sidebar-header">
|
<div className="groups-sidebar-header">
|
||||||
<h2>Recipe Groups</h2>
|
<h2>קבוצות מתכונים</h2>
|
||||||
<button onClick={() => setActiveTab("create")} className="btn-new-group">
|
<button onClick={() => setActiveTab("create")} className="btn-new-group">
|
||||||
+ New
|
+ חדש
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="groups-list">
|
<div className="groups-list">
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<p className="no-groups">No groups yet. Create one!</p>
|
<p className="no-groups">אין קבוצות עדיין. צור אחת!</p>
|
||||||
) : (
|
) : (
|
||||||
groups.map((group) => (
|
groups.map((group) => (
|
||||||
<div
|
<div
|
||||||
@ -177,7 +177,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
{group.name}
|
{group.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="group-stats">
|
<div className="group-stats">
|
||||||
{group.member_count} members · {group.recipe_count || 0} recipes
|
{group.member_count} חברים · {group.recipe_count || 0} מתכונים
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@ -188,25 +188,25 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
<div className="groups-main">
|
<div className="groups-main">
|
||||||
{activeTab === "create" ? (
|
{activeTab === "create" ? (
|
||||||
<div className="create-group-form">
|
<div className="create-group-form">
|
||||||
<h3>Create New Group</h3>
|
<h3>צור קבוצה חדשה</h3>
|
||||||
<form onSubmit={handleCreateGroup}>
|
<form onSubmit={handleCreateGroup}>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Group Name *</label>
|
<label>שם הקבוצה *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newGroupName}
|
value={newGroupName}
|
||||||
onChange={(e) => setNewGroupName(e.target.value)}
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
placeholder="Family Recipes, Vegan Friends, etc."
|
placeholder="מתכוני משפחה, חברים טבעונים וכו'"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label>Description</label>
|
<label>תיאור</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={newGroupDescription}
|
value={newGroupDescription}
|
||||||
onChange={(e) => setNewGroupDescription(e.target.value)}
|
onChange={(e) => setNewGroupDescription(e.target.value)}
|
||||||
placeholder="What's this group about?"
|
placeholder="על מה הקבוצה הזאת?"
|
||||||
rows="3"
|
rows="3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -218,20 +218,20 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
checked={isPrivate}
|
checked={isPrivate}
|
||||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>Private Group (invite only)</span>
|
<span>קבוצה פרטית (בהזמנה בלבד)</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn-create">
|
<button type="submit" className="btn-create">
|
||||||
Create Group
|
צור קבוצה
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab("groups")}
|
onClick={() => setActiveTab("groups")}
|
||||||
className="btn-cancel"
|
className="btn-cancel"
|
||||||
>
|
>
|
||||||
Cancel
|
ביטול
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -255,23 +255,23 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
className={activeTab === "recipes" ? "active" : ""}
|
className={activeTab === "recipes" ? "active" : ""}
|
||||||
onClick={() => setActiveTab("recipes")}
|
onClick={() => setActiveTab("recipes")}
|
||||||
>
|
>
|
||||||
Recipes ({groupRecipes.length})
|
מתכונים ({groupRecipes.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={activeTab === "members" ? "active" : ""}
|
className={activeTab === "members" ? "active" : ""}
|
||||||
onClick={() => setActiveTab("members")}
|
onClick={() => setActiveTab("members")}
|
||||||
>
|
>
|
||||||
Members ({groupDetails?.members?.length || 0})
|
חברים ({groupDetails?.members?.length || 0})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === "recipes" && (
|
{activeTab === "recipes" && (
|
||||||
<div className="group-content">
|
<div className="group-content">
|
||||||
<div className="content-header">
|
<div className="content-header">
|
||||||
<h4>Shared Recipes</h4>
|
<h4>מתכונים משותפים</h4>
|
||||||
{groupDetails?.is_admin && (
|
{groupDetails?.is_admin && (
|
||||||
<button onClick={handleShowShareRecipe} className="btn-share">
|
<button onClick={handleShowShareRecipe} className="btn-share">
|
||||||
+ Share Recipe
|
+ שתף מתכון
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -279,17 +279,17 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
{showShareRecipe && (
|
{showShareRecipe && (
|
||||||
<div className="share-recipe-modal">
|
<div className="share-recipe-modal">
|
||||||
<div className="modal-content">
|
<div className="modal-content">
|
||||||
<h4>Share Recipe to Group</h4>
|
<h4>שתף מתכון לקבוצה</h4>
|
||||||
<div className="recipes-selection">
|
<div className="recipes-selection">
|
||||||
{myRecipes.map((recipe) => (
|
{myRecipes.map((recipe) => (
|
||||||
<div key={recipe.id} className="recipe-option">
|
<div key={recipe.id} className="recipe-option">
|
||||||
<span>{recipe.name}</span>
|
<span>{recipe.name}</span>
|
||||||
<button onClick={() => handleShareRecipe(recipe.id)}>Share</button>
|
<button onClick={() => handleShareRecipe(recipe.id)}>שתף</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowShareRecipe(false)} className="btn-close">
|
<button onClick={() => setShowShareRecipe(false)} className="btn-close">
|
||||||
Cancel
|
ביטול
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -297,7 +297,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
|
|
||||||
<div className="recipes-grid">
|
<div className="recipes-grid">
|
||||||
{groupRecipes.length === 0 ? (
|
{groupRecipes.length === 0 ? (
|
||||||
<p className="empty-state">No recipes shared yet</p>
|
<p className="empty-state">עדיין לא שותפו מתכונים</p>
|
||||||
) : (
|
) : (
|
||||||
groupRecipes.map((recipe) => (
|
groupRecipes.map((recipe) => (
|
||||||
<div
|
<div
|
||||||
@ -307,7 +307,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
>
|
>
|
||||||
<div className="recipe-name">{recipe.recipe_name}</div>
|
<div className="recipe-name">{recipe.recipe_name}</div>
|
||||||
<div className="recipe-meta">
|
<div className="recipe-meta">
|
||||||
Shared by {recipe.shared_by_username || recipe.shared_by_email}
|
שותף על ידי {recipe.shared_by_username || recipe.shared_by_email}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@ -319,10 +319,10 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
{activeTab === "members" && (
|
{activeTab === "members" && (
|
||||||
<div className="group-content">
|
<div className="group-content">
|
||||||
<div className="content-header">
|
<div className="content-header">
|
||||||
<h4>Members</h4>
|
<h4>חברים</h4>
|
||||||
{groupDetails?.is_admin && (
|
{groupDetails?.is_admin && (
|
||||||
<button onClick={handleShowAddMember} className="btn-add-member">
|
<button onClick={handleShowAddMember} className="btn-add-member">
|
||||||
+ Add Member
|
+ הוסף חבר
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -330,17 +330,17 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
{showAddMember && (
|
{showAddMember && (
|
||||||
<div className="add-member-modal">
|
<div className="add-member-modal">
|
||||||
<div className="modal-content">
|
<div className="modal-content">
|
||||||
<h4>Add Member</h4>
|
<h4>הוסף חבר</h4>
|
||||||
<div className="friends-selection">
|
<div className="friends-selection">
|
||||||
{friends.map((friend) => (
|
{friends.map((friend) => (
|
||||||
<div key={friend.user_id} className="friend-option">
|
<div key={friend.user_id} className="friend-option">
|
||||||
<span>{friend.username || friend.email}</span>
|
<span>{friend.username || friend.email}</span>
|
||||||
<button onClick={() => handleAddMember(friend.user_id)}>Add</button>
|
<button onClick={() => handleAddMember(friend.user_id)}>הוסף</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowAddMember(false)} className="btn-close">
|
<button onClick={() => setShowAddMember(false)} className="btn-close">
|
||||||
Cancel
|
ביטול
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -351,14 +351,14 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
<div key={member.user_id} className="member-item">
|
<div key={member.user_id} className="member-item">
|
||||||
<div className="member-info">
|
<div className="member-info">
|
||||||
<div className="member-name">{member.username || member.email}</div>
|
<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>
|
</div>
|
||||||
{groupDetails.is_admin && !member.is_admin && (
|
{groupDetails.is_admin && !member.is_admin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveMember(member.user_id)}
|
onClick={() => handleRemoveMember(member.user_id)}
|
||||||
className="btn-remove-member"
|
className="btn-remove-member"
|
||||||
>
|
>
|
||||||
Remove
|
הסר
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -369,7 +369,7 @@ export default function Groups({ showToast, onRecipeSelect }) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="no-selection">
|
<div className="no-selection">
|
||||||
<p>Select a group or create a new one</p>
|
<p>בחר קבוצה או צור קבוצה חדשה</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
221
frontend/src/components/NotificationBell.css
Normal file
221
frontend/src/components/NotificationBell.css
Normal 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;
|
||||||
|
}
|
||||||
@ -5,11 +5,13 @@ import {
|
|||||||
markAllNotificationsAsRead,
|
markAllNotificationsAsRead,
|
||||||
deleteNotification,
|
deleteNotification,
|
||||||
} from "../notificationApi";
|
} from "../notificationApi";
|
||||||
|
import "./NotificationBell.css";
|
||||||
|
|
||||||
function NotificationBell({ onShowToast }) {
|
function NotificationBell({ onShowToast }) {
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [processingIds, setProcessingIds] = useState(new Set());
|
||||||
const dropdownRef = useRef(null);
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -47,12 +49,25 @@ function NotificationBell({ onShowToast }) {
|
|||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
return;
|
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
|
// Silent fail for other polling errors
|
||||||
console.error("Failed to load notifications", error);
|
console.error("Failed to load notifications", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAsRead = async (notificationId) => {
|
const handleMarkAsRead = async (notificationId) => {
|
||||||
|
// Prevent duplicate calls
|
||||||
|
if (processingIds.has(notificationId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProcessingIds(new Set(processingIds).add(notificationId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await markNotificationAsRead(notificationId);
|
await markNotificationAsRead(notificationId);
|
||||||
setNotifications(
|
setNotifications(
|
||||||
@ -62,7 +77,19 @@ function NotificationBell({ onShowToast }) {
|
|||||||
);
|
);
|
||||||
setUnreadCount(Math.max(0, unreadCount - 1));
|
setUnreadCount(Math.max(0, unreadCount - 1));
|
||||||
} catch (error) {
|
} 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
|
<button
|
||||||
className="btn-icon-small"
|
className="btn-icon-small"
|
||||||
onClick={() => handleMarkAsRead(notification.id)}
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
|
disabled={processingIds.has(notification.id)}
|
||||||
title="סמן כנקרא"
|
title="סמן כנקרא"
|
||||||
>
|
>
|
||||||
✓
|
{processingIds.has(notification.id) ? "..." : "✓"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@ -174,225 +202,6 @@ function NotificationBell({ onShowToast }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export async function getNotifications(unreadOnly = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function markNotificationAsRead(notificationId) {
|
export async function markNotificationAsRead(notificationId) {
|
||||||
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
|
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
@ -38,6 +39,13 @@ export async function markNotificationAsRead(notificationId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markAllNotificationsAsRead() {
|
export async function markAllNotificationsAsRead() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user