mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
feat(dashboard): add mobile layout and responsive fixes
Bumps to 2.6.0. - MobileLayout activates via useIsMobile at <768px with bottom-tab navigation (Graph/Info/Files); panes stay mounted (visibility toggle) to preserve ReactFlow dimensions and FileExplorer state. - MobileDrawer holds persona, view mode, diff, node-type filters, layers, and tool buttons (Filter/Export/Path/Theme/Help). - Selecting a node auto-pivots to Info; CodeViewer is always fullscreen on mobile; SearchBar collapses to a 🔍 toggle. - Homepage Hero/Footer/Install responsive: drop nowrap on title and tagline, stack title spans for editorial wrap, full-width CTAs at <480px, narrow-width spacing refinements. - Desktop dashboard: sidebar telescopes 260/300/360px, header gaps tighten, Path button label collapses to icon at narrow widths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"author": {
|
||||
"name": "Lum1104"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"author": {
|
||||
"name": "Lum1104"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "understand-anything",
|
||||
"displayName": "Understand Anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"author": {
|
||||
"name": "Lum1104"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,14 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.15rem;
|
||||
@@ -60,4 +68,10 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer { padding: 3rem 1.25rem; }
|
||||
.footer-sep { display: none; }
|
||||
.footer-links { gap: 0.25rem 1rem; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -122,12 +122,13 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
|
||||
/* Headline */
|
||||
.hero-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: clamp(3.5rem, 9vw, 7rem);
|
||||
font-size: clamp(2.75rem, 9vw, 7rem);
|
||||
color: #e8e2d8;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.025em;
|
||||
margin-bottom: 2rem;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero-grad {
|
||||
@@ -141,7 +142,7 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
|
||||
|
||||
/* Tagline */
|
||||
.hero-tagline {
|
||||
font-size: clamp(1.1rem, 2vw, 1.4rem);
|
||||
font-size: clamp(1rem, 2vw, 1.4rem);
|
||||
color: #8a8578;
|
||||
line-height: 1.7;
|
||||
max-width: 900px;
|
||||
@@ -329,15 +330,56 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
|
||||
.anim-6 { animation-delay: 1.15s; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.hero-tagline {
|
||||
white-space: normal;
|
||||
max-width: 36ch;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-pillars { gap: 0.5rem; }
|
||||
.hero { padding: 5rem 1.5rem 3rem; min-height: auto; }
|
||||
.hero-content { gap: 0; }
|
||||
.hero-pillars { gap: 0.5rem; margin-bottom: 2.25rem; }
|
||||
.pillar { font-size: 0.8rem; }
|
||||
.hero-badge {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.35rem 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.hero-title { margin-bottom: 1.5rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title {
|
||||
white-space: normal;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
/* Stack the two words on dedicated lines for editorial drama */
|
||||
.hero-title > span { display: block; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-actions { flex-direction: column; gap: 1rem; }
|
||||
.hero { padding: 4rem 1.25rem 2.25rem; }
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
.hero-cta,
|
||||
.hero-demo {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 0.85rem 1.5rem;
|
||||
}
|
||||
.hero-pillars { flex-direction: column; gap: 0.4rem; }
|
||||
.pillar-dot { display: none; }
|
||||
.hero-subscribe { margin-top: 1.5rem; }
|
||||
.hero-subscribe-toggle { font-size: 0.85rem; }
|
||||
.hero-subscribe-frame iframe { height: 130px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -131,4 +131,14 @@
|
||||
.install-note strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.install { padding: 4.5rem 1.25rem; }
|
||||
.install-title { margin-bottom: 1.75rem; }
|
||||
pre { padding: 1rem 1.1rem; }
|
||||
code { font-size: 0.82rem; line-height: 1.7; }
|
||||
.install-code-header { padding: 0.65rem 0.85rem; }
|
||||
.install-code-label { font-size: 0.72rem; }
|
||||
.install-note { font-size: 0.82rem; line-height: 1.55; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "understand-anything",
|
||||
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"author": {
|
||||
"name": "Lum1104"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@understand-anything/skill",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -16,6 +16,8 @@ import ProjectOverview from "./components/ProjectOverview";
|
||||
import FileExplorer from "./components/FileExplorer";
|
||||
import WarningBanner from "./components/WarningBanner";
|
||||
import TokenGate from "./components/TokenGate";
|
||||
import MobileLayout from "./components/MobileLayout";
|
||||
import { useIsMobile } from "./hooks/useIsMobile";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts";
|
||||
import { ThemeProvider } from "./themes/index.ts";
|
||||
@@ -118,6 +120,7 @@ function Dashboard({ accessToken }: { accessToken: string }) {
|
||||
const domainGraph = useDashboardStore((s) => s.domainGraph);
|
||||
const setDomainGraph = useDashboardStore((s) => s.setDomainGraph);
|
||||
const layoutIssues = useDashboardStore((s) => s.layoutIssues);
|
||||
const isMobile = useIsMobile();
|
||||
// Schema issues + ELK layout issues share the WarningBanner — graph-load
|
||||
// problems and dashboard rendering problems are equally surfaced.
|
||||
const allIssues = useMemo(
|
||||
@@ -376,17 +379,32 @@ function Dashboard({ accessToken }: { accessToken: string }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<ThemeProvider metaTheme={metaTheme}>
|
||||
<MobileLayout
|
||||
accessToken={accessToken}
|
||||
showKeyboardHelp={showKeyboardHelp}
|
||||
setShowKeyboardHelp={setShowKeyboardHelp}
|
||||
loadError={loadError}
|
||||
allIssues={allIssues}
|
||||
shortcuts={shortcuts}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider metaTheme={metaTheme}>
|
||||
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
|
||||
{/* Header */}
|
||||
<header className="flex items-center px-5 py-3 bg-surface border-b border-border-subtle shrink-0 gap-4">
|
||||
<header className="flex items-center px-3 sm:px-5 py-3 bg-surface border-b border-border-subtle shrink-0 gap-2 sm:gap-4">
|
||||
{/* Left — fixed */}
|
||||
<div className="flex items-center gap-5 shrink-0">
|
||||
<h1 className="font-serif text-lg text-text-primary tracking-wide">
|
||||
<div className="flex items-center gap-3 sm:gap-5 shrink-0 min-w-0">
|
||||
<h1 className="font-serif text-base sm:text-lg text-text-primary tracking-wide truncate max-w-[160px] sm:max-w-[220px] lg:max-w-none">
|
||||
{graph?.project.name ?? "Understand Anything"}
|
||||
</h1>
|
||||
<div className="w-px h-5 bg-border-subtle" />
|
||||
<div className="w-px h-5 bg-border-subtle hidden sm:block" />
|
||||
<PersonaSelector />
|
||||
{graph && !isKnowledgeGraph && domainGraph && (
|
||||
<>
|
||||
@@ -463,12 +481,12 @@ function Dashboard({ accessToken }: { accessToken: string }) {
|
||||
</div>
|
||||
|
||||
{/* Right — fixed actions */}
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
|
||||
<FilterPanel />
|
||||
<ExportMenu />
|
||||
<button
|
||||
onClick={togglePathFinder}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
|
||||
className="flex items-center gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
|
||||
title="Find path between nodes (P)"
|
||||
>
|
||||
<svg
|
||||
@@ -484,7 +502,7 @@ function Dashboard({ accessToken }: { accessToken: string }) {
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
Path
|
||||
<span className="hidden md:inline">Path</span>
|
||||
</button>
|
||||
<ThemePicker />
|
||||
<button
|
||||
@@ -540,8 +558,8 @@ function Dashboard({ accessToken }: { accessToken: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right sidebar */}
|
||||
<aside className="w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-auto">
|
||||
{/* Right sidebar — telescopes at narrower widths */}
|
||||
<aside className="w-[260px] md:w-[300px] lg:w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-auto">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type MobileTab = "graph" | "info" | "files";
|
||||
|
||||
interface Props {
|
||||
activeTab: MobileTab;
|
||||
onTabChange: (tab: MobileTab) => void;
|
||||
}
|
||||
|
||||
const tabs: { id: MobileTab; label: string; icon: ReactNode }[] = [
|
||||
{
|
||||
id: "graph",
|
||||
label: "Graph",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
|
||||
<circle cx="6" cy="7" r="2" />
|
||||
<circle cx="18" cy="7" r="2" />
|
||||
<circle cx="12" cy="17" r="2" />
|
||||
<path strokeLinecap="round" d="M7.6 8.5L11 15.5M16.4 8.5L13 15.5M8 7h8" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "info",
|
||||
label: "Info",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 11v5M12 8h.01" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
label: "Files",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 6.5A1.5 1.5 0 0 1 5.5 5h3.382a1.5 1.5 0 0 1 1.342.83l.671 1.34A1.5 1.5 0 0 0 12.236 8H18.5A1.5 1.5 0 0 1 20 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function MobileBottomNav({ activeTab, onTabChange }: Props) {
|
||||
return (
|
||||
<nav className="flex shrink-0 bg-surface border-t border-border-subtle">
|
||||
{tabs.map((tab) => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`relative flex-1 flex flex-col items-center justify-center gap-1 py-2.5 text-[10px] font-semibold uppercase tracking-[0.14em] transition-colors ${
|
||||
active ? "text-accent" : "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<span className="w-5 h-5">{tab.icon}</span>
|
||||
{tab.label}
|
||||
{active && (
|
||||
<span className="absolute top-0 left-1/2 -translate-x-1/2 w-8 h-px bg-accent" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDashboardStore } from "../store";
|
||||
import PersonaSelector from "./PersonaSelector";
|
||||
import DiffToggle from "./DiffToggle";
|
||||
import LayerLegend from "./LayerLegend";
|
||||
import FilterPanel from "./FilterPanel";
|
||||
import ExportMenu from "./ExportMenu";
|
||||
import { ThemePicker } from "./ThemePicker";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onTogglePathFinder: () => void;
|
||||
onShowKeyboardHelp: () => void;
|
||||
}
|
||||
|
||||
interface NodeTypeFilterDef {
|
||||
key: "code" | "config" | "docs" | "infra" | "data" | "domain" | "knowledge";
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const STRUCTURAL_FILTERS: NodeTypeFilterDef[] = [
|
||||
{ key: "code", label: "Code", color: "var(--color-node-file)" },
|
||||
{ key: "config", label: "Config", color: "var(--color-node-config)" },
|
||||
{ key: "docs", label: "Docs", color: "var(--color-node-document)" },
|
||||
{ key: "infra", label: "Infra", color: "var(--color-node-service)" },
|
||||
{ key: "data", label: "Data", color: "var(--color-node-table)" },
|
||||
{ key: "domain", label: "Domain", color: "var(--color-node-concept)" },
|
||||
{ key: "knowledge", label: "Knowledge", color: "var(--color-node-article)" },
|
||||
];
|
||||
|
||||
const KNOWLEDGE_FILTERS: NodeTypeFilterDef[] = [
|
||||
{ key: "knowledge", label: "All", color: "var(--color-node-article)" },
|
||||
];
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted mb-3">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MobileDrawer({
|
||||
open,
|
||||
onClose,
|
||||
onTogglePathFinder,
|
||||
onShowKeyboardHelp,
|
||||
}: Props) {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph);
|
||||
const domainGraph = useDashboardStore((s) => s.domainGraph);
|
||||
const viewMode = useDashboardStore((s) => s.viewMode);
|
||||
const setViewMode = useDashboardStore((s) => s.setViewMode);
|
||||
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
|
||||
const toggleNodeTypeFilter = useDashboardStore((s) => s.toggleNodeTypeFilter);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Lock body scroll while open so the page behind doesn't drift
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = original;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filterDefs = isKnowledgeGraph ? KNOWLEDGE_FILTERS : STRUCTURAL_FILTERS;
|
||||
const showViewToggle = Boolean(graph && !isKnowledgeGraph && domainGraph);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-40 ${open ? "pointer-events-auto" : "pointer-events-none"}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
onClick={onClose}
|
||||
className={`absolute inset-0 bg-black/65 backdrop-blur-sm transition-opacity duration-300 ${
|
||||
open ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<aside
|
||||
className={`absolute left-0 top-0 bottom-0 w-[86%] max-w-[360px] bg-surface border-r border-border-subtle flex flex-col transition-transform duration-300 ease-out ${
|
||||
open ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
role="dialog"
|
||||
aria-label="Settings"
|
||||
>
|
||||
{/* Drawer header */}
|
||||
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle shrink-0">
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.2em] text-accent">
|
||||
Controls
|
||||
</span>
|
||||
<h2 className="font-serif text-lg text-text-primary mt-0.5 leading-none">
|
||||
{graph?.project.name ?? "Dashboard"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-elevated transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M6 18L18 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto px-5 py-5 space-y-7">
|
||||
<section>
|
||||
<SectionLabel>Role</SectionLabel>
|
||||
<PersonaSelector />
|
||||
</section>
|
||||
|
||||
{showViewToggle && (
|
||||
<section>
|
||||
<SectionLabel>View</SectionLabel>
|
||||
<div className="inline-flex items-center bg-elevated rounded-lg p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("domain")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
viewMode === "domain"
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
Domain
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("structural")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
viewMode === "structural"
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-text-muted hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
Structural
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionLabel>Diff overlay</SectionLabel>
|
||||
<DiffToggle />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionLabel>Node types</SectionLabel>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filterDefs.map((cat) => {
|
||||
const active = nodeTypeFilters[cat.key] !== false;
|
||||
return (
|
||||
<button
|
||||
key={cat.key}
|
||||
type="button"
|
||||
onClick={() => toggleNodeTypeFilter(cat.key)}
|
||||
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-1 rounded border transition-colors flex items-center gap-1.5 whitespace-nowrap ${
|
||||
active
|
||||
? "border-border-medium bg-elevated text-text-secondary"
|
||||
: "border-transparent bg-transparent text-text-muted/40 line-through"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: cat.color,
|
||||
opacity: active ? 1 : 0.3,
|
||||
}}
|
||||
/>
|
||||
{cat.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{graph && (graph.layers?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<SectionLabel>Layers</SectionLabel>
|
||||
<div className="-mx-1">
|
||||
<LayerLegend />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionLabel>Tools</SectionLabel>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterPanel />
|
||||
<ExportMenu />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTogglePathFinder();
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
Path
|
||||
</button>
|
||||
<ThemePicker />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onShowKeyboardHelp();
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Help
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import type { GraphIssue } from "@understand-anything/core/schema";
|
||||
import { useDashboardStore } from "../store";
|
||||
import GraphView from "./GraphView";
|
||||
import DomainGraphView from "./DomainGraphView";
|
||||
import KnowledgeGraphView from "./KnowledgeGraphView";
|
||||
import SearchBar from "./SearchBar";
|
||||
import NodeInfo from "./NodeInfo";
|
||||
import ProjectOverview from "./ProjectOverview";
|
||||
import FileExplorer from "./FileExplorer";
|
||||
import WarningBanner from "./WarningBanner";
|
||||
import MobileBottomNav from "./MobileBottomNav";
|
||||
import type { MobileTab } from "./MobileBottomNav";
|
||||
import MobileDrawer from "./MobileDrawer";
|
||||
|
||||
const CodeViewer = lazy(() => import("./CodeViewer"));
|
||||
const LearnPanel = lazy(() => import("./LearnPanel"));
|
||||
const PathFinderModal = lazy(() => import("./PathFinderModal"));
|
||||
const KeyboardShortcutsHelp = lazy(() => import("./KeyboardShortcutsHelp"));
|
||||
|
||||
interface Props {
|
||||
accessToken: string;
|
||||
showKeyboardHelp: boolean;
|
||||
setShowKeyboardHelp: (value: boolean) => void;
|
||||
loadError: string | null;
|
||||
allIssues: GraphIssue[];
|
||||
shortcuts: import("../hooks/useKeyboardShortcuts").KeyboardShortcut[];
|
||||
}
|
||||
|
||||
export default function MobileLayout({
|
||||
accessToken,
|
||||
showKeyboardHelp,
|
||||
setShowKeyboardHelp,
|
||||
loadError,
|
||||
allIssues,
|
||||
shortcuts,
|
||||
}: Props) {
|
||||
const graph = useDashboardStore((s) => s.graph);
|
||||
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
|
||||
const tourActive = useDashboardStore((s) => s.tourActive);
|
||||
const persona = useDashboardStore((s) => s.persona);
|
||||
const viewMode = useDashboardStore((s) => s.viewMode);
|
||||
const domainGraph = useDashboardStore((s) => s.domainGraph);
|
||||
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
|
||||
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
|
||||
const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen);
|
||||
const togglePathFinder = useDashboardStore((s) => s.togglePathFinder);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<MobileTab>("graph");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
// Auto-pivot to Info when a node is selected — keeps feedback visible
|
||||
// on a small screen where graph and sidebar can't coexist
|
||||
useEffect(() => {
|
||||
if (selectedNodeId) setActiveTab("info");
|
||||
}, [selectedNodeId]);
|
||||
|
||||
// When a code viewer opens (e.g. from the Files tab) keep focus there
|
||||
useEffect(() => {
|
||||
if (codeViewerOpen) setSearchOpen(false);
|
||||
}, [codeViewerOpen]);
|
||||
|
||||
const isLearnMode = tourActive || persona === "junior";
|
||||
const infoContent = (
|
||||
<>
|
||||
{selectedNodeId && <NodeInfo />}
|
||||
{isLearnMode && (
|
||||
<Suspense fallback={null}>
|
||||
<LearnPanel />
|
||||
</Suspense>
|
||||
)}
|
||||
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center gap-2 px-3 h-12 shrink-0 bg-surface border-b border-border-subtle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:text-text-primary hover:bg-elevated transition-colors -ml-1"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h1 className="font-serif text-base flex-1 min-w-0 truncate text-center text-text-primary tracking-wide">
|
||||
{graph?.project.name ?? "Understand Anything"}
|
||||
</h1>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchOpen((prev) => !prev)}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-colors -mr-1 ${
|
||||
searchOpen
|
||||
? "text-accent bg-accent/15"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
|
||||
}`}
|
||||
aria-label={searchOpen ? "Hide search" : "Show search"}
|
||||
aria-pressed={searchOpen}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Search (collapsible) */}
|
||||
{searchOpen && <SearchBar />}
|
||||
|
||||
{/* Validation warnings */}
|
||||
{allIssues.length > 0 && !loadError && <WarningBanner issues={allIssues} />}
|
||||
|
||||
{/* Load error */}
|
||||
{loadError && (
|
||||
<div className="px-4 py-3 bg-red-900/30 border-b border-red-700 text-red-200 text-sm">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabbed content — all panes stay mounted to preserve layout/state.
|
||||
Inactive panes are kept in the layout (not display:none) so that
|
||||
ReactFlow keeps real dimensions and pinch/pan don't collapse on
|
||||
tab switch. */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
activeTab === "graph" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "graph"}
|
||||
>
|
||||
{viewMode === "knowledge" ? (
|
||||
<KnowledgeGraphView />
|
||||
) : viewMode === "domain" && domainGraph ? (
|
||||
<DomainGraphView />
|
||||
) : (
|
||||
<GraphView />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 overflow-auto bg-surface ${
|
||||
activeTab === "info" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "info"}
|
||||
>
|
||||
{infoContent}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 overflow-auto bg-surface ${
|
||||
activeTab === "files" ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={activeTab !== "files"}
|
||||
>
|
||||
<FileExplorer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tab nav */}
|
||||
<MobileBottomNav activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* Drawer */}
|
||||
<MobileDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onTogglePathFinder={togglePathFinder}
|
||||
onShowKeyboardHelp={() => setShowKeyboardHelp(true)}
|
||||
/>
|
||||
|
||||
{/* Code viewer — always fullscreen on mobile */}
|
||||
{codeViewerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex bg-black/70 backdrop-blur-sm p-2 sm:p-4"
|
||||
onMouseDown={closeCodeViewer}
|
||||
>
|
||||
<div
|
||||
className="flex-1 rounded-lg border border-border-medium bg-surface shadow-2xl overflow-hidden"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<CodeViewer
|
||||
accessToken={accessToken}
|
||||
presentation="modal"
|
||||
onClose={closeCodeViewer}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard help (mobile reads it as a quick reference too) */}
|
||||
{showKeyboardHelp && (
|
||||
<Suspense fallback={null}>
|
||||
<KeyboardShortcutsHelp
|
||||
shortcuts={shortcuts}
|
||||
onClose={() => setShowKeyboardHelp(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Path finder */}
|
||||
{pathFinderOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<PathFinderModal isOpen={pathFinderOpen} onClose={togglePathFinder} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export default function SearchBar() {
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative z-30">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-surface border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-surface border-b border-border-subtle">
|
||||
<svg
|
||||
className="w-4 h-4 text-text-muted shrink-0"
|
||||
fill="none"
|
||||
@@ -105,7 +105,7 @@ export default function SearchBar() {
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
placeholder="Search nodes by name, summary, or tags..."
|
||||
className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted"
|
||||
className="flex-1 min-w-0 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted"
|
||||
/>
|
||||
<div className="flex items-center gap-1 bg-elevated rounded-lg p-0.5 shrink-0">
|
||||
<button
|
||||
@@ -130,7 +130,7 @@ export default function SearchBar() {
|
||||
</button>
|
||||
</div>
|
||||
{searchQuery.trim() && (
|
||||
<span className="text-xs text-text-muted shrink-0">
|
||||
<span className="hidden sm:inline text-xs text-text-muted shrink-0">
|
||||
{searchResults.length} result{searchResults.length !== 1 ? "s" : ""}{" "}
|
||||
<span className="text-text-muted">({searchMode})</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const DEFAULT_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile(breakpoint: number = DEFAULT_BREAKPOINT): boolean {
|
||||
const query = `(max-width: ${breakpoint - 1}px)`;
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches);
|
||||
setIsMobile(mql.matches);
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
Reference in New Issue
Block a user