);
}
@@ -1458,7 +1489,7 @@ function ChannelsScreen() {
};
if (channels.length === 0) {
- return ;
+ return void createBlankChannel()} />;
}
return (
@@ -3442,12 +3473,30 @@ export function App() {
const [activeRoute, setActiveRoute] = useState(() => routeFromLocation());
const healthState = useDashboardHealthQuery();
+ // The last path the guard has approved us being on. Browser Back/Forward (popstate) can't be
+ // cancelled, so when the dirty guard vetoes a pop we re-push THIS path to undo the browser's URL
+ // change. Kept in a ref (not state) so the popstate handler reads it synchronously at event time.
+ const currentPathRef = useRef(window.location.pathname);
+
useEffect(() => {
applyDesignSystemTheme(theme);
}, [theme]);
useEffect(() => {
- const onPopState = () => setActiveRoute(routeFromLocation());
+ // popstate covers both real Back/Forward and the synthetic pop navigateToPath() dispatches. If a
+ // screen has registered a dirty guard and it vetoes (user cancelled the confirm), we cannot stop
+ // the navigation — the URL has already moved — so we restore it by re-pushing the pre-pop path
+ // and leave activeRoute untouched. Re-pushing does NOT fire popstate, but that's fine: only one
+ // screen is mounted at a time and the guard-registering screen (schedules) owns no internal
+ // popstate listener, so no sub-path screen's pathname state can desync (see spa-conventions §8).
+ const onPopState = () => {
+ if (!canLeaveCurrentScreen()) {
+ window.history.pushState(null, '', currentPathRef.current);
+ return;
+ }
+ currentPathRef.current = window.location.pathname;
+ setActiveRoute(routeFromLocation());
+ };
window.addEventListener('popstate', onPopState);
@@ -3474,6 +3523,7 @@ export function App() {
}
window.history.pushState(null, '', routeHref(nextRoute));
+ currentPathRef.current = window.location.pathname;
setActiveRoute(nextRoute);
};
diff --git a/web/src/shell.css b/web/src/shell.css
index 3dc15264c..2aa669fc0 100644
--- a/web/src/shell.css
+++ b/web/src/shell.css
@@ -1275,6 +1275,21 @@ body {
font-size: var(--text-sm, 13px);
}
+.ctv-channels-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ padding: 16px 0;
+}
+
+.ctv-channels-empty-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
.ctv-schedule-inspector {
display: flex;
flex-direction: column;