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
+105
-14
No files matched your search
@@ -50,6 +50,15 @@ CREATE TABLE IF NOT EXISTS clips (
|
|||||||
-- ALTER TABLE clips ADD COLUMN title TEXT; -- run separately if needed
|
-- ALTER TABLE clips ADD COLUMN title TEXT; -- run separately if needed
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_clips_created ON clips(created_at DESC);
|
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
|
-- Migration: add artwork_filename column to existing databases
|
||||||
-- ALTER TABLE clips ADD COLUMN artwork_filename TEXT; -- run separately if needed
|
-- ALTER TABLE clips ADD COLUMN artwork_filename TEXT; -- run separately if needed
|
||||||
@@ -222,3 +231,48 @@ async def delete_all_clips():
|
|||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
await db.execute("DELETE FROM clips")
|
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")
|
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||||
|
|
||||||
from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, MetadataRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL
|
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 kithara_client import generate_clip, save_clip_file
|
||||||
from waveform import generate_waveform_svg, get_waveform_path
|
from waveform import generate_waveform_svg, get_waveform_path
|
||||||
from analysis import analyze_clip
|
from analysis import analyze_clip
|
||||||
@@ -569,6 +569,35 @@ async def delete_all_clips_endpoint():
|
|||||||
await delete_all_clips()
|
await delete_all_clips()
|
||||||
return {"deleted": len(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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
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 [showFavorites, setShowFavorites] = useState(false)
|
||||||
const debouncedSearch = useDebounce(search, 300)
|
const debouncedSearch = useDebounce(search, 300)
|
||||||
|
|
||||||
// Favorites
|
// Favorites (DB-backed)
|
||||||
const [favorites, setFavorites] = useState<Set<string>>(() => {
|
const [favorites, setFavorites] = useState<Set<string>>(new Set())
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('kithara-favorites')
|
|
||||||
return raw ? new Set(JSON.parse(raw)) : new Set()
|
|
||||||
} catch {
|
|
||||||
return new Set()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleFavorite = useCallback((id: string) => {
|
// 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 => {
|
setFavorites(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) next.delete(id)
|
if (data.favorited) next.add(id)
|
||||||
else next.add(id)
|
else next.delete(id)
|
||||||
localStorage.setItem('kithara-favorites', JSON.stringify([...next]))
|
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Pagination + view
|
// Pagination + view
|
||||||
|
|||||||
Reference in new issue
Block a user