Add deck sharing, star ratings, and shared deck browsing

- Users can share/unshare their flashcard decks (toggle button)
- Shared tab: paginated list of all shared decks sorted by avg rating
- Star rating system (1-5): click stars to rate, updates average live
- Each user can rate each shared deck once (upsert on re-rate)
- Admin can force-unshare any deck (remove from shared listing)
- Shared decks are study-able by anyone (read-only access)
- Owner name displayed on shared deck cards
- Load more button for shared decks (12 per page)
- New DB table: flashcard_deck_ratings (user_id+deck_id unique)
- New column: flashcard_decks.is_shared (default 0)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-04 23:37:40 +02:00
parent 800de50fc9
commit 18d0a74348
4 changed files with 293 additions and 11 deletions

View file

@ -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()

View file

@ -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)

View file

@ -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")

View file

@ -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 (
<div style={{ display: 'inline-flex', gap: 2 }}>
{[1, 2, 3, 4, 5].map(star => (
<span key={star}
onClick={readonly ? undefined : () => 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',
}}>
&#9733;
</span>
))}
</div>
)
}
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() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<h2 style={{ marginBottom: 0 }}>Flashcards</h2>
<div style={{ display: 'flex', gap: 4 }}>
<button className={`btn btn-sm ${tab === 'decks' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('decks')}>Decks</button>
<button className={`btn btn-sm ${tab === 'decks' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('decks')}>My Decks</button>
<button className={`btn btn-sm ${tab === 'shared' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('shared')}>Shared</button>
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse Cards</button>
<button className={`btn btn-sm ${tab === 'trash' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('trash')}>Trash</button>
</div>
@ -120,7 +180,7 @@ export default function FlashcardsPage() {
</div>
)}
{/* DECKS TAB */}
{/* MY DECKS TAB */}
{tab === 'decks' && (
<>
{decks.length === 0 && !loading && (
@ -130,11 +190,17 @@ export default function FlashcardsPage() {
{decks.map(deck => (
<div key={deck.id} className="card" style={{ padding: 20 }}>
<h3 style={{ fontSize: '1rem', marginBottom: 6 }}>{deck.title}</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>
{deck.card_count} cards · {new Date(deck.created_at).toLocaleDateString()}
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 4 }}>
{deck.card_count} cards <EFBFBD><EFBFBD> {new Date(deck.created_at).toLocaleDateString()}
</p>
<div style={{ display: 'flex', gap: 8 }}>
{deck.is_shared ? (
<span style={{ fontSize: '0.72rem', padding: '2px 7px', borderRadius: 10, background: '#dbeafe', color: '#1d4ed8', fontWeight: 600 }}>Shared</span>
) : null}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<Link to={`/flashcards/${deck.id}/study`} className="btn btn-primary btn-sm">Study</Link>
<button className="btn btn-secondary btn-sm" onClick={() => toggleShare(deck.id)}>
{deck.is_shared ? 'Unshare' : 'Share'}
</button>
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
</div>
</div>
@ -143,6 +209,47 @@ export default function FlashcardsPage() {
</>
)}
{/* SHARED TAB */}
{tab === 'shared' && (
<>
{sharedDecks.length === 0 && (
<div className="card"><div className="empty-state">No shared decks yet.</div></div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 12 }}>
{sharedDecks.map(deck => (
<div key={deck.id} className="card" style={{ padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
<h3 style={{ fontSize: '1rem', marginBottom: 0 }}>{deck.title}</h3>
{isAdmin && (
<button className="btn btn-sm btn-secondary" onClick={() => adminUnshare(deck.id)}
style={{ fontSize: '0.7rem', padding: '2px 6px' }} title="Remove from shared">
Unshare
</button>
)}
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.82rem', marginBottom: 6 }}>
by {deck.owner_name} · {deck.card_count} cards
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<StarRating rating={deck.my_rating || 0} onRate={(r) => rateDeck(deck.id, r)} />
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
{deck.avg_rating > 0 ? `${deck.avg_rating}` : 'No ratings'}{deck.rating_count > 0 ? ` (${deck.rating_count})` : ''}
</span>
</div>
<Link to={`/flashcards/${deck.id}/study`} className="btn btn-primary btn-sm">Study</Link>
</div>
))}
</div>
{sharedDecks.length < sharedTotal && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button className="btn btn-secondary" onClick={() => loadShared(sharedDecks.length)}>
Load more ({sharedTotal - sharedDecks.length} remaining)
</button>
</div>
)}
</>
)}
{/* BROWSE TAB */}
{tab === 'browse' && (
<>