/**
* QuickAccessPanel Component
*
* Displays recent memories and saved sessions for quick access.
* Uses glassmorphism styling consistent with the dark theme design.
*
* Requirements: 23.3
*/
import { useCallback, useMemo, useState } from 'react';
import type { Bookmark, SavedPath } from '../../stores/sessionStore';
// ============================================================================
// Types
// ============================================================================
export interface RecentMemory {
/** Memory ID */
id: string;
/** Memory content preview */
contentPreview: string;
/** Primary sector type */
primarySector: string;
/** Last accessed timestamp */
lastAccessed: number;
}
export interface QuickAccessPanelProps {
/** Recent memories to display */
recentMemories: RecentMemory[];
/** Bookmarked memories */
bookmarks: Bookmark[];
/** Saved exploration paths */
savedPaths: SavedPath[];
/** Callback when a memory is clicked */
onMemoryClick?: (memoryId: string) => void;
/** Callback when a path is clicked */
onPathClick?: (pathId: string) => void;
/** Callback when a bookmark is clicked */
onBookmarkClick?: (nodeId: string) => void;
/** Whether the panel is expanded */
isExpanded?: boolean;
/** Callback to toggle expansion */
onToggleExpand?: () => void;
/** Maximum items to show per section */
maxItems?: number;
/** Additional CSS classes */
className?: string;
}
type TabId = 'recent' | 'bookmarks' | 'paths';
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_MAX_ITEMS = 5;
const SECTOR_COLORS: Record<string, string> = {
episodic: 'text-sector-episodic',
semantic: 'text-sector-semantic',
procedural: 'text-sector-procedural',
emotional: 'text-sector-emotional',
reflective: 'text-sector-reflective',
};
const SECTOR_ICONS: Record<string, string> = {
episodic: '📅',
semantic: '📚',
procedural: '⚙️',
emotional: '❤️',
reflective: '🪞',
};
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Format a timestamp as relative time
*/
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${String(days)}d ago`;
if (hours > 0) return `${String(hours)}h ago`;
if (minutes > 0) return `${String(minutes)}m ago`;
return 'Just now';
}
/**
* Truncate text to a maximum length
*/
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
// ============================================================================
// Sub-Components
// ============================================================================
interface TabButtonProps {
id: TabId;
label: string;
count: number;
isActive: boolean;
onClick: () => void;
}
/**
* Tab button for switching between sections
*/
function TabButton({ id, label, count, isActive, onClick }: TabButtonProps): React.ReactElement {
return (
<button
onClick={onClick}
aria-selected={isActive}
role="tab"
id={`tab-${id}`}
aria-controls={`panel-${id}`}
className={`
flex-1 px-3 py-2
text-xs font-medium
rounded-t-lg
transition-colors duration-200
${
isActive
? 'bg-ui-surface text-ui-accent-primary border-b-2 border-ui-accent-primary'
: 'text-ui-text-secondary hover:text-ui-text-primary hover:bg-ui-border/30'
}
`}
>
{label}
{count > 0 && (
<span
className={`
ml-1 px-1.5 py-0.5
text-[10px] rounded-full
${isActive ? 'bg-ui-accent-primary/20' : 'bg-ui-border'}
`}
>
{count}
</span>
)}
</button>
);
}
interface MemoryItemProps {
memory: RecentMemory;
onClick: () => void;
}
/**
* Recent memory list item
*/
function MemoryItem({ memory, onClick }: MemoryItemProps): React.ReactElement {
const sectorColor = SECTOR_COLORS[memory.primarySector] ?? 'text-ui-text-secondary';
const sectorIcon = SECTOR_ICONS[memory.primarySector] ?? '📝';
return (
<button
onClick={onClick}
className="
w-full p-2
text-left
rounded-lg
bg-ui-background/30
hover:bg-ui-border/50
transition-colors duration-200
group
"
aria-label={`Navigate to memory: ${memory.contentPreview}`}
>
<div className="flex items-start gap-2">
<span className={`text-sm ${sectorColor}`} aria-hidden="true">
{sectorIcon}
</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-ui-text-primary truncate group-hover:text-ui-accent-primary">
{truncateText(memory.contentPreview, 50)}
</p>
<p className="text-[10px] text-ui-text-muted mt-0.5">
{formatRelativeTime(memory.lastAccessed)}
</p>
</div>
</div>
</button>
);
}
interface BookmarkItemProps {
bookmark: Bookmark;
onClick: () => void;
}
/**
* Bookmark list item
*/
function BookmarkItem({ bookmark, onClick }: BookmarkItemProps): React.ReactElement {
return (
<button
onClick={onClick}
className="
w-full p-2
text-left
rounded-lg
bg-ui-background/30
hover:bg-ui-border/50
transition-colors duration-200
group
"
aria-label={`Navigate to bookmark: ${bookmark.label}`}
>
<div className="flex items-start gap-2">
<span className="text-sm text-ui-accent-secondary" aria-hidden="true">
⭐
</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-ui-text-primary truncate group-hover:text-ui-accent-primary">
{truncateText(bookmark.label, 50)}
</p>
{bookmark.notes !== undefined && bookmark.notes.length > 0 && (
<p className="text-[10px] text-ui-text-muted mt-0.5 truncate">{bookmark.notes}</p>
)}
</div>
</div>
</button>
);
}
interface PathItemProps {
path: SavedPath;
onClick: () => void;
}
/**
* Saved path list item
*/
function PathItem({ path, onClick }: PathItemProps): React.ReactElement {
return (
<button
onClick={onClick}
className="
w-full p-2
text-left
rounded-lg
bg-ui-background/30
hover:bg-ui-border/50
transition-colors duration-200
group
"
aria-label={`Load path: ${path.name}`}
>
<div className="flex items-start gap-2">
<span className="text-sm text-ui-accent-primary" aria-hidden="true">
🛤️
</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-ui-text-primary truncate group-hover:text-ui-accent-primary">
{path.name}
</p>
<p className="text-[10px] text-ui-text-muted mt-0.5">
{path.nodeIds.length} nodes • {formatRelativeTime(path.createdAt)}
</p>
</div>
</div>
</button>
);
}
interface EmptyStateProps {
message: string;
}
/**
* Empty state display
*/
function EmptyState({ message }: EmptyStateProps): React.ReactElement {
return (
<div className="py-4 text-center">
<p className="text-xs text-ui-text-muted">{message}</p>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
/**
* QuickAccessPanel - Recent memories and saved sessions
*
* Features:
* - Recent memories list (Requirement 23.3)
* - Saved sessions/paths list (Requirement 23.3)
* - Bookmarks list
* - Tabbed interface for organization
* - Glassmorphism styling
* - Collapsible panel
*
* Requirements: 23.3
*/
export function QuickAccessPanel({
recentMemories,
bookmarks,
savedPaths,
onMemoryClick,
onPathClick,
onBookmarkClick,
isExpanded = true,
onToggleExpand,
maxItems = DEFAULT_MAX_ITEMS,
className = '',
}: QuickAccessPanelProps): React.ReactElement {
const [activeTab, setActiveTab] = useState<TabId>('recent');
// Limit items to maxItems
const limitedMemories = useMemo(
() => recentMemories.slice(0, maxItems),
[recentMemories, maxItems]
);
const limitedBookmarks = useMemo(() => bookmarks.slice(0, maxItems), [bookmarks, maxItems]);
const limitedPaths = useMemo(() => savedPaths.slice(0, maxItems), [savedPaths, maxItems]);
// Handle item clicks
const handleMemoryClick = useCallback(
(memoryId: string) => {
onMemoryClick?.(memoryId);
},
[onMemoryClick]
);
const handleBookmarkClick = useCallback(
(nodeId: string) => {
onBookmarkClick?.(nodeId);
},
[onBookmarkClick]
);
const handlePathClick = useCallback(
(pathId: string) => {
onPathClick?.(pathId);
},
[onPathClick]
);
// Render tab content
const renderTabContent = (): React.ReactElement => {
switch (activeTab) {
case 'recent':
return limitedMemories.length > 0 ? (
<div className="space-y-1">
{limitedMemories.map((memory) => (
<MemoryItem
key={memory.id}
memory={memory}
onClick={(): void => {
handleMemoryClick(memory.id);
}}
/>
))}
</div>
) : (
<EmptyState message="No recent memories" />
);
case 'bookmarks':
return limitedBookmarks.length > 0 ? (
<div className="space-y-1">
{limitedBookmarks.map((bookmark) => (
<BookmarkItem
key={bookmark.nodeId}
bookmark={bookmark}
onClick={(): void => {
handleBookmarkClick(bookmark.nodeId);
}}
/>
))}
</div>
) : (
<EmptyState message="No bookmarks yet" />
);
case 'paths':
return limitedPaths.length > 0 ? (
<div className="space-y-1">
{limitedPaths.map((path) => (
<PathItem
key={path.id}
path={path}
onClick={(): void => {
handlePathClick(path.id);
}}
/>
))}
</div>
) : (
<EmptyState message="No saved paths" />
);
}
};
return (
<div
className={`
bg-ui-surface/80
backdrop-blur-glass
border border-ui-border
rounded-lg
overflow-hidden
${className}
`}
style={{
boxShadow: '0 0 15px rgba(0, 255, 255, 0.1)',
zIndex: 25, // Z_INDEX.QUICK_ACCESS_PANEL - ensures panel is above 3D canvas and clickable (Requirement 48.1, 48.5)
position: 'relative',
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-ui-border">
<h3 className="text-sm font-medium text-ui-accent-primary">Quick Access</h3>
{onToggleExpand && (
<button
onClick={onToggleExpand}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
className="text-ui-text-muted hover:text-ui-text-primary transition-colors"
>
<span className="text-xs">{isExpanded ? '▼' : '▶'}</span>
</button>
)}
</div>
{isExpanded && (
<>
{/* Tabs */}
<div
className="flex border-b border-ui-border"
role="tablist"
aria-label="Quick access sections"
>
<TabButton
id="recent"
label="Recent"
count={recentMemories.length}
isActive={activeTab === 'recent'}
onClick={(): void => {
setActiveTab('recent');
}}
/>
<TabButton
id="bookmarks"
label="Bookmarks"
count={bookmarks.length}
isActive={activeTab === 'bookmarks'}
onClick={(): void => {
setActiveTab('bookmarks');
}}
/>
<TabButton
id="paths"
label="Paths"
count={savedPaths.length}
isActive={activeTab === 'paths'}
onClick={(): void => {
setActiveTab('paths');
}}
/>
</div>
{/* Tab Content */}
<div
role="tabpanel"
id={`panel-${activeTab}`}
aria-labelledby={`tab-${activeTab}`}
className="p-2 max-h-64 overflow-y-auto"
>
{renderTabContent()}
</div>
</>
)}
</div>
);
}
export default QuickAccessPanel;