mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
fix(web): improve backend retry and sidebar scrolling
This commit is contained in:
@@ -85,7 +85,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { ChevronRight, Plus } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -187,6 +187,7 @@ const ENTITY_ROUTE_MAP: Record<EntityCategoryId, string> = {
|
||||
// localStorage key for collapsible section open/closed state
|
||||
const SIDEBAR_SECTIONS_KEY = 'sidebar_sections';
|
||||
const SIDEBAR_LIST_EXPANSION_KEY = 'sidebar_entity_list_expansion';
|
||||
const SCROLL_HINT_BOTTOM_THRESHOLD = 40;
|
||||
|
||||
type SidebarNavSection = 'home' | 'extensions';
|
||||
type SidebarListExpansionState = Record<
|
||||
@@ -1519,6 +1520,27 @@ export default function HomeSidebar({
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigationContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
function scrollNavigationToBottom() {
|
||||
const contentEl = navigationContentRef.current;
|
||||
if (!contentEl) return;
|
||||
|
||||
const maxScrollTop = contentEl.scrollHeight - contentEl.clientHeight;
|
||||
contentEl.scrollTo({
|
||||
top: maxScrollTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
setShowScrollHint(false);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (contentEl.scrollTop < maxScrollTop - 2) {
|
||||
contentEl.scrollTop = maxScrollTop;
|
||||
}
|
||||
setShowScrollHint(false);
|
||||
}, 250);
|
||||
}
|
||||
function handleModelsDialogChange(open: boolean) {
|
||||
setModelsDialogOpen(open);
|
||||
if (open) {
|
||||
@@ -1622,6 +1644,48 @@ export default function HomeSidebar({
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const contentEl = navigationContentRef.current;
|
||||
if (!contentEl) return;
|
||||
|
||||
let animationFrame = 0;
|
||||
const updateScrollHint = () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
const hasHiddenContent =
|
||||
contentEl.scrollTop + contentEl.clientHeight <
|
||||
contentEl.scrollHeight - SCROLL_HINT_BOTTOM_THRESHOLD;
|
||||
setShowScrollHint(hasHiddenContent);
|
||||
});
|
||||
};
|
||||
|
||||
updateScrollHint();
|
||||
contentEl.addEventListener('scroll', updateScrollHint, { passive: true });
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateScrollHint);
|
||||
resizeObserver.observe(contentEl);
|
||||
if (contentEl.firstElementChild) {
|
||||
resizeObserver.observe(contentEl.firstElementChild);
|
||||
}
|
||||
|
||||
const mutationObserver = new MutationObserver(updateScrollHint);
|
||||
mutationObserver.observe(contentEl, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
window.addEventListener('resize', updateScrollHint);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
contentEl.removeEventListener('scroll', updateScrollHint);
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
window.removeEventListener('resize', updateScrollHint);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update selected state + notify parent without navigating
|
||||
function selectChild(child: SidebarChildVO) {
|
||||
setSelectedChild(child);
|
||||
@@ -1715,7 +1779,8 @@ export default function HomeSidebar({
|
||||
</SidebarHeader>
|
||||
|
||||
{/* Navigation items grouped by section */}
|
||||
<SidebarContent>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t('sidebar.home')}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
@@ -1746,6 +1811,25 @@ export default function HomeSidebar({
|
||||
</SidebarGroup>
|
||||
<PluginPagesNav />
|
||||
</SidebarContent>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollNavigationToBottom}
|
||||
disabled={!showScrollHint}
|
||||
aria-label={t('sidebar.scrollToBottom')}
|
||||
aria-hidden={!showScrollHint}
|
||||
tabIndex={showScrollHint ? 0 : -1}
|
||||
className={cn(
|
||||
'absolute inset-x-0 bottom-2 z-10 mx-auto flex w-fit justify-center rounded-full transition-opacity duration-200 group-data-[collapsible=icon]:hidden',
|
||||
showScrollHint
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-full border border-sidebar-border bg-sidebar/95 text-sidebar-foreground/70 shadow-sm backdrop-blur transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground">
|
||||
<ChevronDown className="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -59,6 +59,8 @@ function isExtensionsRoute(pathname: string): boolean {
|
||||
}
|
||||
|
||||
const HOME_CONTENT_MAX_WIDTH = 'max-w-[1360px]';
|
||||
const BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY =
|
||||
'langbot_backend_unavailable_return_to';
|
||||
|
||||
export default function HomeLayout({
|
||||
children,
|
||||
@@ -66,6 +68,7 @@ export default function HomeLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Initialize user info if not already initialized
|
||||
useEffect(() => {
|
||||
@@ -87,7 +90,15 @@ export default function HomeLayout({
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
navigate('/backend-unavailable', { replace: true });
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
sessionStorage.setItem(
|
||||
BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY,
|
||||
returnTo,
|
||||
);
|
||||
navigate('/backend-unavailable', {
|
||||
replace: true,
|
||||
state: { from: returnTo },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -96,7 +107,7 @@ export default function HomeLayout({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navigate]);
|
||||
}, [location.hash, location.pathname, location.search, navigate]);
|
||||
|
||||
return (
|
||||
<SidebarDataProvider>
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle, Home, RefreshCw } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle, Home, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { initializeSystemInfo, systemInfo } from '@/app/infra/http';
|
||||
|
||||
const RETURN_TO_STORAGE_KEY = 'langbot_backend_unavailable_return_to';
|
||||
|
||||
type BackendUnavailableLocationState = {
|
||||
from?: string;
|
||||
};
|
||||
|
||||
export default function BackendUnavailablePage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [retryError, setRetryError] = useState<string | null>(null);
|
||||
|
||||
async function handleRetry() {
|
||||
setChecking(true);
|
||||
setRetryError(null);
|
||||
|
||||
try {
|
||||
await initializeSystemInfo({ throwOnError: true });
|
||||
const state = location.state as BackendUnavailableLocationState | null;
|
||||
const storedReturnTo = sessionStorage.getItem(RETURN_TO_STORAGE_KEY);
|
||||
const returnTo = state?.from || storedReturnTo || '/home';
|
||||
sessionStorage.removeItem(RETURN_TO_STORAGE_KEY);
|
||||
|
||||
if (systemInfo.wizard_status === 'none') {
|
||||
navigate('/wizard', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(returnTo === '/backend-unavailable' ? '/home' : returnTo, {
|
||||
replace: true,
|
||||
});
|
||||
} catch (error) {
|
||||
setRetryError(
|
||||
error instanceof Error ? error.message : t('errorPage.retryFailed'),
|
||||
);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
@@ -27,6 +66,12 @@ export default function BackendUnavailablePage() {
|
||||
{t('common.loginLoadErrorDesc')}
|
||||
</p>
|
||||
|
||||
{retryError ? (
|
||||
<p className="mt-4 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{t('errorPage.retryFailed')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -36,9 +81,13 @@ export default function BackendUnavailablePage() {
|
||||
<Home className="h-4 w-4" />
|
||||
{t('errorPage.backToLogin')}
|
||||
</Button>
|
||||
<Button className="gap-2" onClick={() => window.location.reload()}>
|
||||
<Button className="gap-2" onClick={handleRetry} disabled={checking}>
|
||||
{checking ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('common.retry')}
|
||||
)}
|
||||
{checking ? t('errorPage.retrying') : t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,9 +374,13 @@ function SidebarSeparator({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<'div'>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
@@ -386,7 +390,8 @@ function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
SidebarContent.displayName = 'SidebarContent';
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ const enUS = {
|
||||
pluginPages: 'Plugin Pages',
|
||||
pluginPagesTooltip: 'Visual pages provided by installed plugins',
|
||||
quickStart: 'Quick Start',
|
||||
scrollToBottom: 'Scroll to bottom',
|
||||
},
|
||||
common: {
|
||||
login: 'Login',
|
||||
@@ -1539,6 +1540,9 @@ const enUS = {
|
||||
goBack: 'Go Back',
|
||||
backToHome: 'Back to Home',
|
||||
backToLogin: 'Back to Login',
|
||||
retrying: 'Retrying',
|
||||
retryFailed:
|
||||
'Still cannot connect to the backend. Start the service and try again.',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||
|
||||
@@ -10,6 +10,7 @@ const esES = {
|
||||
pluginPagesTooltip:
|
||||
'Páginas visuales proporcionadas por los plugins instalados',
|
||||
quickStart: 'Inicio rápido',
|
||||
scrollToBottom: 'Desplazar al final',
|
||||
},
|
||||
common: {
|
||||
login: 'Iniciar sesión',
|
||||
@@ -1463,6 +1464,9 @@ const esES = {
|
||||
goBack: 'Volver',
|
||||
backToHome: 'Ir al inicio',
|
||||
backToLogin: 'Volver al inicio de sesión',
|
||||
retrying: 'Reintentando',
|
||||
retryFailed:
|
||||
'Aún no se puede conectar con el backend. Inicia el servicio e inténtalo de nuevo.',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||
|
||||
@@ -9,6 +9,7 @@ const jaJP = {
|
||||
pluginPages: 'プラグインページ',
|
||||
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
|
||||
quickStart: 'クイックスタート',
|
||||
scrollToBottom: '一番下までスクロール',
|
||||
},
|
||||
common: {
|
||||
login: 'ログイン',
|
||||
@@ -1454,6 +1455,9 @@ const jaJP = {
|
||||
goBack: '戻る',
|
||||
backToHome: 'ホームに戻る',
|
||||
backToLogin: 'ログインに戻る',
|
||||
retrying: '再試行中',
|
||||
retryFailed:
|
||||
'バックエンドにまだ接続できません。サービスを起動してからもう一度お試しください。',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||
|
||||
@@ -10,6 +10,7 @@ const ruRU = {
|
||||
pluginPagesTooltip:
|
||||
'Визуальные страницы, предоставляемые установленными плагинами',
|
||||
quickStart: 'Быстрый старт',
|
||||
scrollToBottom: 'Прокрутить вниз',
|
||||
},
|
||||
common: {
|
||||
login: 'Войти',
|
||||
@@ -1435,6 +1436,9 @@ const ruRU = {
|
||||
goBack: 'Назад',
|
||||
backToHome: 'На главную',
|
||||
backToLogin: 'Вернуться к входу',
|
||||
retrying: 'Повторяем',
|
||||
retryFailed:
|
||||
'По-прежнему не удается подключиться к бэкенду. Запустите сервис и повторите попытку.',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||
|
||||
@@ -9,6 +9,7 @@ const thTH = {
|
||||
pluginPages: 'หน้าปลั๊กอิน',
|
||||
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
|
||||
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
|
||||
scrollToBottom: 'เลื่อนไปด้านล่าง',
|
||||
},
|
||||
common: {
|
||||
login: 'เข้าสู่ระบบ',
|
||||
@@ -1403,6 +1404,9 @@ const thTH = {
|
||||
goBack: 'ย้อนกลับ',
|
||||
backToHome: 'กลับหน้าหลัก',
|
||||
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
|
||||
retrying: 'กำลังลองใหม่',
|
||||
retryFailed:
|
||||
'ยังไม่สามารถเชื่อมต่อแบ็กเอนด์ได้ โปรดเริ่มบริการแล้วลองใหม่อีกครั้ง',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||
|
||||
@@ -10,6 +10,7 @@ const viVN = {
|
||||
pluginPagesTooltip:
|
||||
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
|
||||
quickStart: 'Bắt đầu nhanh',
|
||||
scrollToBottom: 'Cuộn xuống cuối',
|
||||
},
|
||||
common: {
|
||||
login: 'Đăng nhập',
|
||||
@@ -1427,6 +1428,9 @@ const viVN = {
|
||||
goBack: 'Quay lại',
|
||||
backToHome: 'Về trang chủ',
|
||||
backToLogin: 'Quay lại đăng nhập',
|
||||
retrying: 'Đang thử lại',
|
||||
retryFailed:
|
||||
'Vẫn không thể kết nối backend. Hãy khởi động dịch vụ rồi thử lại.',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||
|
||||
@@ -9,6 +9,7 @@ const zhHans = {
|
||||
pluginPages: '插件页面',
|
||||
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
|
||||
quickStart: '快速开始向导',
|
||||
scrollToBottom: '滚动到底部',
|
||||
},
|
||||
common: {
|
||||
login: '登录',
|
||||
@@ -1474,6 +1475,8 @@ const zhHans = {
|
||||
goBack: '返回上页',
|
||||
backToHome: '返回首页',
|
||||
backToLogin: '返回登录',
|
||||
retrying: '正在重试',
|
||||
retryFailed: '仍然无法连接后端,请确认服务已启动后再重试。',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||
|
||||
@@ -9,6 +9,7 @@ const zhHant = {
|
||||
pluginPages: '插件頁面',
|
||||
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
|
||||
quickStart: '快速開始',
|
||||
scrollToBottom: '捲動到底部',
|
||||
},
|
||||
common: {
|
||||
login: '登入',
|
||||
@@ -1384,6 +1385,8 @@ const zhHant = {
|
||||
goBack: '返回上頁',
|
||||
backToHome: '返回首頁',
|
||||
backToLogin: '返回登入',
|
||||
retrying: '正在重試',
|
||||
retryFailed: '仍然無法連接後端,請確認服務已啟動後再重試。',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||
|
||||
Reference in New Issue
Block a user