diff --git a/backend/app/main.py b/backend/app/main.py index bcc3cad..a3a6800 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -305,8 +305,20 @@ def setup_pgvector(): PRIMARY KEY (flashcard_id, tag_id) ) """)) - # Soft-delete for flashcard decks + # Soft-delete + sharing for flashcard decks conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP")) + conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0")) + # Deck ratings + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS flashcard_deck_ratings ( + id SERIAL PRIMARY KEY, + deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (user_id, deck_id) + ) + """)) conn.commit() diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py index e07f619..51c327a 100644 --- a/backend/app/models/flashcard.py +++ b/backend/app/models/flashcard.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint from sqlalchemy.orm import relationship from app.database import Base @@ -10,11 +10,24 @@ class FlashcardDeck(Base): section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) card_count = Column(Integer, default=0) + is_shared = Column(Integer, default=0) # 0 = private, 1 = shared created_at = Column(DateTime, default=datetime.utcnow) deleted_at = Column(DateTime, nullable=True) cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan") + ratings = relationship("FlashcardDeckRating", back_populates="deck", cascade="all, delete-orphan") user = relationship("User") + +class FlashcardDeckRating(Base): + __tablename__ = "flashcard_deck_ratings" + __table_args__ = (UniqueConstraint("user_id", "deck_id", name="uq_deck_rating_user"),) + id = Column(Integer, primary_key=True, index=True) + deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + rating = Column(Integer, nullable=False) # 1-5 + created_at = Column(DateTime, default=datetime.utcnow) + deck = relationship("FlashcardDeck", back_populates="ratings") + class Flashcard(Base): __tablename__ = "flashcards" id = Column(Integer, primary_key=True, index=True) diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py index a46f962..6c01389 100644 --- a/backend/app/routers/flashcards.py +++ b/backend/app/routers/flashcards.py @@ -7,7 +7,7 @@ from sqlalchemy import or_, text as sa_text from sqlalchemy.orm import Session from app.database import get_db -from app.models.flashcard import FlashcardDeck, Flashcard +from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating from app.models.section import Section from app.models.user import User from app.utils.auth import get_current_user, require_moderator @@ -29,6 +29,7 @@ class FlashcardDeckResponse(BaseModel): section_id: int | None = None user_id: int card_count: int + is_shared: int = 0 created_at: object deleted_at: object | None = None @@ -36,6 +37,10 @@ class FlashcardDeckResponse(BaseModel): from_attributes = True +class RatingRequest(BaseModel): + rating: int # 1-5 + + class FlashcardCardResponse(BaseModel): id: int deck_id: int @@ -148,8 +153,13 @@ def get_flashcard_deck( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Get a deck with all its cards.""" - return _own_deck_or_404(deck_id, current_user, db) + """Get a deck with all its cards. Accessible if owned or shared.""" + deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first() + if not deck: + raise HTTPException(status_code=404, detail="Deck not found") + if deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared: + raise HTTPException(status_code=403, detail="Not your deck") + return deck @router.delete("/{deck_id}", status_code=204) @@ -183,6 +193,146 @@ def restore_flashcard_deck( return {"id": deck.id, "title": deck.title, "restored": True} +# ── Sharing & rating ──────────────────────────────────────────────── + +@router.put("/{deck_id}/share") +def toggle_share_deck( + deck_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Toggle sharing on a deck. Owner or admin.""" + deck = _own_deck_or_404(deck_id, current_user, db) + deck.is_shared = 0 if deck.is_shared else 1 + db.commit() + return {"id": deck.id, "is_shared": deck.is_shared} + + +@router.delete("/{deck_id}/share", status_code=204) +def admin_unshare_deck( + deck_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Admin: force-unshare any deck.""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin only") + deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first() + if not deck: + raise HTTPException(status_code=404, detail="Deck not found") + deck.is_shared = 0 + db.commit() + + +@router.get("/shared") +def list_shared_decks( + limit: int = Query(20, le=100), + offset: int = Query(0), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List shared decks sorted by average rating (desc), with pagination.""" + from sqlalchemy import func + + # Subquery for average rating + avg_rating = db.query( + FlashcardDeckRating.deck_id, + func.avg(FlashcardDeckRating.rating).label("avg_rating"), + func.count(FlashcardDeckRating.id).label("rating_count"), + ).group_by(FlashcardDeckRating.deck_id).subquery() + + # Current user's rating per deck + my_ratings = {} + if current_user: + for r in db.query(FlashcardDeckRating).filter(FlashcardDeckRating.user_id == current_user.id).all(): + my_ratings[r.deck_id] = r.rating + + query = db.query(FlashcardDeck).filter( + FlashcardDeck.is_shared == 1, + FlashcardDeck.deleted_at.is_(None), + ) + total = query.count() + + # Join with avg rating for sorting + decks_with_rating = db.query( + FlashcardDeck, + func.coalesce(avg_rating.c.avg_rating, 0).label("avg_rating"), + func.coalesce(avg_rating.c.rating_count, 0).label("rating_count"), + ).outerjoin( + avg_rating, FlashcardDeck.id == avg_rating.c.deck_id + ).filter( + FlashcardDeck.is_shared == 1, + FlashcardDeck.deleted_at.is_(None), + ).order_by( + func.coalesce(avg_rating.c.avg_rating, 0).desc(), + FlashcardDeck.created_at.desc(), + ).offset(offset).limit(limit).all() + + # Get owner names + user_cache: dict[int, str] = {} + result = [] + for deck, avg_r, r_count in decks_with_rating: + if deck.user_id not in user_cache: + owner = db.query(User).filter(User.id == deck.user_id).first() + user_cache[deck.user_id] = owner.name if owner else "Unknown" + result.append({ + "id": deck.id, + "title": deck.title, + "card_count": deck.card_count, + "user_id": deck.user_id, + "owner_name": user_cache[deck.user_id], + "is_shared": deck.is_shared, + "created_at": deck.created_at, + "avg_rating": round(float(avg_r), 1), + "rating_count": int(r_count), + "my_rating": my_ratings.get(deck.id), + }) + + return {"total": total, "decks": result} + + +@router.post("/{deck_id}/rate") +def rate_deck( + deck_id: int, + data: RatingRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Rate a shared deck (1-5). Updates if already rated.""" + if data.rating < 1 or data.rating > 5: + raise HTTPException(status_code=400, detail="Rating must be 1-5") + + deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first() + if not deck: + raise HTTPException(status_code=404, detail="Deck not found") + if not deck.is_shared: + raise HTTPException(status_code=400, detail="Deck is not shared") + + existing = db.query(FlashcardDeckRating).filter( + FlashcardDeckRating.deck_id == deck_id, + FlashcardDeckRating.user_id == current_user.id, + ).first() + + if existing: + existing.rating = data.rating + else: + db.add(FlashcardDeckRating( + deck_id=deck_id, + user_id=current_user.id, + rating=data.rating, + )) + db.commit() + + # Return updated average + from sqlalchemy import func + avg = db.query(func.avg(FlashcardDeckRating.rating)).filter( + FlashcardDeckRating.deck_id == deck_id + ).scalar() + count = db.query(FlashcardDeckRating).filter(FlashcardDeckRating.deck_id == deck_id).count() + + return {"deck_id": deck_id, "my_rating": data.rating, "avg_rating": round(float(avg), 1), "rating_count": count} + + # ── Card browse endpoints ──────────────────────────────────────────── @router.get("/cards/browse") diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx index e1fa60b..b04e6e2 100644 --- a/frontend/src/pages/FlashcardsPage.jsx +++ b/frontend/src/pages/FlashcardsPage.jsx @@ -3,10 +3,33 @@ import { Link, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' +function StarRating({ rating, onRate, readonly = false }) { + const [hover, setHover] = useState(0) + return ( +
+ {[1, 2, 3, 4, 5].map(star => ( + onRate(star)} + onMouseEnter={readonly ? undefined : () => setHover(star)} + onMouseLeave={readonly ? undefined : () => setHover(0)} + style={{ + cursor: readonly ? 'default' : 'pointer', fontSize: '1.1rem', + color: star <= (hover || rating) ? '#f59e0b' : 'var(--border)', + transition: 'color 0.1s', + }}> + ★ + + ))} +
+ ) +} + export default function FlashcardsPage() { - const [tab, setTab] = useState('decks') // 'decks' | 'browse' | 'trash' + const [tab, setTab] = useState('decks') const [decks, setDecks] = useState([]) const [trashedDecks, setTrashedDecks] = useState([]) + const [sharedDecks, setSharedDecks] = useState([]) + const [sharedTotal, setSharedTotal] = useState(0) const [cards, setCards] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) @@ -17,7 +40,9 @@ export default function FlashcardsPage() { const [deletingCard, setDeletingCard] = useState(null) const { user } = useAuth() const navigate = useNavigate() + const isAdmin = user?.role === 'admin' const LIMIT = 50 + const SHARED_LIMIT = 12 const loadDecks = () => { api.get('/flashcards/').then(res => { @@ -30,6 +55,14 @@ export default function FlashcardsPage() { api.get('/flashcards/trash').then(res => setTrashedDecks(res.data)).catch(() => {}) } + const loadShared = (off = 0) => { + api.get('/flashcards/shared', { params: { limit: SHARED_LIMIT, offset: off } }).then(res => { + if (off === 0) setSharedDecks(res.data.decks) + else setSharedDecks(prev => [...prev, ...res.data.decks]) + setSharedTotal(res.data.total) + }).catch(() => {}) + } + useEffect(() => { loadDecks() }, []) const loadCards = async (q = searchQuery, off = 0, deckId = filterDeckId) => { @@ -47,6 +80,7 @@ export default function FlashcardsPage() { useEffect(() => { if (tab === 'browse') loadCards() if (tab === 'trash') loadTrash() + if (tab === 'shared') loadShared(0) }, [tab]) useEffect(() => { @@ -77,6 +111,31 @@ export default function FlashcardsPage() { } catch { } } + const toggleShare = async (deckId) => { + try { + const res = await api.put(`/flashcards/${deckId}/share`) + setDecks(prev => prev.map(d => d.id === deckId ? { ...d, is_shared: res.data.is_shared } : d)) + } catch { } + } + + const adminUnshare = async (deckId) => { + try { + await api.delete(`/flashcards/${deckId}/share`) + setSharedDecks(prev => prev.filter(d => d.id !== deckId)) + setSharedTotal(t => t - 1) + } catch { } + } + + const rateDeck = async (deckId, rating) => { + try { + const res = await api.post(`/flashcards/${deckId}/rate`, { rating }) + setSharedDecks(prev => prev.map(d => d.id === deckId + ? { ...d, my_rating: res.data.my_rating, avg_rating: res.data.avg_rating, rating_count: res.data.rating_count } + : d + )) + } catch { } + } + const deleteCard = async (cardId) => { try { await api.delete(`/flashcards/cards/${cardId}`) @@ -94,7 +153,8 @@ export default function FlashcardsPage() {

Flashcards

- + +
@@ -120,7 +180,7 @@ export default function FlashcardsPage() {
)} - {/* DECKS TAB */} + {/* MY DECKS TAB */} {tab === 'decks' && ( <> {decks.length === 0 && !loading && ( @@ -130,11 +190,17 @@ export default function FlashcardsPage() { {decks.map(deck => (

{deck.title}

-

- {deck.card_count} cards · {new Date(deck.created_at).toLocaleDateString()} +

+ {deck.card_count} cards �� {new Date(deck.created_at).toLocaleDateString()}

-
+ {deck.is_shared ? ( + Shared + ) : null} +
Study +
@@ -143,6 +209,47 @@ export default function FlashcardsPage() { )} + {/* SHARED TAB */} + {tab === 'shared' && ( + <> + {sharedDecks.length === 0 && ( +
No shared decks yet.
+ )} +
+ {sharedDecks.map(deck => ( +
+
+

{deck.title}

+ {isAdmin && ( + + )} +
+

+ by {deck.owner_name} · {deck.card_count} cards +

+
+ rateDeck(deck.id, r)} /> + + {deck.avg_rating > 0 ? `${deck.avg_rating}` : 'No ratings'}{deck.rating_count > 0 ? ` (${deck.rating_count})` : ''} + +
+ Study +
+ ))} +
+ {sharedDecks.length < sharedTotal && ( +
+ +
+ )} + + )} + {/* BROWSE TAB */} {tab === 'browse' && ( <>