Skip to content

Commit a2648ea

Browse files
committed
feat: remove items from history
1 parent 4509897 commit a2648ea

2 files changed

Lines changed: 152 additions & 65 deletions

File tree

backend/routers/history.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
from fastapi import APIRouter, Depends, Query
2+
from fastapi import APIRouter, Depends, HTTPException, Query
33
from sqlalchemy.ext.asyncio import AsyncSession
44
from sqlalchemy import and_, or_, select, desc, func, delete
55
from sqlalchemy.orm import selectinload
@@ -511,6 +511,45 @@ async def mark_as_watched(
511511
return {"status": "ok", "message": f"Marked {media.title} as watched"}
512512

513513

514+
@router.delete("/event/{event_id}")
515+
async def delete_single_event(
516+
event_id: int,
517+
db: AsyncSession = Depends(get_db),
518+
current_user: User = Depends(get_current_user),
519+
):
520+
"""Delete a single watch event by its ID."""
521+
result = await db.execute(
522+
select(WatchEvent).where(
523+
WatchEvent.id == event_id,
524+
WatchEvent.user_id == current_user.id,
525+
)
526+
)
527+
event = result.scalar_one_or_none()
528+
if not event:
529+
raise HTTPException(status_code=404, detail="Event not found")
530+
531+
media_id = event.media_id
532+
await db.execute(
533+
delete(WatchEvent).where(
534+
WatchEvent.id == event_id,
535+
WatchEvent.user_id == current_user.id,
536+
)
537+
)
538+
await db.commit()
539+
540+
# Only push "unwatched" to connected services if no events remain for this media
541+
remaining = await db.execute(
542+
select(func.count()).where(
543+
WatchEvent.user_id == current_user.id,
544+
WatchEvent.media_id == media_id,
545+
)
546+
)
547+
if remaining.scalar() == 0:
548+
await _push_watch_state(db, current_user.id, [media_id], watched=False)
549+
550+
return {"status": "ok"}
551+
552+
514553
@router.delete("")
515554
async def clear_history(
516555
db: AsyncSession = Depends(get_db),

frontend/src/components/HistoryCard.astro

Lines changed: 112 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -31,71 +31,119 @@ const href = isEpisode
3131
: `/media/movie/${media.tmdb_id}`;
3232
---
3333

34-
<a href={href} class="flex gap-5 p-4 bg-zinc-900/40 hover:bg-zinc-800/60 border border-zinc-800/50 hover:border-blue-500/30 rounded-2xl transition-all duration-300 group shadow-sm hover:shadow-xl hover:shadow-blue-900/10 active:opacity-70">
35-
<!-- Poster -->
36-
<div class="w-20 shrink-0 aspect-[2/3] rounded-xl overflow-hidden bg-zinc-950 shadow-2xl ring-1 ring-white/5 group-hover:ring-blue-500/30 transition-all duration-300">
37-
{posterPath ? (
38-
<img
39-
src={posterPath}
40-
alt={title}
41-
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
42-
loading="lazy"
43-
decoding="async"
44-
/>
45-
) : (
46-
<div class="w-full h-full flex flex-col items-center justify-center text-xs text-zinc-700 p-2 text-center bg-zinc-900">
47-
<span class="text-xl mb-1 opacity-20">🎬</span>
48-
<span class="font-bold uppercase tracking-tighter truncate w-full">{title}</span>
49-
</div>
50-
)}
51-
</div>
52-
53-
<!-- Content -->
54-
<div class="flex flex-col justify-center min-w-0 flex-1 py-1">
55-
<div class="flex items-start justify-between gap-4">
56-
<div class="min-w-0 flex-1">
57-
<p class="text-lg font-black text-zinc-100 group-hover:text-blue-400 transition-colors truncate tracking-tight leading-tight mb-1">
58-
{title}
59-
</p>
60-
61-
{isEpisode ? (
62-
<p class="text-xs font-bold text-zinc-400 flex items-center gap-2">
63-
<span class="text-blue-500 tracking-tighter uppercase bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20">
64-
S{media.season_number?.toString().padStart(2, '0')}E{media.episode_number?.toString().padStart(2, '0')}
65-
</span>
66-
<span class="truncate font-medium text-zinc-500">{media.title}</span>
67-
</p>
68-
) : (
69-
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest">
70-
{year}
71-
</p>
72-
)}
73-
</div>
74-
75-
<div class="text-xs font-bold text-zinc-400 bg-zinc-950/50 border border-zinc-800 px-2 py-1 rounded-lg shrink-0 uppercase tracking-wider">
76-
{timeString}
77-
</div>
34+
<div class="relative group/card" data-event-id={event.id}>
35+
<a href={href} class="flex gap-5 p-4 bg-zinc-900/40 hover:bg-zinc-800/60 border border-zinc-800/50 hover:border-blue-500/30 rounded-2xl transition-all duration-300 group shadow-sm hover:shadow-xl hover:shadow-blue-900/10 active:opacity-70">
36+
<!-- Poster -->
37+
<div class="w-20 shrink-0 aspect-[2/3] rounded-xl overflow-hidden bg-zinc-950 shadow-2xl ring-1 ring-white/5 group-hover:ring-blue-500/30 transition-all duration-300">
38+
{posterPath ? (
39+
<img
40+
src={posterPath}
41+
alt={title}
42+
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
43+
loading="lazy"
44+
decoding="async"
45+
/>
46+
) : (
47+
<div class="w-full h-full flex flex-col items-center justify-center text-xs text-zinc-700 p-2 text-center bg-zinc-900">
48+
<span class="text-xl mb-1 opacity-20">🎬</span>
49+
<span class="font-bold uppercase tracking-tighter truncate w-full">{title}</span>
50+
</div>
51+
)}
7852
</div>
79-
80-
<!-- Progress bar if not completed -->
81-
{!event.completed && event.progress_percent > 0 ? (
82-
<div class="mt-4">
83-
<div class="flex items-center justify-between text-xs font-bold text-zinc-400 uppercase tracking-widest mb-1.5">
84-
<span>Watching</span>
85-
<span>{Math.round(event.progress_percent * 100)}%</span>
53+
54+
<!-- Content -->
55+
<div class="flex flex-col justify-center min-w-0 flex-1 py-1">
56+
<div class="flex items-start justify-between gap-4">
57+
<div class="min-w-0 flex-1">
58+
<p class="text-lg font-black text-zinc-100 group-hover:text-blue-400 transition-colors truncate tracking-tight leading-tight mb-1">
59+
{title}
60+
</p>
61+
62+
{isEpisode ? (
63+
<p class="text-xs font-bold text-zinc-400 flex items-center gap-2">
64+
<span class="text-blue-500 tracking-tighter uppercase bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20">
65+
S{media.season_number?.toString().padStart(2, '0')}E{media.episode_number?.toString().padStart(2, '0')}
66+
</span>
67+
<span class="truncate font-medium text-zinc-500">{media.title}</span>
68+
</p>
69+
) : (
70+
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest">
71+
{year}
72+
</p>
73+
)}
8674
</div>
87-
<div class="w-full bg-zinc-950 h-1.5 rounded-full overflow-hidden border border-zinc-800/50">
88-
<div
89-
class="bg-blue-600 h-full rounded-full shadow-[0_0_8px_rgba(37,99,235,0.4)]"
90-
style={`width: ${event.progress_percent * 100}%`}
91-
></div>
75+
76+
<div class="text-xs font-bold text-zinc-400 bg-zinc-950/50 border border-zinc-800 px-2 py-1 rounded-lg shrink-0 uppercase tracking-wider">
77+
{timeString}
9278
</div>
9379
</div>
94-
) : (
95-
<div class="mt-4 flex items-center gap-1.5">
96-
<span class="w-1.5 h-1.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.4)]"></span>
97-
<span class="text-xs font-bold text-zinc-400 uppercase tracking-widest">Completed</span>
98-
</div>
99-
)}
100-
</div>
101-
</a>
80+
81+
<!-- Progress bar if not completed -->
82+
{!event.completed && event.progress_percent > 0 ? (
83+
<div class="mt-4">
84+
<div class="flex items-center justify-between text-xs font-bold text-zinc-400 uppercase tracking-widest mb-1.5">
85+
<span>Watching</span>
86+
<span>{Math.round(event.progress_percent * 100)}%</span>
87+
</div>
88+
<div class="w-full bg-zinc-950 h-1.5 rounded-full overflow-hidden border border-zinc-800/50">
89+
<div
90+
class="bg-blue-600 h-full rounded-full shadow-[0_0_8px_rgba(37,99,235,0.4)]"
91+
style={`width: ${event.progress_percent * 100}%`}
92+
></div>
93+
</div>
94+
</div>
95+
) : (
96+
<div class="mt-4 flex items-center gap-1.5">
97+
<span class="w-1.5 h-1.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.4)]"></span>
98+
<span class="text-xs font-bold text-zinc-400 uppercase tracking-widest">Completed</span>
99+
</div>
100+
)}
101+
</div>
102+
</a>
103+
104+
<!-- Delete button — visible on card hover -->
105+
<button
106+
class="delete-history-event absolute bottom-2 right-2 w-7 h-7 rounded-lg flex items-center justify-center bg-zinc-900/80 border border-zinc-700/50 text-zinc-500 hover:text-red-400 hover:border-red-500/40 hover:bg-red-500/10 transition-all duration-200 cursor-pointer"
107+
title="Delete this entry"
108+
aria-label="Delete history entry"
109+
>
110+
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
111+
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
112+
</svg>
113+
</button>
114+
</div>
115+
116+
<script>
117+
document.querySelectorAll<HTMLElement>('[data-event-id]').forEach(card => {
118+
const btn = card.querySelector<HTMLButtonElement>('.delete-history-event');
119+
if (!btn) return;
120+
121+
btn.addEventListener('click', async (e) => {
122+
e.preventDefault();
123+
e.stopPropagation();
124+
125+
const eventId = card.dataset.eventId;
126+
const token = (window as any).__AUTH_TOKEN__ ?? '';
127+
128+
btn.disabled = true;
129+
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="animate-spin"><circle cx="12" cy="12" r="10" stroke-opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10" /></svg>`;
130+
131+
try {
132+
const res = await fetch(`/api/proxy/history/event/${eventId}`, {
133+
method: 'DELETE',
134+
headers: { 'Authorization': `Bearer ${token}` },
135+
});
136+
if (!res.ok) throw new Error('Failed');
137+
card.style.transition = 'opacity 0.25s, transform 0.25s';
138+
card.style.opacity = '0';
139+
card.style.transform = 'scale(0.97)';
140+
setTimeout(() => card.remove(), 250);
141+
} catch {
142+
btn.disabled = false;
143+
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
144+
btn.classList.add('text-red-400', 'border-red-500/40');
145+
setTimeout(() => btn.classList.remove('text-red-400', 'border-red-500/40'), 1500);
146+
}
147+
});
148+
});
149+
</script>

0 commit comments

Comments
 (0)