Make portal mobile-friendly with floating buttons and slide-over panels
Replace fixed sidebar with floating action buttons (menu + add node) in bottom-left corner. Sidebar and node panel now slide in as overlay drawers with backdrop dismiss, capped width for small screens, and slide animations.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import GraphView from './components/GraphView';
|
import GraphView from './components/GraphView';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
@@ -12,6 +12,7 @@ export default function App() {
|
|||||||
const [showAddNode, setShowAddNode] = useState(false);
|
const [showAddNode, setShowAddNode] = useState(false);
|
||||||
const [linkFromId, setLinkFromId] = useState<string | null>(null);
|
const [linkFromId, setLinkFromId] = useState<string | null>(null);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
@@ -24,25 +25,85 @@ export default function App() {
|
|||||||
setTimeout(() => setToast(null), 3000);
|
setTimeout(() => setToast(null), 3000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const selectNode = useCallback((id: string) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (selectedId) setSelectedId(null);
|
||||||
|
else if (drawerOpen) setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [selectedId, drawerOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen overflow-hidden">
|
<div className="h-screen w-screen overflow-hidden relative">
|
||||||
<Sidebar
|
{/* Full-screen graph */}
|
||||||
selectedId={selectedId}
|
<GraphView selectedId={selectedId} onSelect={selectNode} />
|
||||||
onSelect={setSelectedId}
|
|
||||||
onAddNode={() => setShowAddNode(true)}
|
{/* Floating action buttons — bottom-left */}
|
||||||
/>
|
<div className="fixed bottom-5 left-5 flex flex-col gap-2 z-30">
|
||||||
<div className="flex-1 relative">
|
<button
|
||||||
<GraphView selectedId={selectedId} onSelect={setSelectedId} />
|
onClick={() => setDrawerOpen(!drawerOpen)}
|
||||||
|
className="w-12 h-12 rounded-full bg-gray-800/90 backdrop-blur border border-gray-700 text-gray-300 hover:bg-gray-700 hover:text-white shadow-lg flex items-center justify-center text-lg"
|
||||||
|
title="Node list"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddNode(true)}
|
||||||
|
className="w-12 h-12 rounded-full bg-indigo-600/90 backdrop-blur border border-indigo-500 text-white hover:bg-indigo-500 shadow-lg flex items-center justify-center text-xl font-light"
|
||||||
|
title="Add node"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{selectedId && (
|
|
||||||
<NodePanel
|
{/* Sidebar drawer — slides in from left */}
|
||||||
nodeId={selectedId}
|
{drawerOpen && (
|
||||||
onClose={() => setSelectedId(null)}
|
<div className="fixed inset-0 z-40" onClick={() => setDrawerOpen(false)}>
|
||||||
onLink={(id) => setLinkFromId(id)}
|
<div className="absolute inset-0 bg-black/40" />
|
||||||
onRefresh={refresh}
|
<div
|
||||||
onNotify={notify}
|
className="absolute top-0 left-0 h-full w-80 max-w-[85vw] animate-slide-in-left"
|
||||||
/>
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={selectNode}
|
||||||
|
onAddNode={() => { setDrawerOpen(false); setShowAddNode(true); }}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Node detail panel — slides in from right */}
|
||||||
|
{selectedId && (
|
||||||
|
<div className="fixed inset-0 z-40 pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40 pointer-events-auto"
|
||||||
|
onClick={() => setSelectedId(null)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-0 h-full w-80 max-w-[90vw] pointer-events-auto animate-slide-in-right"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<NodePanel
|
||||||
|
nodeId={selectedId}
|
||||||
|
onClose={() => setSelectedId(null)}
|
||||||
|
onLink={(id) => setLinkFromId(id)}
|
||||||
|
onRefresh={refresh}
|
||||||
|
onNotify={notify}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showAddNode && (
|
{showAddNode && (
|
||||||
<AddNodeModal
|
<AddNodeModal
|
||||||
onClose={() => setShowAddNode(false)}
|
onClose={() => setShowAddNode(false)}
|
||||||
|
|||||||
@@ -1,2 +1,20 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@xyflow/react/dist/style.css";
|
@import "@xyflow/react/dist/style.css";
|
||||||
|
|
||||||
|
@keyframes slide-in-left {
|
||||||
|
from { transform: translateX(-100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-left {
|
||||||
|
animation: slide-in-left 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slide-in-right 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import type { NodeWithConnections } from '../types';
|
|
||||||
|
|
||||||
export default function NodePanel({
|
export default function NodePanel({
|
||||||
nodeId,
|
nodeId,
|
||||||
@@ -25,17 +24,9 @@ export default function NodePanel({
|
|||||||
const [editing, setEditing] = useState<string | null>(null);
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
||||||
window.addEventListener('keydown', handler);
|
|
||||||
return () => window.removeEventListener('keydown', handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
if (isLoading || !node) {
|
if (isLoading || !node) {
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-gray-900 border-l border-gray-800 p-4 text-gray-400">
|
<div className="h-full bg-gray-900 p-4 text-gray-400">Loading...</div>
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +76,7 @@ export default function NodePanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-80 bg-gray-900 border-l border-gray-800 flex flex-col h-full overflow-y-auto">
|
<aside className="h-full bg-gray-900 border-l border-gray-800 flex flex-col overflow-y-auto">
|
||||||
<div className="p-4 border-b border-gray-800 flex justify-between items-start">
|
<div className="p-4 border-b border-gray-800 flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] uppercase tracking-wide text-gray-500">{node.kind}</span>
|
<span className="text-[10px] uppercase tracking-wide text-gray-500">{node.kind}</span>
|
||||||
|
|||||||
@@ -9,18 +9,23 @@ export default function Sidebar({
|
|||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onAddNode,
|
onAddNode,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onAddNode: () => void;
|
onAddNode: () => void;
|
||||||
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [kindFilter, setKindFilter] = useState('');
|
const [kindFilter, setKindFilter] = useState('');
|
||||||
const { data: nodes, isLoading } = useNodes(kindFilter ? { kind: kindFilter } : undefined);
|
const { data: nodes, isLoading } = useNodes(kindFilter ? { kind: kindFilter } : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 bg-gray-900 border-r border-gray-800 flex flex-col h-full">
|
<aside className="h-full bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||||
<div className="p-4 border-b border-gray-800">
|
<div className="p-4 border-b border-gray-800">
|
||||||
<h1 className="text-lg font-bold text-white mb-3">Cortex Portal</h1>
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h1 className="text-lg font-bold text-white">Cortex Portal</h1>
|
||||||
|
<button onClick={onClose} className="text-gray-500 hover:text-white text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
<SearchBar onSelect={onSelect} />
|
<SearchBar onSelect={onSelect} />
|
||||||
<div className="flex gap-1 mt-3 flex-wrap">
|
<div className="flex gap-1 mt-3 flex-wrap">
|
||||||
{KINDS.map((k) => (
|
{KINDS.map((k) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user