diff --git a/backend/src/database.py b/backend/src/database.py index ee186f6..ff6631d 100644 --- a/backend/src/database.py +++ b/backend/src/database.py @@ -50,6 +50,15 @@ CREATE TABLE IF NOT EXISTS clips ( -- ALTER TABLE clips ADD COLUMN title TEXT; -- run separately if needed CREATE INDEX IF NOT EXISTS idx_clips_created ON clips(created_at DESC); +-- clip_favorites: many-to-many join for user favorites (user_id defaults to 'default' until auth) +CREATE TABLE IF NOT EXISTS clip_favorites ( + clip_id TEXT NOT NULL REFERENCES clips(id) ON DELETE CASCADE, + user_id TEXT NOT NULL DEFAULT 'default', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (clip_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_favorites_user ON clip_favorites(user_id, created_at DESC); -- Migration: add artwork_filename column to existing databases -- ALTER TABLE clips ADD COLUMN artwork_filename TEXT; -- run separately if needed @@ -221,4 +230,49 @@ async def delete_clip(clip_id: str): async def delete_all_clips(): async with aiosqlite.connect(DB_PATH) as db: await db.execute("DELETE FROM clips") - await db.commit() \ No newline at end of file + await db.commit() + +# ─── Favorites ─────────────────────────────────────────────────────────────── + +async def toggle_favorite(clip_id: str, user_id: str = "default") -> bool: + """Toggle favorite status. Returns True if now favorited, False if removed.""" + async with aiosqlite.connect(DB_PATH) as db: + # Check if already favorited + async with db.execute( + "SELECT 1 FROM clip_favorites WHERE clip_id = ? AND user_id = ?", + (clip_id, user_id) + ) as cursor: + exists = await cursor.fetchone() + if exists: + await db.execute( + "DELETE FROM clip_favorites WHERE clip_id = ? AND user_id = ?", + (clip_id, user_id) + ) + await db.commit() + return False + else: + await db.execute( + "INSERT INTO clip_favorites (clip_id, user_id) VALUES (?, ?)", + (clip_id, user_id) + ) + await db.commit() + return True + +async def get_favorites(user_id: str = "default") -> list[str]: + """Get list of favorited clip IDs for a user.""" + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute( + "SELECT clip_id FROM clip_favorites WHERE user_id = ? ORDER BY created_at DESC", + (user_id,) + ) as cursor: + rows = await cursor.fetchall() + return [row[0] for row in rows] + +async def is_favorite(clip_id: str, user_id: str = "default") -> bool: + """Check if a clip is favorited by a user.""" + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute( + "SELECT 1 FROM clip_favorites WHERE clip_id = ? AND user_id = ?", + (clip_id, user_id) + ) as cursor: + return await cursor.fetchone() is not None \ No newline at end of file diff --git a/backend/src/main.py b/backend/src/main.py index a08f1c5..a629c37 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -10,7 +10,7 @@ from dotenv import load_dotenv load_dotenv(Path(__file__).parent.parent / ".env") from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, MetadataRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL -from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork, delete_all_clips, set_analysis, update_metadata +from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork, delete_all_clips, set_analysis, update_metadata, toggle_favorite, get_favorites, is_favorite from kithara_client import generate_clip, save_clip_file from waveform import generate_waveform_svg, get_waveform_path from analysis import analyze_clip @@ -569,6 +569,35 @@ async def delete_all_clips_endpoint(): await delete_all_clips() return {"deleted": len(all_clips)} +# ─── Favorites ───────────────────────────────────────────────────────────────── + +@app.post("/api/clips/{clip_id}/favorite") +async def toggle_favorite_endpoint(clip_id: str): + """Toggle favorite status for a clip. Returns {favorited: bool}.""" + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + favorited = await toggle_favorite(clip_id) + return {"favorited": favorited} + + +@app.get("/api/favorites") +async def get_favorites_endpoint(): + """Get list of favorited clip IDs for the current user.""" + ids = await get_favorites() + return {"ids": ids} + + +@app.get("/api/clips/{clip_id}/favorite") +async def check_favorite_endpoint(clip_id: str): + """Check if a clip is favorited.""" + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + is_fav = await is_favorite(clip_id) + return {"favorited": is_fav} + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/frontend/src/components/Gallery.tsx b/frontend/src/components/Gallery.tsx index c5f6a07..4edb5b2 100644 --- a/frontend/src/components/Gallery.tsx +++ b/frontend/src/components/Gallery.tsx @@ -98,24 +98,32 @@ export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, o const [showFavorites, setShowFavorites] = useState(false) const debouncedSearch = useDebounce(search, 300) - // Favorites - const [favorites, setFavorites] = useState>(() => { - try { - const raw = localStorage.getItem('kithara-favorites') - return raw ? new Set(JSON.parse(raw)) : new Set() - } catch { - return new Set() - } - }) + // Favorites (DB-backed) + const [favorites, setFavorites] = useState>(new Set()) - const toggleFavorite = useCallback((id: string) => { - setFavorites(prev => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - localStorage.setItem('kithara-favorites', JSON.stringify([...next])) - return next - }) + // Load favorites on mount + useEffect(() => { + fetch('/api/favorites') + .then(res => res.json()) + .then(data => setFavorites(new Set(data.ids || []))) + .catch(() => {}) // silently fail + }, []) + + const toggleFavorite = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/clips/${id}/favorite`, { method: 'POST' }) + if (res.ok) { + const data = await res.json() + setFavorites(prev => { + const next = new Set(prev) + if (data.favorited) next.add(id) + else next.delete(id) + return next + }) + } + } catch { + // silently fail + } }, []) // Pagination + view