Skip to content

Commit 52447fc

Browse files
committed
feat(web): server management on the client, extended offline mode ui
1 parent 1b250e0 commit 52447fc

25 files changed

+1934
-101
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { motion } from "motion/react";
2+
import { useState, useEffect } from "react";
3+
import NavLink from "./nav-link";
4+
import Logo from "../logo";
5+
import UserMenu from "./user-menu";
6+
import { useLocation } from "@tanstack/react-router";
7+
import { tabsContainerVariants, tabsContainerTransition } from "./variants";
8+
9+
// Offline mode tabs for different media types
10+
const offlineTabs = [
11+
{ id: "watch", label: "Watch", href: "/" },
12+
{ id: "listen", label: "Listen", href: "/listen" },
13+
{ id: "read", label: "Read", href: "/read" },
14+
];
15+
16+
/**
17+
* Offline version of Header with limited functionality
18+
* No mode switching dialog - just media type tabs
19+
*/
20+
const HeaderOffline = () => {
21+
const location = useLocation();
22+
const [hasMounted, setHasMounted] = useState(false);
23+
24+
// Use offline-specific tabs
25+
const tabs = offlineTabs;
26+
const [activeTab, setActiveTab] = useState(tabs[0].id);
27+
28+
// Skip initial animations
29+
useEffect(() => {
30+
setHasMounted(true);
31+
}, []);
32+
33+
// Sync activeTab with current route
34+
useEffect(() => {
35+
const currentTab = tabs.find((tab) => {
36+
if (tab.href === "/") {
37+
return location.pathname === "/";
38+
}
39+
return location.pathname.startsWith(tab.href);
40+
});
41+
if (currentTab) {
42+
setActiveTab(currentTab.id);
43+
}
44+
}, [location.pathname, tabs]);
45+
46+
// Check if current path matches any of the header tabs
47+
const isOnHeaderTab = tabs.some((tab) => {
48+
if (tab.href === "/") {
49+
return location.pathname === "/";
50+
}
51+
return location.pathname.startsWith(tab.href);
52+
});
53+
54+
return (
55+
<div className="w-full fixed top-0 left-0 right-0 z-50">
56+
<nav className="w-fit mx-auto space-y-4 p-4">
57+
<motion.div
58+
layout="preserve-aspect"
59+
initial={{ opacity: 0, scale: 0.95 }}
60+
animate={{ opacity: 1, scale: 1 }}
61+
exit={{ opacity: 0, scale: 0.95 }}
62+
transition={{
63+
duration: 0.2,
64+
layout: { duration: 0.3, ease: [0.4, 0, 0.2, 1] },
65+
}}
66+
className="w-full flex justify-center items-center"
67+
>
68+
{/* Logo - no dialog in offline mode */}
69+
<motion.div
70+
layout="position"
71+
transition={{ layout: { duration: 0.3, ease: [0.4, 0, 0.2, 1] } }}
72+
>
73+
<motion.div
74+
initial={{ backdropFilter: "blur(0px)" }}
75+
animate={{
76+
backdropFilter: "blur(10px)",
77+
transition: { delay: 0.2 },
78+
}}
79+
exit={{ backdropFilter: "blur(0px)" }}
80+
className="border h-12 w-12 bg-neutral-900/60 border-white/10 rounded-[50px] flex items-center justify-center"
81+
>
82+
<Logo className="w-8 h-8" />
83+
</motion.div>
84+
</motion.div>
85+
86+
{/* Navigation Tabs */}
87+
<motion.div
88+
layout="position"
89+
initial={hasMounted ? "initial" : false}
90+
animate="animate"
91+
exit="exit"
92+
variants={tabsContainerVariants}
93+
transition={tabsContainerTransition}
94+
>
95+
<motion.div
96+
initial={{ backdropFilter: "blur(0px)" }}
97+
animate={{ backdropFilter: "blur(10px)" }}
98+
exit={{ backdropFilter: "blur(0px)" }}
99+
transition={{ delay: 0.2 }}
100+
className="flex ml-2 space-x-1 w-fit bg-neutral-900/60 p-1 rounded-[50px] border border-white/10"
101+
>
102+
{tabs.map((tab) => (
103+
<NavLink
104+
key={tab.id}
105+
tab={tab}
106+
activeTab={activeTab}
107+
setActiveTab={setActiveTab}
108+
showBubble={isOnHeaderTab}
109+
/>
110+
))}
111+
</motion.div>
112+
</motion.div>
113+
114+
{/* User Menu */}
115+
<motion.div
116+
layout="position"
117+
transition={{ layout: { duration: 0.3, ease: [0.4, 0, 0.2, 1] } }}
118+
>
119+
<UserMenu />
120+
</motion.div>
121+
</motion.div>
122+
</nav>
123+
</div>
124+
);
125+
};
126+
127+
export default HeaderOffline;

apps/web/src/components/ui/header/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import type { GetApiV1SearchParams } from "@dester/api-client";
2424
import SearchResults from "./search-results";
2525
import { useLocation } from "@tanstack/react-router";
2626
import { useAuth } from "@/hooks/useAuth";
27+
import { useOffline } from "@/hooks/useOffline";
28+
import HeaderOffline from "./header-offline";
2729

2830
const allTabs = [
2931
{ id: "home", label: "Home", href: "/" },
@@ -34,6 +36,7 @@ const allTabs = [
3436
const Header = () => {
3537
const location = useLocation();
3638
const { user } = useAuth();
39+
const { isOnline } = useOffline();
3740
const [hasMounted, setHasMounted] = useState(false);
3841

3942
// Filter tabs based on user role - hide Settings for guests and unauthenticated users
@@ -111,6 +114,11 @@ const Header = () => {
111114
setDebouncedQuery("");
112115
};
113116

117+
// Use offline header when not connected - check after all hooks
118+
if (!isOnline) {
119+
return <HeaderOffline />;
120+
}
121+
114122
return (
115123
<div className="w-full fixed top-0 left-0 right-0 z-50">
116124
<nav className="w-fit mx-auto space-y-4 p-4">

apps/web/src/components/ui/header/user-menu.tsx

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, memo } from "react";
22
import { useNavigate } from "@tanstack/react-router";
33
import { useAuth } from "@/hooks/useAuth";
4+
import { useOffline } from "@/hooks/useOffline";
45
import {
56
User,
67
Settings,
@@ -9,21 +10,27 @@ import {
910
Shield,
1011
LogIn,
1112
Star,
13+
WifiOff,
14+
Server,
1215
} from "lucide-react";
1316
import { AnimatePresence, motion } from "motion/react";
1417
import { Badge } from "@/components/ui/badge";
18+
import { ServersDialog } from "@/components/ui/servers-dialog";
1519

1620
function UserMenu() {
1721
const { user, isAuthenticated, logout } = useAuth();
22+
const { isOnline } = useOffline();
1823
const navigate = useNavigate();
1924
const [isOpen, setIsOpen] = useState(false);
25+
const [isServersDialogOpen, setIsServersDialogOpen] = useState(false);
2026

2127
const handleLogout = async () => {
2228
await logout();
2329
navigate({ to: "/login" });
2430
};
2531

26-
if (!isAuthenticated) {
32+
// Don't show sign in button when offline
33+
if (!isAuthenticated && isOnline) {
2734
return (
2835
<div className="border bg-neutral-900/60 rounded-[50px] p-1 ml-2">
2936
<button
@@ -37,6 +44,18 @@ function UserMenu() {
3744
);
3845
}
3946

47+
// Show offline badge when offline and not authenticated
48+
if (!isAuthenticated && !isOnline) {
49+
return (
50+
<div className="border bg-neutral-900/60 rounded-[50px] p-1 ml-2">
51+
<div className="h-10 px-4 backdrop-blur-lg rounded-[50px] flex items-center gap-2">
52+
<WifiOff className="w-4 h-4 text-orange-400" />
53+
<span className="text-sm font-medium text-white">Offline Mode</span>
54+
</div>
55+
</div>
56+
);
57+
}
58+
4059
return (
4160
<div className="relative ml-2">
4261
<div className="border bg-neutral-900/60 backdrop-blur-lg rounded-[50px] p-1">
@@ -101,7 +120,7 @@ function UserMenu() {
101120
{user?.email && (
102121
<p className="text-xs text-white/60 mt-0.5">{user.email}</p>
103122
)}
104-
<div className="mt-2">
123+
<div className="mt-2 flex gap-2">
105124
<Badge
106125
variant={
107126
user?.role === "SUPER_ADMIN"
@@ -130,13 +149,34 @@ function UserMenu() {
130149
? "Guest"
131150
: "User"}
132151
</Badge>
152+
{!isOnline && (
153+
<Badge
154+
variant="outline"
155+
className="inline-flex items-center gap-1 bg-orange-500/20 text-orange-300 border-orange-500/30"
156+
>
157+
<WifiOff className="w-3 h-3" />
158+
API Offline
159+
</Badge>
160+
)}
133161
</div>
134162
</div>
135163

136164
{/* Menu Items */}
137165
<div className="py-1">
138-
{/* Only show settings for non-guest users */}
139-
{user?.role !== "GUEST" && (
166+
{/* Servers - Always visible */}
167+
<button
168+
onClick={() => {
169+
setIsServersDialogOpen(true);
170+
setIsOpen(false);
171+
}}
172+
className="w-full px-4 py-2.5 text-left text-sm text-white hover:bg-white/10 transition-colors flex items-center gap-3"
173+
>
174+
<Server className="w-4 h-4" />
175+
Servers
176+
</button>
177+
178+
{/* Only show settings for non-guest users and when online */}
179+
{user?.role !== "GUEST" && isOnline && (
140180
<button
141181
onClick={() => {
142182
navigate({ to: "/settings" });
@@ -149,21 +189,37 @@ function UserMenu() {
149189
</button>
150190
)}
151191

152-
<button
153-
onClick={() => {
154-
handleLogout();
155-
setIsOpen(false);
156-
}}
157-
className="w-full px-4 py-2.5 text-left text-sm text-red-400 hover:bg-red-500/10 transition-colors flex items-center gap-3"
158-
>
159-
<LogOut className="w-4 h-4" />
160-
Sign Out
161-
</button>
192+
{/* Only show sign out when online */}
193+
{isOnline && (
194+
<button
195+
onClick={() => {
196+
handleLogout();
197+
setIsOpen(false);
198+
}}
199+
className="w-full px-4 py-2.5 text-left text-sm text-red-400 hover:bg-red-500/10 transition-colors flex items-center gap-3"
200+
>
201+
<LogOut className="w-4 h-4" />
202+
Sign Out
203+
</button>
204+
)}
205+
206+
{/* Show offline message when offline */}
207+
{!isOnline && (
208+
<div className="px-4 py-2.5 text-xs text-white/60">
209+
Connect to the API to access settings and sign out.
210+
</div>
211+
)}
162212
</div>
163213
</motion.div>
164214
</>
165215
)}
166216
</AnimatePresence>
217+
218+
{/* Servers Dialog */}
219+
<ServersDialog
220+
isOpen={isServersDialogOpen}
221+
onClose={() => setIsServersDialogOpen(false)}
222+
/>
167223
</div>
168224
);
169225
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { motion, AnimatePresence } from "motion/react";
2+
import { ServerOff, Wifi } from "lucide-react";
3+
import { useOnlineStatus } from "@/hooks/useOnlineStatus";
4+
import { useEffect, useState } from "react";
5+
6+
/**
7+
* OfflineIndicator - Shows a banner when the app is offline
8+
* and a brief notification when coming back online
9+
*/
10+
export function OfflineIndicator() {
11+
const { isOnline, wasOffline } = useOnlineStatus();
12+
const [showOnlineNotification, setShowOnlineNotification] = useState(false);
13+
14+
useEffect(() => {
15+
if (isOnline && wasOffline) {
16+
// Show "back online" notification briefly
17+
setShowOnlineNotification(true);
18+
const timer = setTimeout(() => {
19+
setShowOnlineNotification(false);
20+
}, 3000);
21+
return () => clearTimeout(timer);
22+
}
23+
}, [isOnline, wasOffline]);
24+
25+
return (
26+
<>
27+
{/* Offline Banner */}
28+
<AnimatePresence>
29+
{!isOnline && (
30+
<motion.div
31+
initial={{ opacity: 0, y: -50 }}
32+
animate={{ opacity: 1, y: 0 }}
33+
exit={{ opacity: 0, y: -50 }}
34+
transition={{ duration: 0.3, ease: "easeOut" }}
35+
className="fixed top-0 left-0 right-0 z-[100] bg-orange-500/90 backdrop-blur-md text-white py-2 px-4 shadow-lg"
36+
>
37+
<div className="flex items-center justify-center gap-2">
38+
<ServerOff className="w-4 h-4" />
39+
<span className="text-sm font-medium">
40+
Server unreachable. Showing downloaded content.
41+
</span>
42+
</div>
43+
</motion.div>
44+
)}
45+
</AnimatePresence>
46+
47+
{/* Back Online Notification */}
48+
<AnimatePresence>
49+
{showOnlineNotification && (
50+
<motion.div
51+
initial={{ opacity: 0, y: -50 }}
52+
animate={{ opacity: 1, y: 0 }}
53+
exit={{ opacity: 0, y: -50 }}
54+
transition={{ duration: 0.3, ease: "easeOut" }}
55+
className="fixed top-0 left-0 right-0 z-[100] bg-green-500/90 backdrop-blur-md text-white py-2 px-4 shadow-lg"
56+
>
57+
<div className="flex items-center justify-center gap-2">
58+
<Wifi className="w-4 h-4" />
59+
<span className="text-sm font-medium">You're back online!</span>
60+
</div>
61+
</motion.div>
62+
)}
63+
</AnimatePresence>
64+
</>
65+
);
66+
}

0 commit comments

Comments
 (0)