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:
parent
800de50fc9
commit
18d0a74348
4 changed files with 293 additions and 11 deletions
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}}>
|
||||
★
|
||||
</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' && (
|
||||
<>
|
||||
|
|
|
|||
Loading…
Reference in a new issue