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)
|
||||
@@ -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<Set<string>>(() => {
|
||||
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<Set<string>>(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
|
||||
|
||||
Reference in new issue
Block a user