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:
@@ -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 = "") =>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user