Add feature to shared lists

This commit is contained in:
dvirlabs 2025-12-21 02:46:36 +02:00
parent c65cce9de7
commit d159cadacc
9 changed files with 284 additions and 44 deletions

View File

@ -136,7 +136,7 @@ def delete_grocery_list(list_id: int) -> bool:
def share_grocery_list(list_id: int, shared_with_user_id: int, can_edit: bool = False) -> Dict[str, Any]:
"""Share a grocery list with another user"""
"""Share a grocery list with another user or update existing share permissions"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
@ -158,6 +158,28 @@ def share_grocery_list(list_id: int, shared_with_user_id: int, can_edit: bool =
conn.close()
def update_share_permission(list_id: int, shared_with_user_id: int, can_edit: bool) -> Optional[Dict[str, Any]]:
"""Update edit permission for an existing share"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
UPDATE grocery_list_shares
SET can_edit = %s
WHERE list_id = %s AND shared_with_user_id = %s
RETURNING id, list_id, shared_with_user_id, can_edit, shared_at
""",
(can_edit, list_id, shared_with_user_id)
)
share = cur.fetchone()
conn.commit()
return dict(share) if share else None
finally:
cur.close()
conn.close()
def unshare_grocery_list(list_id: int, user_id: int) -> bool:
"""Remove sharing access for a user"""
conn = get_db_connection()

View File

@ -45,6 +45,7 @@ from grocery_db_utils import (
get_grocery_list_shares,
search_users,
toggle_grocery_list_pin,
update_share_permission,
)
from notification_db_utils import (
@ -178,6 +179,10 @@ class ShareGroceryList(BaseModel):
can_edit: bool = False
class UpdateSharePermission(BaseModel):
can_edit: bool
class GroceryListShare(BaseModel):
id: int
list_id: int
@ -1037,6 +1042,45 @@ def unshare_grocery_list_endpoint(
return
@app.patch("/grocery-lists/{list_id}/shares/{user_id}", response_model=GroceryListShare)
def update_share_permission_endpoint(
list_id: int,
user_id: int,
share_data: UpdateSharePermission,
current_user: dict = Depends(get_current_user)
):
"""Update share permissions for a user"""
# Check if current user is the owner
existing = get_grocery_list_by_id(list_id, current_user["user_id"])
if not existing:
raise HTTPException(status_code=404, detail="רשימת קניות לא נמצאה")
if not existing["is_owner"]:
raise HTTPException(status_code=403, detail="רק הבעלים יכול לשנות הרשאות")
updated_share = update_share_permission(list_id, user_id, share_data.can_edit)
if not updated_share:
raise HTTPException(status_code=404, detail="שיתוף לא נמצא")
# Get user details for response
shares = get_grocery_list_shares(list_id)
share_with_details = next((s for s in shares if s["shared_with_user_id"] == user_id), None)
if not share_with_details:
raise HTTPException(status_code=404, detail="שיתוף לא נמצא")
return GroceryListShare(
id=share_with_details["id"],
list_id=list_id,
shared_with_user_id=user_id,
username=share_with_details["username"],
display_name=share_with_details["display_name"],
email=share_with_details["email"],
can_edit=share_data.can_edit,
shared_at=share_with_details["shared_at"].isoformat() if hasattr(share_with_details["shared_at"], 'isoformat') else str(share_with_details["shared_at"])
)
@app.get("/users/search", response_model=List[UserSearch])
def search_users_endpoint(
q: str = Query(..., min_length=1, description="Search query for username or display name"),

View File

@ -1909,3 +1909,72 @@ html {
opacity: 0.5;
}
/* Share Modal Search Results Styles */
.share-search .search-results {
list-style: none;
padding: 0;
margin: 1rem 0;
max-height: 300px;
overflow-y: auto;
}
.share-search .search-results li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: background-color 0.2s;
}
.share-search .search-results li:hover:not(.disabled) {
background-color: var(--accent-soft);
}
.share-search .search-results li.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.share-search .search-results li.disabled:hover {
background-color: transparent;
}
.share-search .search-results .username {
color: var(--text-muted);
font-size: 0.85rem;
margin-left: 0.5rem;
}
.share-search .search-results .badge {
display: inline-block;
background: var(--accent-soft);
color: var(--accent);
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
margin-left: 0.5rem;
}
/* Share Actions */
.share-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.shares-list .share-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--border-subtle);
}
.shares-list .share-item .username {
color: var(--text-muted);
font-size: 0.85rem;
margin-left: 0.5rem;
}

View File

@ -30,6 +30,8 @@ function App() {
}
}); // "recipes" or "grocery-lists"
const [selectedGroceryListId, setSelectedGroceryListId] = useState(null);
const [recipes, setRecipes] = useState([]);
const [selectedRecipe, setSelectedRecipe] = useState(null);
@ -396,6 +398,10 @@ function App() {
user={user}
onLogout={handleLogout}
onShowToast={addToast}
onNotificationClick={(listId) => {
setCurrentView("grocery-lists");
setSelectedGroceryListId(listId);
}}
/>
)}
@ -457,7 +463,12 @@ function App() {
<main className="layout">
{currentView === "grocery-lists" ? (
<GroceryLists user={user} onShowToast={addToast} />
<GroceryLists
user={user}
onShowToast={addToast}
selectedListIdFromNotification={selectedGroceryListId}
onListSelected={() => setSelectedGroceryListId(null)}
/>
) : (
<>
{isAuthenticated && (

View File

@ -10,9 +10,10 @@ import {
unshareGroceryList,
searchUsers,
togglePinGroceryList,
updateSharePermission,
} from "../groceryApi";
function GroceryLists({ user, onShowToast }) {
function GroceryLists({ user, onShowToast, selectedListIdFromNotification, onListSelected }) {
const [lists, setLists] = useState([]);
const [selectedList, setSelectedList] = useState(null);
const [loading, setLoading] = useState(true);
@ -21,6 +22,7 @@ function GroceryLists({ user, onShowToast }) {
const [shares, setShares] = useState([]);
const [userSearch, setUserSearch] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [allUsers, setAllUsers] = useState([]);
const [sharePermission, setSharePermission] = useState(false);
// New list form
@ -45,9 +47,20 @@ function GroceryLists({ user, onShowToast }) {
loadLists();
}, []);
// Handle list selection from notification
useEffect(() => {
if (selectedListIdFromNotification && lists.length > 0) {
const listToSelect = lists.find(list => list.id === selectedListIdFromNotification);
if (listToSelect) {
setSelectedList(listToSelect);
onListSelected?.();
}
}
}, [selectedListIdFromNotification, lists, onListSelected]);
// Restore selected list from localStorage after lists are loaded
useEffect(() => {
if (lists.length > 0) {
if (lists.length > 0 && !selectedListIdFromNotification) {
try {
const savedListId = localStorage.getItem("selectedGroceryListId");
if (savedListId) {
@ -60,7 +73,7 @@ function GroceryLists({ user, onShowToast }) {
console.error("Failed to restore selected list", err);
}
}
}, [lists]);
}, [lists, selectedListIdFromNotification]);
const loadLists = async () => {
try {
@ -215,26 +228,46 @@ function GroceryLists({ user, onShowToast }) {
setSharePermission(false);
try {
// Load shares for this list
const sharesData = await getGroceryListShares(list.id);
setShares(sharesData);
// Load all users (use a space to bypass minimum length requirement)
const users = await searchUsers(" ");
// Sort users: recent shares first (those in sharesData), then others alphabetically
const sharedUserIds = new Set(sharesData.map(s => s.shared_with_user_id));
const recentUsers = users.filter(u => sharedUserIds.has(u.id));
const otherUsers = users.filter(u => !sharedUserIds.has(u.id));
const sortedUsers = [
...recentUsers.sort((a, b) => a.display_name.localeCompare(b.display_name)),
...otherUsers.sort((a, b) => a.display_name.localeCompare(b.display_name))
];
setAllUsers(sortedUsers);
setSearchResults(sortedUsers);
} catch (error) {
onShowToast(error.message, "error");
console.error("Failed to load users:", error);
onShowToast("לא הצלחנו לטעון את רשימת המשתמשים", "error");
}
};
const handleSearchUsers = async (query) => {
setUserSearch(query);
if (query.trim().length < 2) {
setSearchResults([]);
if (query.trim().length === 0) {
// Show all users when search is empty
setSearchResults(allUsers);
return;
}
try {
const results = await searchUsers(query);
setSearchResults(results);
} catch (error) {
onShowToast(error.message, "error");
}
// Filter from all users
const filtered = allUsers.filter(user =>
user.display_name.toLowerCase().includes(query.toLowerCase()) ||
user.username.toLowerCase().includes(query.toLowerCase())
);
setSearchResults(filtered);
};
const handleShareWithUser = async (userId, username) => {
@ -264,6 +297,22 @@ function GroceryLists({ user, onShowToast }) {
}
};
const handleTogglePermission = async (userId, currentCanEdit) => {
try {
const updatedShare = await updateSharePermission(showShareModal.id, userId, !currentCanEdit);
// Update the shares list with the new permission
setShares(shares.map((s) =>
s.shared_with_user_id === userId ? { ...s, can_edit: updatedShare.can_edit } : s
));
const message = updatedShare.can_edit
? "הרשאת עריכה ניתנה"
: "הרשאת עריכה הוסרה";
onShowToast(message, "success");
} catch (error) {
onShowToast(error.message, "error");
}
};
if (loading) {
return <div className="loading">טוען רשימות קניות...</div>;
}
@ -542,31 +591,26 @@ function GroceryLists({ user, onShowToast }) {
value={userSearch}
onChange={(e) => handleSearchUsers(e.target.value)}
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={sharePermission}
onChange={(e) => setSharePermission(e.target.checked)}
/>
<span>אפשר עריכה</span>
</label>
{searchResults.length > 0 && (
<ul className="search-results">
{searchResults.map((user) => (
<ul className="search-results">
{searchResults.map((user) => {
const alreadyShared = shares.some(s => s.shared_with_user_id === user.id);
return (
<li
key={user.id}
onClick={() => handleShareWithUser(user.id, user.username)}
onClick={() => !alreadyShared && handleShareWithUser(user.id, user.username)}
className={alreadyShared ? "disabled" : ""}
>
<div>
<strong>{user.display_name}</strong>
<span className="username">@{user.username}</span>
{alreadyShared && <span className="badge">משותף</span>}
</div>
<button className="btn small">שתף</button>
{!alreadyShared && <button className="btn small">שתף</button>}
</li>
))}
</ul>
)}
);
})}
</ul>
</div>
<div className="shares-list">
@ -580,14 +624,22 @@ function GroceryLists({ user, onShowToast }) {
<div>
<strong>{share.display_name}</strong>
<span className="username">@{share.username}</span>
{share.can_edit && <span className="badge">עורך</span>}
</div>
<button
className="btn danger small"
onClick={() => handleUnshare(share.shared_with_user_id)}
>
הסר
</button>
<div className="share-actions">
<button
className={`btn small ${share.can_edit ? 'secondary' : 'ghost'}`}
onClick={() => handleTogglePermission(share.shared_with_user_id, share.can_edit)}
title={share.can_edit ? "הסר הרשאת עריכה" : "אפשר עריכה"}
>
{share.can_edit ? "עורך" : "צופה"}
</button>
<button
className="btn danger small"
onClick={() => handleUnshare(share.shared_with_user_id)}
>
הסר
</button>
</div>
</li>
))}
</ul>

View File

@ -125,6 +125,14 @@
position: relative;
}
.notification-item.clickable {
cursor: pointer;
}
.notification-item.clickable:hover {
background: var(--hover-bg);
}
.notification-item::before {
content: '';
position: absolute;

View File

@ -7,7 +7,7 @@ import {
} from "../notificationApi";
import "./NotificationBell.css";
function NotificationBell({ onShowToast }) {
function NotificationBell({ onShowToast, onNotificationClick }) {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showDropdown, setShowDropdown] = useState(false);
@ -67,6 +67,19 @@ function NotificationBell({ onShowToast }) {
}
};
const handleNotificationClick = async (notification) => {
// Mark as read
if (!notification.is_read) {
await handleMarkAsRead(notification.id);
}
// Handle grocery share notifications
if (notification.type === "grocery_share" && notification.related_id) {
setShowDropdown(false);
onNotificationClick?.(notification.related_id);
}
};
const handleMarkAllAsRead = async () => {
try {
await markAllNotificationsAsRead();
@ -141,7 +154,8 @@ function NotificationBell({ onShowToast }) {
key={notification.id}
className={`notification-item ${
notification.is_read ? "read" : "unread"
}`}
} ${notification.type === "grocery_share" ? "clickable" : ""}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="notification-content">
<p className="notification-message">
@ -155,7 +169,10 @@ function NotificationBell({ onShowToast }) {
{!notification.is_read && (
<button
className="btn-icon-small"
onClick={() => handleMarkAsRead(notification.id)}
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(notification.id);
}}
title="סמן כנקרא"
>
@ -163,7 +180,10 @@ function NotificationBell({ onShowToast }) {
)}
<button
className="btn-icon-small delete"
onClick={() => handleDelete(notification.id)}
onClick={(e) => {
e.stopPropagation();
handleDelete(notification.id);
}}
title="מחק"
>

View File

@ -1,6 +1,6 @@
import NotificationBell from "./NotificationBell";
function TopBar({ onAddClick, user, onLogout, onShowToast }) {
function TopBar({ onAddClick, user, onLogout, onShowToast, onNotificationClick }) {
return (
<header className="topbar">
<div className="topbar-left">
@ -14,7 +14,7 @@ function TopBar({ onAddClick, user, onLogout, onShowToast }) {
</div>
<div className="topbar-actions">
{user && <NotificationBell onShowToast={onShowToast} />}
{user && <NotificationBell onShowToast={onShowToast} onNotificationClick={onNotificationClick} />}
{user && (
<button className="btn primary btn-mobile-compact" onClick={onAddClick}>
<span className="btn-text-desktop">+ מתכון חדש</span>

View File

@ -121,6 +121,20 @@ export const unshareGroceryList = async (listId, userId) => {
}
};
// Update share permissions
export const updateSharePermission = async (listId, userId, canEdit) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/shares/${userId}`, {
method: "PATCH",
headers: getAuthHeaders(),
body: JSON.stringify({ can_edit: canEdit }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || "Failed to update share permission");
}
return res.json();
};
// Search users
export const searchUsers = async (query) => {
const res = await fetch(`${API_URL}/users/search?q=${encodeURIComponent(query)}`, {