jamdesk 1.1.49 → 1.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,47 +1,31 @@
1
- import { headers } from 'next/headers';
2
- import { getDocsConfig } from '@/lib/docs';
3
- import { getDocsConfig as getIsrDocsConfig } from '@/lib/docs-isr';
4
- import { isIsrMode, getProjectFromRequest } from '@/lib/page-isr-helpers';
5
- import { NotFoundContent } from '@/components/errors/NotFoundContent';
6
- import type { DocsConfig } from '@/lib/docs-types';
7
-
1
+ // Bare 404 for unresolved-project requests (wild hostname, deleted R2
2
+ // project). Renders WITHOUT docs chrome — no theme, no fonts, no
3
+ // providers because there's no project context to read chrome from.
4
+ // In-project 404s (real project, missing slug) are returned by
5
+ // app/[[...slug]]/page.tsx via Next's notFound() and inherit the docs
6
+ // layout and chrome from the catch-all segment.
8
7
  export const metadata = {
9
- title: 'Page Not Found',
8
+ title: 'Site not found',
10
9
  };
11
10
 
12
- export default async function NotFound() {
13
- // Get config - from R2 in ISR mode, from filesystem in static mode
14
- let config: DocsConfig;
15
-
16
- if (isIsrMode()) {
17
- const headersList = await headers();
18
- const projectSlug = getProjectFromRequest(headersList);
19
- if (projectSlug) {
20
- try {
21
- config = await getIsrDocsConfig(projectSlug);
22
- } catch {
23
- // Project not found - use minimal fallback config
24
- config = {
25
- name: 'Documentation',
26
- theme: 'jam',
27
- colors: { primary: '#0ea5e9' },
28
- navigation: { groups: [] },
29
- };
30
- }
31
- } else {
32
- // No project slug - use minimal fallback
33
- config = {
34
- name: 'Documentation',
35
- theme: 'jam',
36
- colors: { primary: '#0ea5e9' },
37
- navigation: { groups: [] },
38
- };
39
- }
40
- } else {
41
- config = getDocsConfig();
42
- }
43
-
44
- // Note: LayoutWrapper is already provided by the root layout (layout.tsx)
45
- // so we only render the content here
46
- return <NotFoundContent config={config} />;
11
+ export default function RootNotFound() {
12
+ return (
13
+ <html lang="en">
14
+ <body
15
+ style={{
16
+ margin: 0,
17
+ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
18
+ padding: '4rem 2rem',
19
+ maxWidth: '40rem',
20
+ marginInline: 'auto',
21
+ color: '#111',
22
+ }}
23
+ >
24
+ <h1 style={{ fontSize: '2rem', fontWeight: 600 }}>Site not found</h1>
25
+ <p style={{ marginTop: '1rem', color: '#666' }}>
26
+ This Jamdesk project does not exist or has been removed.
27
+ </p>
28
+ </body>
29
+ </html>
30
+ );
47
31
  }
@@ -298,7 +298,7 @@ export function AIActionsMenu({ options, projectName }: AIActionsMenuProps) {
298
298
  const menuRef = useRef<HTMLDivElement>(null);
299
299
  const triggerRef = useRef<HTMLButtonElement>(null);
300
300
  const itemsRef = useRef<(HTMLButtonElement | HTMLAnchorElement | null)[]>([]);
301
- const pathname = usePathname();
301
+ const pathname = usePathname() ?? '';
302
302
  const linkPrefix = useLinkPrefix();
303
303
 
304
304
  // Compute URLs client-side to survive SPA navigation
@@ -8,20 +8,15 @@ export { FA_CSS_HREF };
8
8
  /**
9
9
  * Ensures Font Awesome CSS is loaded in the document head.
10
10
  *
11
- * On normal pages, the inline script in layout.tsx's <head> loads the
12
- * stylesheet during initial HTML parsing. But on 404 pages, Next.js renders
13
- * a minimal error shell (<html id="__next_error__">) that omits the root
14
- * layout's <head> content. The RSC payload later adds the script tag via
15
- * React hydration, but inline scripts injected by React after initial load
16
- * don't execute. This component acts as a fallback, injecting the stylesheet
17
- * link via useEffect so icons render on 404 pages too.
11
+ * On normal pages, layout-helpers calls preinit(FA_CSS_HREF, { as: 'style' })
12
+ * and React 19 hoists the <link rel="stylesheet"> into <head> at SSR time. But
13
+ * on 404 pages Next.js renders a minimal error shell (<html id="__next_error__">)
14
+ * that omits the root layout's <head> content. This component runs in
15
+ * useEffect as a client-side fallback so icons still render on 404 pages.
18
16
  */
19
17
  export function FontAwesomeLoader() {
20
18
  useEffect(() => {
21
- // Inline script in <head> sets this flag — skip DOM query on normal pages
22
- if ((window as Window & { __FA_CSS_LOADED__?: boolean }).__FA_CSS_LOADED__) return;
23
-
24
- // 404 error shell path: check if stylesheet exists (handles browser-normalized absolute URLs)
19
+ // querySelector handles browser-normalized absolute href values.
25
20
  const existing = document.querySelector(
26
21
  `link[rel="stylesheet"][href$="${FA_CSS_HREF}"]`
27
22
  );
@@ -31,6 +26,7 @@ export function FontAwesomeLoader() {
31
26
  link.rel = 'stylesheet';
32
27
  link.href = FA_CSS_HREF;
33
28
  document.head.appendChild(link);
29
+ return () => link.remove();
34
30
  }, []);
35
31
 
36
32
  return null;
@@ -20,7 +20,7 @@ interface HtmlLangSyncProps {
20
20
  * `lang` pointing at the previous language until a hard refresh.
21
21
  */
22
22
  export function HtmlLangSync({ defaultLanguage }: HtmlLangSyncProps) {
23
- const pathname = usePathname();
23
+ const pathname = usePathname() ?? '';
24
24
 
25
25
  useEffect(() => {
26
26
  const lang = extractLanguageFromPath(pathname || '/') ?? defaultLanguage;
@@ -81,7 +81,11 @@ export function NotFoundContent({ config }: NotFoundContentProps) {
81
81
  }
82
82
  }
83
83
 
84
- return `${linkPrefix}/index`;
84
+ // No matched page — go to the project home (`/docs` for hostAtDocs,
85
+ // `/` otherwise). FALLBACK_CONFIG with empty groups hits this branch
86
+ // and previously produced `/index` (404 on click) or `/docs/index`
87
+ // (also 404). The bare prefix is the reachable project root.
88
+ return linkPrefix || '/';
85
89
  };
86
90
 
87
91
  return (
@@ -47,27 +47,11 @@ export function LayoutWrapper({ config, children }: LayoutWrapperProps) {
47
47
  // Handle hash navigation for three-column independent scroll layout
48
48
  useHashNavigation();
49
49
 
50
- // Toggle 'scrolled' class on body when user scrolls past 500px
51
- // Used by Jam theme to hide gradient and make header solid
52
- // The gradient extends 500px from top, so we hide it after scrolling past that point
53
- useEffect(() => {
54
- const contentScroll = document.getElementById('content-scroll-container');
55
- const scrollTarget = contentScroll || window;
56
-
57
- const handleScroll = () => {
58
- const scrollY = contentScroll ? contentScroll.scrollTop : window.scrollY;
59
- if (scrollY > 500) {
60
- document.body.classList.add('scrolled');
61
- } else {
62
- document.body.classList.remove('scrolled');
63
- }
64
- };
65
-
66
- scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
67
- handleScroll(); // Check initial position
68
-
69
- return () => scrollTarget.removeEventListener('scroll', handleScroll);
70
- }, []);
50
+ // The body.scrolled toggle used to live here, but #content-scroll-container
51
+ // is rendered by PageColumns (in page.tsx) and remounts on every navigation.
52
+ // The listener attached once at LayoutWrapper mount referenced a detached
53
+ // node after the first nav, so .scrolled never updated again. The effect
54
+ // now lives in PageColumns so it re-binds on each container instance.
71
55
 
72
56
  // Get theme config to determine layout
73
57
  const themeConfig = getTheme(config.theme as ThemeName | undefined);
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { type ReactNode } from 'react';
3
+ import { useEffect, useRef, type ReactNode } from 'react';
4
4
  import { useChatPanel } from '@/hooks/useChatPanel';
5
5
 
6
6
  interface PageColumnsProps {
@@ -16,11 +16,34 @@ interface PageColumnsProps {
16
16
  */
17
17
  export function PageColumns({ children, toc, isWideMode }: PageColumnsProps) {
18
18
  const { isChatOpen } = useChatPanel();
19
+ const scrollRef = useRef<HTMLDivElement>(null);
20
+
21
+ // Toggle body.scrolled when the user scrolls past 500px in the content
22
+ // column. Lives here (not in LayoutWrapper) because the scroll container is
23
+ // rendered by this component and is replaced on every page navigation —
24
+ // attaching the listener at the layout level left it bound to a detached
25
+ // node after the first nav. Used by the Jam theme to hide the top gradient
26
+ // and make the header solid (themes/jam/variables.css).
27
+ useEffect(() => {
28
+ const contentScroll = scrollRef.current;
29
+ const scrollTarget: HTMLElement | Window = contentScroll ?? window;
30
+
31
+ const handleScroll = () => {
32
+ const scrollY = contentScroll ? contentScroll.scrollTop : window.scrollY;
33
+ document.body.classList.toggle('scrolled', scrollY > 500);
34
+ };
35
+
36
+ scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
37
+ handleScroll();
38
+
39
+ return () => scrollTarget.removeEventListener('scroll', handleScroll);
40
+ }, []);
19
41
 
20
42
  return (
21
43
  <div className="flex h-full">
22
44
  {/* Content column - scrolls independently */}
23
45
  <div
46
+ ref={scrollRef}
24
47
  id="content-scroll-container"
25
48
  className="flex-1 min-w-0 lg:overflow-y-auto lg:h-full content-scroll"
26
49
  >
@@ -44,7 +44,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
44
44
  const [isDark, setIsDark] = useState(false);
45
45
  const [isTabsDropdownOpen, setIsTabsDropdownOpen] = useState(false);
46
46
  const tabsDropdownRef = useRef<HTMLDivElement>(null);
47
- const pathname = usePathname();
47
+ const pathname = usePathname() ?? '';
48
48
  const linkPrefix = useLinkPrefix();
49
49
  const currentLang = extractLanguageFromPath(pathname || '/');
50
50
  const ui = getUiStrings(currentLang);
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
4
  import { useOnClickOutside } from '@/hooks/useOnClickOutside';
5
- import { useRouter, usePathname } from 'next/navigation';
5
+ import { usePathname, useRouter } from 'next/navigation';
6
6
  import type { ResolvedLanguage } from '@/lib/navigation-resolver';
7
7
  import type { LanguageCode } from '@/lib/docs-types';
8
8
  import {
@@ -33,7 +33,7 @@ export function LanguageSelector({
33
33
  }: LanguageSelectorProps) {
34
34
  const router = useRouter();
35
35
  const linkPrefix = useLinkPrefix();
36
- const pathname = usePathname();
36
+ const pathname = usePathname() ?? '';
37
37
  const ui = getUiStrings(extractLanguageFromPath(pathname || '/'));
38
38
  const [isOpen, setIsOpen] = useState(false);
39
39
  const [focusedIndex, setFocusedIndex] = useState(-1);
@@ -134,7 +134,7 @@ function findGroupsContainingPath(groups: ResolvedGroup[], currentPath: string,
134
134
  }
135
135
 
136
136
  export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPositionProp, isOpen = false, onClose }: SidebarProps) {
137
- const pathname = usePathname();
137
+ const pathname = usePathname() ?? '';
138
138
  const router = useRouter();
139
139
  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
140
140
  const [logoError, setLogoError] = useState(false);
@@ -149,9 +149,19 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
149
149
  // Two-stage feedback for nav clicks:
150
150
  // pendingPathname → optimistic active highlight (instant)
151
151
  // spinnerPathname → small spinner on the row (after 500ms, only if nav is slow)
152
- const { pendingPathname, spinnerPathname, onNavigate: handleNavigate } =
152
+ const { pendingPathname, spinnerPathname, onNavigate: rawNavigate } =
153
153
  useDelayedNavigationSpinner(pathname);
154
154
 
155
+ // Suppresses the auto-scroll-to-active-link effect for user-initiated nav.
156
+ // The user just clicked a link they could see — moving it under their cursor
157
+ // is jarring (see the navigation shift recordings from 2026-04-29). We still
158
+ // auto-scroll for back/forward, deep links, and initial mount.
159
+ const userInitiatedNavRef = useRef(false);
160
+ const handleNavigate = useCallback((url: string) => {
161
+ userInitiatedNavRef.current = true;
162
+ rawNavigate(url);
163
+ }, [rawNavigate]);
164
+
155
165
  const effectivePathname = pendingPathname || pathname;
156
166
 
157
167
  // Determine effective tabsPosition:
@@ -253,9 +263,22 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
253
263
  // eslint-disable-next-line react-hooks/exhaustive-deps
254
264
  }, [pathname]); // Intentionally excluding onClose - we only want to trigger on pathname changes
255
265
 
256
- // Scroll the active navigation link into view when pathname changes
266
+ // Scroll the active navigation link into view when pathname changes.
267
+ //
268
+ // Skipped for user-initiated nav: the user just clicked a sidebar link they
269
+ // could see, so moving it under their cursor is a jarring shift. For
270
+ // back/forward, deep links, and initial mount we still auto-scroll, but only
271
+ // when the active link is meaningfully off-screen (<80% visible) — being
272
+ // partially clipped by 1px shouldn't trigger a centering jump.
273
+ //
274
+ // Uses scrollTop math instead of scrollIntoView() so it only moves the
275
+ // sidebar's own scroll container; scrollIntoView walks all scrollable
276
+ // ancestors and was also tugging the document.
257
277
  useEffect(() => {
258
- // Double RAF ensures DOM is painted before measuring
278
+ if (userInitiatedNavRef.current) {
279
+ userInitiatedNavRef.current = false;
280
+ return;
281
+ }
259
282
  let rafId = requestAnimationFrame(() => {
260
283
  rafId = requestAnimationFrame(() => {
261
284
  const scrollContainer = sidebarRef.current?.querySelector('.sidebar-scroll');
@@ -264,11 +287,17 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
264
287
 
265
288
  const containerRect = scrollContainer.getBoundingClientRect();
266
289
  const linkRect = activeLink.getBoundingClientRect();
267
- const isVisible = linkRect.top >= containerRect.top && linkRect.bottom <= containerRect.bottom;
268
-
269
- if (!isVisible) {
270
- activeLink.scrollIntoView({ block: 'center', behavior: 'instant' });
271
- }
290
+ const linkHeight = linkRect.bottom - linkRect.top;
291
+ const visibleTop = Math.max(linkRect.top, containerRect.top);
292
+ const visibleBottom = Math.min(linkRect.bottom, containerRect.bottom);
293
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
294
+ const visibleFraction = linkHeight > 0 ? visibleHeight / linkHeight : 1;
295
+ if (visibleFraction >= 0.8) return;
296
+
297
+ const desiredCenter = containerRect.top + containerRect.height / 2;
298
+ const linkCenter = linkRect.top + linkHeight / 2;
299
+ const delta = linkCenter - desiredCenter;
300
+ (scrollContainer as HTMLElement).scrollTop += delta;
272
301
  });
273
302
  });
274
303
 
@@ -17,7 +17,7 @@ interface TabsNavProps {
17
17
  }
18
18
 
19
19
  export function TabsNav({ config, className = '' }: TabsNavProps) {
20
- const pathname = usePathname();
20
+ const pathname = usePathname() ?? '';
21
21
  const linkPrefix = useLinkPrefix();
22
22
 
23
23
  // Determine effective tabsPosition
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState, useRef, type ReactElement } from 'react';
4
- import { useRouter, usePathname } from 'next/navigation';
4
+ import { usePathname, useRouter } from 'next/navigation';
5
5
  import { getRecentSearches, addRecentSearch, clearRecentSearches } from '@/lib/recent-searches';
6
6
  import { useFocusTrap } from '@/hooks/useFocusTrap';
7
7
  import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';