The full version string was pushing the sidebar too wide. Now displays just "v" with the full version (e.g. "v1.2.3") shown on hover via title attribute, for both mobile and desktop sidebar layouts. Fixes PAP-533 Co-Authored-By: Paperclip <noreply@paperclip.ing>
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
|
|
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
|
import { CompanyRail } from "./CompanyRail";
|
|
import { Sidebar } from "./Sidebar";
|
|
import { InstanceSidebar } from "./InstanceSidebar";
|
|
import { BreadcrumbBar } from "./BreadcrumbBar";
|
|
import { PropertiesPanel } from "./PropertiesPanel";
|
|
import { CommandPalette } from "./CommandPalette";
|
|
import { NewIssueDialog } from "./NewIssueDialog";
|
|
import { NewProjectDialog } from "./NewProjectDialog";
|
|
import { NewGoalDialog } from "./NewGoalDialog";
|
|
import { NewAgentDialog } from "./NewAgentDialog";
|
|
import { ToastViewport } from "./ToastViewport";
|
|
import { MobileBottomNav } from "./MobileBottomNav";
|
|
import { WorktreeBanner } from "./WorktreeBanner";
|
|
import { useDialog } from "../context/DialogContext";
|
|
import { usePanel } from "../context/PanelContext";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { useTheme } from "../context/ThemeContext";
|
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
|
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
|
import { healthApi } from "../api/health";
|
|
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
|
import {
|
|
DEFAULT_INSTANCE_SETTINGS_PATH,
|
|
normalizeRememberedInstanceSettingsPath,
|
|
} from "../lib/instance-settings";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { cn } from "../lib/utils";
|
|
import { NotFoundPage } from "../pages/NotFound";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
|
|
|
function readRememberedInstanceSettingsPath(): string {
|
|
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
|
|
try {
|
|
return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY));
|
|
} catch {
|
|
return DEFAULT_INSTANCE_SETTINGS_PATH;
|
|
}
|
|
}
|
|
|
|
export function Layout() {
|
|
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
|
const { openNewIssue, openOnboarding } = useDialog();
|
|
const { togglePanelVisible } = usePanel();
|
|
const {
|
|
companies,
|
|
loading: companiesLoading,
|
|
selectedCompany,
|
|
selectedCompanyId,
|
|
selectionSource,
|
|
setSelectedCompanyId,
|
|
} = useCompany();
|
|
const { theme, toggleTheme } = useTheme();
|
|
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
|
|
const onboardingTriggered = useRef(false);
|
|
const lastMainScrollTop = useRef(0);
|
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
|
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
|
const matchedCompany = useMemo(() => {
|
|
if (!companyPrefix) return null;
|
|
const requestedPrefix = companyPrefix.toUpperCase();
|
|
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
|
|
}, [companies, companyPrefix]);
|
|
const hasUnknownCompanyPrefix =
|
|
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
|
|
const { data: health } = useQuery({
|
|
queryKey: queryKeys.health,
|
|
queryFn: () => healthApi.get(),
|
|
retry: false,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (companiesLoading || onboardingTriggered.current) return;
|
|
if (health?.deploymentMode === "authenticated") return;
|
|
if (companies.length === 0) {
|
|
onboardingTriggered.current = true;
|
|
openOnboarding();
|
|
}
|
|
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
|
|
|
|
useEffect(() => {
|
|
if (!companyPrefix || companiesLoading || companies.length === 0) return;
|
|
|
|
if (!matchedCompany) {
|
|
const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
|
|
?? companies[0]
|
|
?? null;
|
|
if (fallback && selectedCompanyId !== fallback.id) {
|
|
setSelectedCompanyId(fallback.id, { source: "route_sync" });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (companyPrefix !== matchedCompany.issuePrefix) {
|
|
const suffix = location.pathname.replace(/^\/[^/]+/, "");
|
|
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
|
|
return;
|
|
}
|
|
|
|
if (
|
|
shouldSyncCompanySelectionFromRoute({
|
|
selectionSource,
|
|
selectedCompanyId,
|
|
routeCompanyId: matchedCompany.id,
|
|
})
|
|
) {
|
|
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
|
|
}
|
|
}, [
|
|
companyPrefix,
|
|
companies,
|
|
companiesLoading,
|
|
matchedCompany,
|
|
location.pathname,
|
|
location.search,
|
|
navigate,
|
|
selectionSource,
|
|
selectedCompanyId,
|
|
setSelectedCompanyId,
|
|
]);
|
|
|
|
const togglePanel = togglePanelVisible;
|
|
|
|
useCompanyPageMemory();
|
|
|
|
useKeyboardShortcuts({
|
|
onNewIssue: () => openNewIssue(),
|
|
onToggleSidebar: toggleSidebar,
|
|
onTogglePanel: togglePanel,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileNavVisible(true);
|
|
return;
|
|
}
|
|
lastMainScrollTop.current = 0;
|
|
setMobileNavVisible(true);
|
|
}, [isMobile]);
|
|
|
|
// Swipe gesture to open/close sidebar on mobile
|
|
useEffect(() => {
|
|
if (!isMobile) return;
|
|
|
|
const EDGE_ZONE = 30; // px from left edge to start open-swipe
|
|
const MIN_DISTANCE = 50; // minimum horizontal swipe distance
|
|
const MAX_VERTICAL = 75; // max vertical drift before we ignore
|
|
|
|
let startX = 0;
|
|
let startY = 0;
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
const t = e.touches[0]!;
|
|
startX = t.clientX;
|
|
startY = t.clientY;
|
|
};
|
|
|
|
const onTouchEnd = (e: TouchEvent) => {
|
|
const t = e.changedTouches[0]!;
|
|
const dx = t.clientX - startX;
|
|
const dy = Math.abs(t.clientY - startY);
|
|
|
|
if (dy > MAX_VERTICAL) return; // vertical scroll, ignore
|
|
|
|
// Swipe right from left edge → open
|
|
if (!sidebarOpen && startX < EDGE_ZONE && dx > MIN_DISTANCE) {
|
|
setSidebarOpen(true);
|
|
return;
|
|
}
|
|
|
|
// Swipe left when open → close
|
|
if (sidebarOpen && dx < -MIN_DISTANCE) {
|
|
setSidebarOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
document.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
|
|
return () => {
|
|
document.removeEventListener("touchstart", onTouchStart);
|
|
document.removeEventListener("touchend", onTouchEnd);
|
|
};
|
|
}, [isMobile, sidebarOpen, setSidebarOpen]);
|
|
|
|
const updateMobileNavVisibility = useCallback((currentTop: number) => {
|
|
const delta = currentTop - lastMainScrollTop.current;
|
|
|
|
if (currentTop <= 24) {
|
|
setMobileNavVisible(true);
|
|
} else if (delta > 8) {
|
|
setMobileNavVisible(false);
|
|
} else if (delta < -8) {
|
|
setMobileNavVisible(true);
|
|
}
|
|
|
|
lastMainScrollTop.current = currentTop;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileNavVisible(true);
|
|
lastMainScrollTop.current = 0;
|
|
return;
|
|
}
|
|
|
|
const onScroll = () => {
|
|
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
|
|
};
|
|
|
|
onScroll();
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("scroll", onScroll);
|
|
};
|
|
}, [isMobile, updateMobileNavVisibility]);
|
|
|
|
useEffect(() => {
|
|
const previousOverflow = document.body.style.overflow;
|
|
|
|
document.body.style.overflow = isMobile ? "visible" : "hidden";
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
};
|
|
}, [isMobile]);
|
|
|
|
useEffect(() => {
|
|
if (!location.pathname.startsWith("/instance/settings/")) return;
|
|
|
|
const nextPath = normalizeRememberedInstanceSettingsPath(
|
|
`${location.pathname}${location.search}${location.hash}`,
|
|
);
|
|
setInstanceSettingsTarget(nextPath);
|
|
|
|
try {
|
|
window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath);
|
|
} catch {
|
|
// Ignore storage failures in restricted environments.
|
|
}
|
|
}, [location.hash, location.pathname, location.search]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
|
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
|
|
)}
|
|
>
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
Skip to Main Content
|
|
</a>
|
|
<WorktreeBanner />
|
|
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
|
|
{isMobile && sidebarOpen && (
|
|
<button
|
|
type="button"
|
|
className="fixed inset-0 z-40 bg-black/50"
|
|
onClick={() => setSidebarOpen(false)}
|
|
aria-label="Close sidebar"
|
|
/>
|
|
)}
|
|
|
|
{isMobile ? (
|
|
<div
|
|
className={cn(
|
|
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
|
|
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
|
)}
|
|
>
|
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
<CompanyRail />
|
|
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
|
</div>
|
|
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
|
<div className="flex items-center gap-1">
|
|
<a
|
|
href="https://docs.paperclip.ing/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
|
>
|
|
<BookOpen className="h-4 w-4 shrink-0" />
|
|
<span className="truncate">Documentation</span>
|
|
</a>
|
|
{health?.version && (
|
|
<span className="px-2 text-xs text-muted-foreground shrink-0" title={`v${health.version}`}>v</span>
|
|
)}
|
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
|
<Link
|
|
to={instanceSettingsTarget}
|
|
aria-label="Instance settings"
|
|
title="Instance settings"
|
|
onClick={() => {
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground shrink-0"
|
|
onClick={toggleTheme}
|
|
aria-label={`Switch to ${nextTheme} mode`}
|
|
title={`Switch to ${nextTheme} mode`}
|
|
>
|
|
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full flex-col shrink-0">
|
|
<div className="flex flex-1 min-h-0">
|
|
<CompanyRail />
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden transition-[width] duration-100 ease-out",
|
|
sidebarOpen ? "w-60" : "w-0"
|
|
)}
|
|
>
|
|
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-r border-border px-3 py-2">
|
|
<div className="flex items-center gap-1">
|
|
<a
|
|
href="https://docs.paperclip.ing/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors text-foreground/80 hover:bg-accent/50 hover:text-foreground flex-1 min-w-0"
|
|
>
|
|
<BookOpen className="h-4 w-4 shrink-0" />
|
|
<span className="truncate">Documentation</span>
|
|
</a>
|
|
{health?.version && (
|
|
<span className="px-2 text-xs text-muted-foreground shrink-0" title={`v${health.version}`}>v</span>
|
|
)}
|
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
|
<Link
|
|
to={instanceSettingsTarget}
|
|
aria-label="Instance settings"
|
|
title="Instance settings"
|
|
onClick={() => {
|
|
if (isMobile) setSidebarOpen(false);
|
|
}}
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground shrink-0"
|
|
onClick={toggleTheme}
|
|
aria-label={`Switch to ${nextTheme} mode`}
|
|
title={`Switch to ${nextTheme} mode`}
|
|
>
|
|
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
|
<div
|
|
className={cn(
|
|
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
|
)}
|
|
>
|
|
<BreadcrumbBar />
|
|
</div>
|
|
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
|
<main
|
|
id="main-content"
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"flex-1 p-4 md:p-6",
|
|
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
|
)}
|
|
>
|
|
{hasUnknownCompanyPrefix ? (
|
|
<NotFoundPage
|
|
scope="invalid_company_prefix"
|
|
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
|
|
/>
|
|
) : (
|
|
<Outlet />
|
|
)}
|
|
</main>
|
|
<PropertiesPanel />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
|
<CommandPalette />
|
|
<NewIssueDialog />
|
|
<NewProjectDialog />
|
|
<NewGoalDialog />
|
|
<NewAgentDialog />
|
|
<ToastViewport />
|
|
</div>
|
|
);
|
|
}
|