Create social network
This commit is contained in:
parent
9b95ba95b8
commit
c3782810bf
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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>
|
||||
|
||||
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,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user