Compare commits
2 Commits
cb05b739a8
...
1cd8645957
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd8645957 | ||
|
|
0d6dd94029 |
@@ -15,6 +15,8 @@ def set_sqlite_pragma(dbapi_conn, _):
|
|||||||
cursor = dbapi_conn.cursor()
|
cursor = dbapi_conn.cursor()
|
||||||
cursor.execute("PRAGMA journal_mode=WAL")
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
cursor.execute("PRAGMA foreign_keys=ON")
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ def on_startup():
|
|||||||
# Backfill descriptions for videos that don't have them yet (runs in background)
|
# Backfill descriptions for videos that don't have them yet (runs in background)
|
||||||
import threading
|
import threading
|
||||||
from .routers.channels import _enrich_missing_task
|
from .routers.channels import _enrich_missing_task
|
||||||
threading.Thread(target=_enrich_missing_task, args=(10,), daemon=True).start()
|
threading.Thread(target=_enrich_missing_task, args=(3,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ def _get_channel_or_404(db: Session, channel_id: int) -> Channel:
|
|||||||
return c
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _index_channels_batch(channel_ids: list[int], user_id: int, delay: float = 1.5):
|
||||||
|
"""Run channel syncs sequentially with a polite delay between requests."""
|
||||||
|
import time
|
||||||
|
for i, cid in enumerate(channel_ids):
|
||||||
|
if i > 0:
|
||||||
|
time.sleep(delay)
|
||||||
|
_index_channel_task(cid, user_id)
|
||||||
|
|
||||||
|
|
||||||
def _index_channel_task(channel_id: int, user_id: int):
|
def _index_channel_task(channel_id: int, user_id: int):
|
||||||
from ..database import SessionLocal
|
from ..database import SessionLocal
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -224,7 +233,9 @@ def _enrich_missing_task(limit: int = 20):
|
|||||||
"""),
|
"""),
|
||||||
{"limit": limit},
|
{"limit": limit},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
for row in rows:
|
for i, row in enumerate(rows):
|
||||||
|
if i > 0:
|
||||||
|
import time; time.sleep(2)
|
||||||
try:
|
try:
|
||||||
meta = ytdlp.fetch_video_metadata(row["youtube_video_id"])
|
meta = ytdlp.fetch_video_metadata(row["youtube_video_id"])
|
||||||
if meta:
|
if meta:
|
||||||
@@ -278,22 +289,24 @@ def sync_all_channels(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
# Only sync channels not touched in the last 6 hours to avoid hammering YouTube
|
||||||
channels = db.execute(
|
channels = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT c.id FROM channels c
|
SELECT c.id FROM channels c
|
||||||
JOIN user_channels uc ON c.id = uc.channel_id
|
JOIN user_channels uc ON c.id = uc.channel_id
|
||||||
WHERE uc.user_id = :uid AND uc.status = 'followed'
|
WHERE uc.user_id = :uid AND uc.status = 'followed'
|
||||||
|
AND (c.crawled_at IS NULL OR c.crawled_at < datetime('now', '-6 hours'))
|
||||||
|
ORDER BY COALESCE(c.crawled_at, '1970-01-01') ASC
|
||||||
"""),
|
"""),
|
||||||
{"uid": current_user.id},
|
{"uid": current_user.id},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
|
|
||||||
for row in channels:
|
|
||||||
background_tasks.add_task(_index_channel_task, row["id"], current_user.id)
|
|
||||||
|
|
||||||
if channels:
|
if channels:
|
||||||
|
ids = [row["id"] for row in channels]
|
||||||
|
background_tasks.add_task(_index_channels_batch, ids, current_user.id)
|
||||||
background_tasks.add_task(_discovery_task, current_user.id)
|
background_tasks.add_task(_discovery_task, current_user.id)
|
||||||
|
|
||||||
background_tasks.add_task(_enrich_missing_task, 20)
|
background_tasks.add_task(_enrich_missing_task, 5)
|
||||||
|
|
||||||
return {"indexing": len(channels)}
|
return {"indexing": len(channels)}
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,8 @@ function IconBtn({ onClick, title, active, pending, children }) {
|
|||||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||||
title={title}
|
title={title}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-150",
|
"flex items-center justify-center w-7 h-7 rounded-full transition-all duration-150",
|
||||||
active
|
active ? "text-accent" : "text-zinc-600 hover:text-zinc-200",
|
||||||
? "text-accent"
|
|
||||||
: "text-zinc-600 hover:text-zinc-200",
|
|
||||||
pending && "opacity-60 cursor-default",
|
pending && "opacity-60 cursor-default",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -104,7 +102,6 @@ function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, class
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dismiss */}
|
|
||||||
{video.is_recommended && !calmMode && (
|
{video.is_recommended && !calmMode && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDismiss?.(); }}
|
onClick={(e) => { e.stopPropagation(); onDismiss?.(); }}
|
||||||
@@ -117,35 +114,30 @@ function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, class
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Duration */}
|
|
||||||
{duration && (
|
{duration && (
|
||||||
<span className="absolute bottom-2 right-2 bg-black/75 text-white text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono tabular-nums">
|
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[10px] font-medium px-1.5 py-0.5 rounded font-mono tabular-nums">
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resolution */}
|
|
||||||
{video.download_resolution && (
|
{video.download_resolution && (
|
||||||
<span className="absolute bottom-2 left-2 text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono text-accent bg-black/75">
|
<span className="absolute bottom-1.5 left-1.5 text-[10px] font-medium px-1.5 py-0.5 rounded font-mono text-accent bg-black/75">
|
||||||
{video.download_resolution}
|
{video.download_resolution}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Watched dot */}
|
|
||||||
{isWatched && (
|
{isWatched && (
|
||||||
<span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_rgba(var(--color-accent-rgb),0.8)]" />
|
<span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_rgba(var(--color-accent-rgb),0.8)]" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Play hover overlay */}
|
<div className="absolute inset-0 bg-black/25 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-white/15 backdrop-blur-sm flex items-center justify-center border border-white/20">
|
||||||
<div className="w-11 h-11 rounded-full bg-white/15 backdrop-blur-sm flex items-center justify-center border border-white/20">
|
|
||||||
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
{video.watch_progress_seconds > 0 && video.duration_seconds > 0 && (
|
{video.watch_progress_seconds > 0 && video.duration_seconds > 0 && (
|
||||||
<div className="absolute bottom-0 inset-x-0 h-[3px] bg-white/10">
|
<div className="absolute bottom-0 inset-x-0 h-[3px] bg-white/10">
|
||||||
<div
|
<div
|
||||||
@@ -176,13 +168,15 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
? (Date.now() - new Date(channelMeta.last_published_at)) / (1000 * 60 * 60 * 24) > 180
|
? (Date.now() - new Date(channelMeta.last_published_at)) / (1000 * 60 * 60 * 24) > 180
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
const avatarUrl = video.channel_thumbnail_url ?? channelMeta?.thumbnail_url ?? null;
|
||||||
|
const avatarLetter = video.channel_name?.[0]?.toUpperCase() ?? "?";
|
||||||
|
|
||||||
const internalId = video.id ?? video.local_video_id ?? null;
|
const internalId = video.id ?? video.local_video_id ?? null;
|
||||||
const isDownloaded = video.is_downloaded;
|
|
||||||
const isWatched = video.is_watched;
|
const isWatched = video.is_watched;
|
||||||
const duration = formatDuration(video.duration_seconds);
|
const duration = formatDuration(video.duration_seconds);
|
||||||
const date = formatDate(video.published_at);
|
const date = formatDate(video.published_at);
|
||||||
|
|
||||||
const [downloaded, setDownloaded] = useState(isDownloaded);
|
const [downloaded, setDownloaded] = useState(video.is_downloaded);
|
||||||
const [queued, setQueued] = useState(video.queued ?? false);
|
const [queued, setQueued] = useState(video.queued ?? false);
|
||||||
const [liked, setLiked] = useState(video.liked ?? false);
|
const [liked, setLiked] = useState(video.liked ?? false);
|
||||||
|
|
||||||
@@ -204,7 +198,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
});
|
});
|
||||||
|
|
||||||
const actions = (
|
const actions = (
|
||||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<IconBtn onClick={() => navigate(`/watch/${video.youtube_video_id}`)} title="Watch">
|
<IconBtn onClick={() => navigate(`/watch/${video.youtube_video_id}`)} title="Watch">
|
||||||
<svg className="w-3.5 h-3.5 ml-px" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5 ml-px" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
@@ -251,40 +245,53 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── List variant ─────────────────────────────────────────────────────────
|
// ── List variant ────────────────────────────────────────────────────────────
|
||||||
if (variant === "list") {
|
if (variant === "list") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||||
className="group flex gap-5 px-3 py-3.5 rounded-2xl cursor-pointer hover:bg-zinc-800/50 transition-colors duration-150"
|
className="group flex gap-3 sm:gap-4 px-2 sm:px-3 py-2 sm:py-3 rounded-xl cursor-pointer hover:bg-zinc-800/50 transition-colors duration-150"
|
||||||
>
|
>
|
||||||
|
{/* Thumbnail — compact on mobile, wide on desktop */}
|
||||||
<ThumbnailBlock
|
<ThumbnailBlock
|
||||||
video={video}
|
video={video}
|
||||||
isWatched={isWatched}
|
isWatched={isWatched}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
calmMode={calmMode}
|
calmMode={calmMode}
|
||||||
onDismiss={() => dismissMut.mutate()}
|
onDismiss={() => dismissMut.mutate()}
|
||||||
className="w-56 sm:w-72 aspect-video rounded-xl shrink-0"
|
className="w-32 sm:w-60 md:w-72 aspect-video rounded-lg shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col min-w-0 flex-1 py-0.5 gap-2">
|
<div className="flex flex-col min-w-0 flex-1 gap-1 sm:gap-1.5 py-px sm:py-0.5">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3 className="font-semibold text-[15px] leading-snug text-zinc-50 line-clamp-2">
|
<h3 className="font-semibold text-[12px] sm:text-[14px] leading-snug text-zinc-50 line-clamp-2">
|
||||||
{video.title}
|
{video.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Channel · date · badges */}
|
{/* Channel row */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-1.5 min-w-0 flex-wrap">
|
||||||
<span className="text-[12px] text-zinc-400 truncate">{video.channel_name}</span>
|
{avatarUrl && (
|
||||||
{date && <span className="text-zinc-700 text-[12px]">·</span>}
|
<img src={avatarUrl} alt="" className="hidden sm:block w-4 h-4 rounded-full object-cover shrink-0" />
|
||||||
{date && <span className="text-[12px] text-zinc-600 shrink-0">{date}</span>}
|
)}
|
||||||
|
<span className="text-[10px] sm:text-[11px] text-zinc-400 truncate">{video.channel_name}</span>
|
||||||
|
{date && (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-700 text-[10px]">·</span>
|
||||||
|
<span className="text-[10px] sm:text-[11px] text-zinc-500 shrink-0">{date}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Views + badges */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap text-[10px] text-zinc-600">
|
||||||
|
{video.view_count > 0 && <span>{formatViews(video.view_count)}</span>}
|
||||||
{video.is_recommended && !calmMode && (
|
{video.is_recommended && !calmMode && (
|
||||||
<span className="text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
<span className="text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full font-semibold tracking-wide text-[9px]">
|
||||||
Discover
|
Discover
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isDormant && !calmMode && (
|
{isDormant && !calmMode && (
|
||||||
<span title="No uploads in 6+ months" className="text-[10px] text-zinc-700 tracking-widest">zzz</span>
|
<span title="No uploads in 6+ months" className="text-zinc-700 tracking-widest text-[9px]">zzz</span>
|
||||||
)}
|
)}
|
||||||
{channelNote && (
|
{channelNote && (
|
||||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
||||||
@@ -295,17 +302,15 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description — desktop only, 2 lines max */}
|
||||||
{video.description ? (
|
{video.description && (
|
||||||
<p className="text-[12px] leading-relaxed text-zinc-500 line-clamp-3 flex-1">
|
<p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-2">
|
||||||
{video.description.replace(/\n+/g, " ")}
|
{video.description.replace(/\n+/g, " ")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
|
||||||
<div className="flex-1" />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions — fade in on hover */}
|
{/* Actions — always visible on mobile, fade on desktop */}
|
||||||
<div className="opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 mt-auto">
|
<div className="mt-auto pt-1 sm:opacity-0 sm:pointer-events-none sm:group-hover:opacity-100 sm:group-hover:pointer-events-auto sm:transition-opacity sm:duration-150">
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,10 +318,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Grid variant ─────────────────────────────────────────────────────────
|
// ── Grid variant ────────────────────────────────────────────────────────────
|
||||||
const avatarUrl = video.channel_thumbnail_url ?? channelMeta?.thumbnail_url ?? null;
|
|
||||||
const avatarLetter = video.channel_name?.[0]?.toUpperCase() ?? "?";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||||
@@ -335,56 +337,62 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
|
|||||||
className="aspect-video rounded-t-2xl overflow-hidden"
|
className="aspect-video rounded-t-2xl overflow-hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2.5 p-3 flex-1">
|
<div className="flex gap-2 sm:gap-2.5 p-2 sm:p-3 flex-1">
|
||||||
{/* Channel avatar */}
|
{/* Channel avatar */}
|
||||||
<div className="shrink-0 mt-0.5" onClick={(e) => { e.stopPropagation(); navigate(`/channels/${video.channel_id}`); }}>
|
<div
|
||||||
|
className="shrink-0 mt-0.5"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/channels/${video.channel_id}`); }}
|
||||||
|
>
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<img src={avatarUrl} alt="" className="w-8 h-8 rounded-full object-cover hover:ring-2 hover:ring-accent/50 transition-all" />
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full object-cover hover:ring-2 hover:ring-accent/50 transition-all"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-8 h-8 rounded-full bg-zinc-700 flex items-center justify-center text-xs font-bold text-zinc-400 hover:ring-2 hover:ring-accent/50 transition-all">
|
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-full bg-zinc-700 flex items-center justify-center text-[11px] font-bold text-zinc-400 hover:ring-2 hover:ring-accent/50 transition-all">
|
||||||
{avatarLetter}
|
{avatarLetter}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text + actions */}
|
{/* Text + actions */}
|
||||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
<div className="flex flex-col gap-0.5 sm:gap-1 min-w-0 flex-1">
|
||||||
<p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2">
|
<p className="font-medium text-[12px] sm:text-[13px] leading-snug text-zinc-50 line-clamp-2">
|
||||||
{video.title}
|
{video.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
|
<span className="text-[10px] sm:text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
|
||||||
{channelNote && (
|
{channelNote && (
|
||||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default shrink-0">
|
||||||
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
|
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isDormant && !calmMode && (
|
{isDormant && !calmMode && (
|
||||||
<span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest">zzz</span>
|
<span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest shrink-0">zzz</span>
|
||||||
)}
|
)}
|
||||||
{video.is_recommended && !calmMode && (
|
{video.is_recommended && !calmMode && (
|
||||||
<span className="text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
<span className="text-[9px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1 py-px rounded-full shrink-0">
|
||||||
Discover
|
Discover
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap text-[11px] text-zinc-600">
|
<div className="flex items-center gap-1 flex-wrap text-[10px] text-zinc-600">
|
||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
{video.view_count > 0 && <><span>·</span><span>{formatViews(video.view_count)}</span></>}
|
{video.view_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatViews(video.view_count)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{video.description && (
|
{/* Actions — desktop hover only (touch users tap to watch) */}
|
||||||
<p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-2 mt-0.5">
|
<div className="mt-auto pt-1 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150">
|
||||||
{video.description.replace(/\n+/g, " ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions — fade in on hover */}
|
|
||||||
<div className="mt-auto pt-1.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150">
|
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -579,6 +579,8 @@ export default function Following() {
|
|||||||
const { data: channels = [], isLoading: loadingChannels } = useQuery({
|
const { data: channels = [], isLoading: loadingChannels } = useQuery({
|
||||||
queryKey: ["channels"],
|
queryKey: ["channels"],
|
||||||
queryFn: () => getChannels().then((r) => r.data),
|
queryFn: () => getChannels().then((r) => r.data),
|
||||||
|
staleTime: 60_000,
|
||||||
|
gcTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: appSettings } = useQuery({
|
const { data: appSettings } = useQuery({
|
||||||
|
|||||||
Reference in New Issue
Block a user