Add grocery-lists

This commit is contained in:
dvirlabs 2025-12-11 15:51:54 +02:00
parent e1515442f4
commit df7510da2e
12 changed files with 373 additions and 17 deletions

View File

@ -0,0 +1,5 @@
-- Add is_pinned column to grocery_lists table
ALTER TABLE grocery_lists ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE;
-- Verify the column was added
\d grocery_lists

View File

@ -44,7 +44,7 @@ def get_user_grocery_lists(user_id: int) -> List[Dict[str, Any]]:
try:
cur.execute(
"""
SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, gl.created_at, gl.updated_at,
SELECT DISTINCT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
u.display_name as owner_display_name,
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
@ -70,7 +70,7 @@ def get_grocery_list_by_id(list_id: int, user_id: int) -> Optional[Dict[str, Any
try:
cur.execute(
"""
SELECT gl.id, gl.name, gl.items, gl.owner_id, gl.created_at, gl.updated_at,
SELECT gl.id, gl.name, gl.items, gl.owner_id, gl.is_pinned, gl.created_at, gl.updated_at,
u.display_name as owner_display_name,
CASE WHEN gl.owner_id = %s THEN TRUE ELSE gls.can_edit END as can_edit,
CASE WHEN gl.owner_id = %s THEN TRUE ELSE FALSE END as is_owner
@ -218,3 +218,36 @@ def search_users(query: str, limit: int = 10) -> List[Dict[str, Any]]:
finally:
cur.close()
conn.close()
def toggle_grocery_list_pin(list_id: int, user_id: int) -> Optional[Dict[str, Any]]:
"""Toggle pin status for a grocery list (owner only)"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Check if user is owner
cur.execute(
"SELECT id, is_pinned FROM grocery_lists WHERE id = %s AND owner_id = %s",
(list_id, user_id)
)
result = cur.fetchone()
if not result:
return None
# Toggle pin status
new_pin_status = not result["is_pinned"]
cur.execute(
"""
UPDATE grocery_lists
SET is_pinned = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s
RETURNING id, name, items, owner_id, is_pinned, created_at, updated_at
""",
(new_pin_status, list_id)
)
updated = cur.fetchone()
conn.commit()
return dict(updated) if updated else None
finally:
cur.close()
conn.close()

View File

@ -2,7 +2,7 @@ import random
from typing import List, Optional
from datetime import timedelta
from fastapi import FastAPI, HTTPException, Query, Depends
from fastapi import FastAPI, HTTPException, Query, Depends, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr, field_validator
import os
@ -42,6 +42,7 @@ from grocery_db_utils import (
unshare_grocery_list,
get_grocery_list_shares,
search_users,
toggle_grocery_list_pin,
)
from notification_db_utils import (
@ -133,6 +134,7 @@ class GroceryList(BaseModel):
name: str
items: List[str]
owner_id: int
is_pinned: bool = False
owner_display_name: Optional[str] = None
can_edit: bool = False
is_owner: bool = False
@ -190,8 +192,9 @@ app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
max_age=0, # Disable CORS preflight caching
)
@ -548,6 +551,26 @@ def delete_grocery_list_endpoint(list_id: int, current_user: dict = Depends(get_
return
@app.options("/grocery-lists/{list_id}/pin")
async def options_pin_grocery_list(list_id: int):
"""Handle CORS preflight for pin endpoint"""
return Response(status_code=200)
@app.patch("/grocery-lists/{list_id}/pin", response_model=GroceryList)
def toggle_pin_grocery_list_endpoint(list_id: int, current_user: dict = Depends(get_current_user)):
"""Toggle pin status for a grocery list (owner only)"""
updated = toggle_grocery_list_pin(list_id, current_user["user_id"])
if not updated:
raise HTTPException(status_code=404, detail="רשימת קניות לא נמצאה או שאין לך הרשאה")
# Get full details with permissions
result = get_grocery_list_by_id(list_id, current_user["user_id"])
result["created_at"] = str(result["created_at"])
result["updated_at"] = str(result["updated_at"])
return result
@app.post("/grocery-lists/{list_id}/share", response_model=GroceryListShare)
def share_grocery_list_endpoint(
list_id: int,

View File

@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS grocery_lists (
name TEXT NOT NULL,
items TEXT[] NOT NULL DEFAULT '{}', -- Array of grocery items
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_pinned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View File

@ -104,6 +104,24 @@ body {
.layout {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.layout:has(.pinned-lists-sidebar) {
grid-template-columns: 320px minmax(0, 1fr) minmax(0, 1fr);
}
}
.pinned-lists-sidebar {
display: none;
}
@media (min-width: 960px) {
.pinned-lists-sidebar {
display: block;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
}
.sidebar,

View File

@ -6,6 +6,7 @@ import RecipeSearchList from "./components/RecipeSearchList";
import RecipeDetails from "./components/RecipeDetails";
import RecipeFormDrawer from "./components/RecipeFormDrawer";
import GroceryLists from "./components/GroceryLists";
import PinnedGroceryLists from "./components/PinnedGroceryLists";
import Modal from "./components/Modal";
import ToastContainer from "./components/ToastContainer";
import ThemeToggle from "./components/ThemeToggle";
@ -367,6 +368,11 @@ function App() {
<GroceryLists user={user} onShowToast={addToast} />
) : (
<>
{isAuthenticated && (
<aside className="pinned-lists-sidebar">
<PinnedGroceryLists onShowToast={addToast} />
</aside>
)}
<section className="sidebar">
<RecipeSearchList
allRecipes={recipes}

View File

@ -8,6 +8,7 @@ import {
getGroceryListShares,
unshareGroceryList,
searchUsers,
togglePinGroceryList,
} from "../groceryApi";
function GroceryLists({ user, onShowToast }) {
@ -181,6 +182,22 @@ function GroceryLists({ user, onShowToast }) {
}
};
const handleTogglePin = async (list) => {
try {
const updated = await togglePinGroceryList(list.id);
setLists(lists.map((l) => (l.id === updated.id ? updated : l)));
if (selectedList?.id === updated.id) {
setSelectedList(updated);
}
const message = updated.is_pinned
? "רשימה הוצמדה לדף הבית"
: "רשימה הוסרה מדף הבית";
onShowToast(message, "success");
} catch (error) {
onShowToast(error.message, "error");
}
};
const handleShowShareModal = async (list) => {
setShowShareModal(list);
setUserSearch("");
@ -411,6 +428,14 @@ function GroceryLists({ user, onShowToast }) {
</div>
<div className="view-header-actions">
{selectedList.is_owner && (
<>
<button
className={`btn-icon-action ${selectedList.is_pinned ? "pinned" : ""}`}
onClick={() => handleTogglePin(selectedList)}
title={selectedList.is_pinned ? "הסר הצמדה" : "הצמד לדף הבית"}
>
📌
</button>
<button
className="btn-icon-action"
onClick={() => handleShowShareModal(selectedList)}
@ -424,6 +449,7 @@ function GroceryLists({ user, onShowToast }) {
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
</svg>
</button>
</>
)}
{selectedList.can_edit && (
<button

View File

@ -0,0 +1,222 @@
import { useState, useEffect } from "react";
import { getGroceryLists, updateGroceryList } from "../groceryApi";
function PinnedGroceryLists({ onShowToast }) {
const [pinnedLists, setPinnedLists] = useState([]);
useEffect(() => {
loadPinnedLists();
}, []);
const loadPinnedLists = async () => {
try {
const allLists = await getGroceryLists();
const pinned = allLists.filter((list) => list.is_pinned);
setPinnedLists(pinned);
} catch (error) {
console.error("Failed to load pinned lists", error);
}
};
const handleToggleItem = async (listId, itemIndex) => {
const list = pinnedLists.find((l) => l.id === listId);
if (!list || !list.can_edit) return;
const updatedItems = [...list.items];
const item = updatedItems[itemIndex];
if (item.startsWith("✓ ")) {
updatedItems[itemIndex] = item.substring(2);
} else {
updatedItems[itemIndex] = "✓ " + item;
}
try {
await updateGroceryList(listId, { items: updatedItems });
setPinnedLists(
pinnedLists.map((l) =>
l.id === listId ? { ...l, items: updatedItems } : l
)
);
} catch (error) {
onShowToast?.(error.message, "error");
}
};
if (pinnedLists.length === 0) {
return null;
}
return (
<div className="pinned-grocery-lists">
{pinnedLists.map((list) => (
<div key={list.id} className="pinned-note">
<div className="pin-icon">📌</div>
<h3 className="note-title">{list.name}</h3>
<ul className="note-items">
{list.items.length === 0 ? (
<li className="empty-note">רשימה ריקה</li>
) : (
list.items.map((item, index) => {
const isChecked = item.startsWith("✓ ");
const itemText = isChecked ? item.substring(2) : item;
return (
<li
key={index}
className={`note-item ${isChecked ? "checked" : ""}`}
onClick={() =>
list.can_edit && handleToggleItem(list.id, index)
}
style={{ cursor: list.can_edit ? "pointer" : "default" }}
>
<span className="checkbox">{isChecked ? "☑" : "☐"}</span>
<span className="item-text">{itemText}</span>
</li>
);
})
)}
</ul>
</div>
))}
<style jsx>{`
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap');
.pinned-grocery-lists {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
max-width: 320px;
}
.pinned-note {
position: relative;
background: linear-gradient(135deg, #fff9e6 0%, #fffaed 100%);
padding: 2rem 1.5rem 1.5rem;
border-radius: 4px;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.1),
0 4px 12px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
border: 1px solid #f5e6c8;
transform: rotate(-1deg);
transition: all 0.3s ease;
font-family: 'Caveat', cursive;
}
.pinned-note:nth-child(even) {
transform: rotate(1deg);
background: linear-gradient(135deg, #fff5e1 0%, #fff9eb 100%);
}
.pinned-note:hover {
transform: rotate(0deg) scale(1.02);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.15),
0 8px 20px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.pin-icon {
position: absolute;
top: -8px;
right: 20px;
font-size: 2rem;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2));
transform: rotate(25deg);
}
.note-title {
margin: 0 0 1rem 0;
font-size: 1.8rem;
color: #5a4a2a;
font-weight: 700;
text-align: center;
border-bottom: 2px solid rgba(90, 74, 42, 0.2);
padding-bottom: 0.5rem;
letter-spacing: 0.5px;
}
.note-items {
list-style: none;
padding: 0;
margin: 0;
}
.empty-note {
text-align: center;
color: #9a8a6a;
font-size: 1.3rem;
padding: 1rem;
font-style: italic;
}
.note-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem 0;
font-size: 1.4rem;
color: #4a3a1a;
line-height: 1.6;
transition: all 0.2s;
}
.note-item:hover {
color: #2a1a0a;
}
.note-item.checked .item-text {
text-decoration: line-through;
opacity: 0.5;
}
.checkbox {
font-size: 1.3rem;
flex-shrink: 0;
margin-top: 2px;
}
.item-text {
flex: 1;
word-break: break-word;
}
/* Paper texture overlay */
.pinned-note::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 31px,
rgba(90, 74, 42, 0.03) 31px,
rgba(90, 74, 42, 0.03) 32px
);
pointer-events: none;
border-radius: 4px;
}
/* Light mode specific adjustments */
@media (prefers-color-scheme: light) {
.pinned-note {
background: linear-gradient(135deg, #fffbf0 0%, #fffef8 100%);
border-color: #f0e0c0;
}
.pinned-note:nth-child(even) {
background: linear-gradient(135deg, #fff8e8 0%, #fffcf3 100%);
}
}
`}</style>
</div>
);
}
export default PinnedGroceryLists;

View File

@ -67,6 +67,28 @@ export const deleteGroceryList = async (id) => {
}
};
// Toggle pin status for a grocery list
export const togglePinGroceryList = async (id) => {
const res = await fetch(`${API_URL}/grocery-lists/${id}/pin`, {
method: "PATCH",
headers: {
...getAuthHeaders(),
"Content-Type": "application/json",
},
});
if (!res.ok) {
let errorMessage = "Failed to toggle pin status";
try {
const error = await res.json();
errorMessage = error.detail || errorMessage;
} catch (e) {
errorMessage = `HTTP ${res.status}: ${res.statusText}`;
}
throw new Error(errorMessage);
}
return res.json();
};
// Share a grocery list
export const shareGroceryList = async (listId, data) => {
const res = await fetch(`${API_URL}/grocery-lists/${listId}/share`, {