This commit is contained in:
dvirlabs 2025-12-19 16:17:17 +02:00
parent 3270788902
commit 653e4f0ea0
13 changed files with 457 additions and 225 deletions

202
backend/social_db_utils.py Normal file
View File

@ -0,0 +1,202 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
from psycopg2 import errors
def get_db_connection():
"""Get database connection"""
return psycopg2.connect(
host=os.getenv("DB_HOST", "localhost"),
port=int(os.getenv("DB_PORT", "5432")),
database=os.getenv("DB_NAME", "recipes_db"),
user=os.getenv("DB_USER", "recipes_user"),
password=os.getenv("DB_PASSWORD", "recipes_password"),
)
# ============= Friends System =============
def send_friend_request(sender_id: int, receiver_id: int):
"""Send a friend request"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Check if already friends
cur.execute(
"SELECT 1 FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
(sender_id, receiver_id, receiver_id, sender_id)
)
if cur.fetchone():
return {"error": "Already friends"}
# Check if request already exists
cur.execute(
"SELECT * FROM friend_requests WHERE sender_id = %s AND receiver_id = %s AND status = 'pending'",
(sender_id, receiver_id)
)
existing = cur.fetchone()
if existing:
return dict(existing)
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()
def accept_friend_request(request_id: int):
"""Accept a friend request and create friendship"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# Get request details
cur.execute(
"SELECT sender_id, receiver_id FROM friend_requests WHERE id = %s AND status = 'pending'",
(request_id,)
)
request = cur.fetchone()
if not request:
return {"error": "Request not found or already processed"}
sender_id = request["sender_id"]
receiver_id = request["receiver_id"]
# Create bidirectional friendship
cur.execute(
"INSERT INTO friendships (user_id, friend_id) VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING",
(sender_id, receiver_id, receiver_id, sender_id)
)
# Update request status
cur.execute(
"UPDATE friend_requests SET status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = %s",
(request_id,)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def reject_friend_request(request_id: int):
"""Reject a friend request"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"UPDATE friend_requests SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = %s AND status = 'pending'",
(request_id,)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def get_friend_requests(user_id: int):
"""Get pending friend requests for a user"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT fr.id AS request_id, fr.sender_id, fr.receiver_id, fr.status, fr.created_at,
u.username AS sender_username, u.display_name AS sender_display_name, u.email AS sender_email
FROM friend_requests fr
JOIN users u ON u.id = fr.sender_id
WHERE fr.receiver_id = %s AND fr.status = 'pending'
ORDER BY fr.created_at DESC
""",
(user_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()
def get_friends(user_id: int):
"""Get list of user's friends"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
cur.execute(
"""
SELECT u.id, u.username, u.display_name, u.email, f.created_at AS friends_since
FROM friendships f
JOIN users u ON u.id = f.friend_id
WHERE f.user_id = %s
ORDER BY u.display_name
""",
(user_id,)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()
def remove_friend(user_id: int, friend_id: int):
"""Remove a friend"""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"DELETE FROM friendships WHERE (user_id = %s AND friend_id = %s) OR (user_id = %s AND friend_id = %s)",
(user_id, friend_id, friend_id, user_id)
)
conn.commit()
return {"success": True}
finally:
cur.close()
conn.close()
def search_users(query: str, current_user_id: int, limit: int = 20):
"""Search for users by username or display name"""
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
search_pattern = f"%{query}%"
cur.execute(
"""
SELECT u.id, u.username, u.display_name, u.email,
EXISTS(SELECT 1 FROM friendships WHERE user_id = %s AND friend_id = u.id) AS is_friend,
EXISTS(SELECT 1 FROM friend_requests WHERE sender_id = %s AND receiver_id = u.id AND status = 'pending') AS request_sent
FROM users u
WHERE (u.username ILIKE %s OR u.display_name ILIKE %s) AND u.id != %s
ORDER BY u.display_name
LIMIT %s
""",
(current_user_id, current_user_id, search_pattern, search_pattern, current_user_id, limit)
)
return [dict(row) for row in cur.fetchall()]
finally:
cur.close()
conn.close()

View File

@ -2,7 +2,8 @@
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
<link rel="icon" type="image/png" href="/src/assets/my-recipes-logo-light.png" />
<link rel="apple-touch-icon" href="/src/assets/my-recipes-logo-light.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google-site-verification" content="xzgN8wy4yNKqR0_qZZeUqj-wfnje0v7koYpUyU1ti2I" />
<title>My Recipes | המתכונים שלי</title>

View File

@ -96,6 +96,18 @@ body {
}
}
.topbar-logo {
height: 2.5rem;
width: auto;
object-fit: contain;
}
@media (min-width: 768px) {
.topbar-logo {
height: 3rem;
}
}
.logo-emoji {
font-size: 1.8rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -582,7 +582,7 @@ function GroceryLists({ user, onShowToast }) {
</div>
)}
<style jsx>{`
<style>{`
.grocery-lists-container {
padding: 1rem;
max-width: 1400px;

View 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;
}

View File

@ -5,6 +5,7 @@ import {
markAllNotificationsAsRead,
deleteNotification,
} from "../notificationApi";
import "./NotificationBell.css";
function NotificationBell({ onShowToast }) {
const [notifications, setNotifications] = useState([]);
@ -174,225 +175,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>
);
}

View File

@ -79,7 +79,7 @@ function PinnedGroceryLists({ onShowToast }) {
</div>
))}
<style jsx>{`
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;700&display=swap');
.pinned-grocery-lists {

View File

@ -1,6 +1,19 @@
import placeholderImage from "../assets/placeholder.svg";
import { useEffect, useState } from "react";
import placeholderLight from "../assets/placeholder-light.png";
import placeholderDark from "../assets/placeholder-dark.png";
function RecipeDetails({ recipe, onEditClick, onDeleteClick, onShowDeleteModal, isAuthenticated, currentUser }) {
const [theme, setTheme] = useState(document.documentElement.getAttribute('data-theme') || 'dark');
useEffect(() => {
const observer = new MutationObserver(() => {
setTheme(document.documentElement.getAttribute('data-theme') || 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
const placeholderImage = theme === 'dark' ? placeholderDark : placeholderLight;
if (!recipe) {
return (
<section className="panel placeholder">

View File

@ -1,5 +1,6 @@
import { useState } from "react";
import placeholderImage from "../assets/placeholder.svg";
import { useState, useEffect } from "react";
import placeholderLight from "../assets/placeholder-light.png";
import placeholderDark from "../assets/placeholder-dark.png";
function RecipeSearchList({
allRecipes,