Add offline indicator, surprise me button, and continue watching on channel

- Offline banner in nav when backend is unreachable (network error, not 4xx)
- GET /channels/{id}/random — picks random unwatched video, navigates to watch
- GET /channels/{id}/in-progress — videos with >30s progress, not yet watched
- Channel page: 'Surprise me' button (desktop + mobile) navigates to random video
- Channel page: 'Continue watching' row above video list when in-progress videos exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 23:46:58 +02:00
parent e02ea12494
commit 3652038cf5
4 changed files with 120 additions and 2 deletions

View File

@@ -65,6 +65,9 @@ export const addChannelToGroup = (groupId, channelId) => api.post(`/channels/gro
export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`);
export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action });
export const getActiveTasks = () => api.get("/channels/tasks");
export const getRandomChannelVideo = (id, unwatchedOnly = true) =>
api.get(`/channels/${id}/random`, { params: { unwatched_only: unwatchedOnly } });
export const getChannelInProgress = (id) => api.get(`/channels/${id}/in-progress`);
// Videos
export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") =>

View File

@@ -2,7 +2,7 @@ import { Outlet, NavLink, Link, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../hooks/useAuth";
import SearchBar from "./SearchBar";
import { getDownloads, getChannels, getActiveTasks } from "../api";
import { getDownloads, getChannels, getActiveTasks, getMe } from "../api";
function BottomNav({ newCount }) {
const tabs = [
@@ -259,12 +259,32 @@ function useNewVideosCount() {
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
}
function useOffline() {
const { isError, error } = useQuery({
queryKey: ["health"],
queryFn: () => getMe().then((r) => r.data),
refetchInterval: 30_000,
retry: 1,
staleTime: 20_000,
});
return isError && !error?.response;
}
export default function Layout() {
const { user, logout } = useAuth();
const newCount = useNewVideosCount();
const offline = useOffline();
return (
<div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
{offline && (
<div className="shrink-0 bg-amber-500/10 border-b border-amber-500/30 px-4 py-2 flex items-center gap-2">
<svg className="w-4 h-4 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-sm text-amber-300">Server unreachable check that the backend is running.</p>
</div>
)}
{/* Header */}
<header className="shrink-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
<div className="max-w-screen-xl mx-auto px-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4">

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";
import {
getChannel, getChannelVideos, searchChannelYoutube,
followChannel, unfollowChannel, indexChannel, indexChannelFull, exploreChannelOlder, fetchPopularVideos, downloadChannel,
getChannelPlaylists, fetchChannelPlaylists, getPlaylistVideos, indexPlaylist,
getRandomChannelVideo, getChannelInProgress,
} from "../api";
import VideoCard from "../components/VideoCard";
@@ -32,6 +33,7 @@ function formatSubs(n) {
export default function ChannelPage() {
const { id } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const [tab, setTab] = useState("videos");
const [sort, setSort] = useState("newest");
@@ -147,6 +149,17 @@ export default function ChannelPage() {
onSuccess: () => setTimeout(() => refetchPlaylistVideos(), 15000),
});
const { data: inProgress = [] } = useQuery({
queryKey: ["channel-in-progress", id],
queryFn: () => getChannelInProgress(id).then((r) => r.data),
enabled: !!id,
});
const randomMut = useMutation({
mutationFn: () => getRandomChannelVideo(id).then((r) => r.data),
onSuccess: (video) => navigate(`/watch/${video.youtube_video_id}`),
});
const [dlResult, setDlResult] = useState(null);
const dlMut = useMutation({
mutationFn: () => downloadChannel(id),
@@ -213,6 +226,11 @@ export default function ChannelPage() {
className={`text-sm font-medium px-4 py-1.5 rounded-lg transition-colors ${isFollowed ? "bg-zinc-700/80 text-zinc-300 hover:bg-zinc-600" : "bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700"}`}>
{isFollowed ? "Following" : "Follow"}
</button>
<button onClick={() => randomMut.mutate()} disabled={randomMut.isPending}
title="Play a random unwatched video"
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-zinc-800/80 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60">
{randomMut.isPending ? "…" : "Surprise me"}
</button>
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"}
@@ -227,6 +245,10 @@ export default function ChannelPage() {
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors">
{isFollowed ? "Following ✓" : "Follow"}
</button>
<button onClick={() => randomMut.mutate()} disabled={randomMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60">
{randomMut.isPending ? "…" : "Surprise me"}
</button>
<button onClick={() => dlMut.mutate()} disabled={dlMut.isPending}
className="flex-1 text-sm font-medium py-2 rounded-lg bg-accent text-black hover:bg-zinc-100 transition-colors disabled:opacity-60">
{dlMut.isPending ? "Queuing…" : "Download all"}
@@ -456,6 +478,19 @@ export default function ChannelPage() {
)
)}
{/* Continue watching */}
{tab === "videos" && inProgress.length > 0 && (
<div className="flex flex-col gap-2">
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Continue watching</h2>
<div className="flex flex-col gap-1">
{inProgress.map((v) => (
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} variant="list" />
))}
</div>
<div className="border-t border-zinc-800/60 mt-1" />
</div>
)}
{/* Video list */}
{tab !== "playlists" && (loadingVideos ? (
<div className="flex items-center justify-center py-8">