Add feature to shared lists
This commit is contained in:
parent
c65cce9de7
commit
d159cadacc
@ -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()
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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="מחק"
|
||||
>
|
||||
✕
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}`, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user