feat: DB-backed favorites replace localStorage
- New clip_favorites table (clip_id, user_id DEFAULT 'default', created_at) - API: POST /clips/:id/favorite (toggle), GET /favorites, GET /clips/:id/favorite - Frontend: loads favorites on mount from API, toggles via POST - Removes localStorage dependency - favorites survive cache clears - Ready for multi-user: just add real user_id when auth arrives
This commit is contained in:
1 parent
ba4d4a95fe
commit
b008a6790a
3 files changed
+110
-19
No files matched your search
+55
-1
@@ -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()
|
||||
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
|
||||
+30
-1
@@ -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)
|
||||
Reference in new issue
Block a user