From 5a0dff1da91495c3802d70638fea283a0bb68b53 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 28 Jan 2026 21:19:57 -0500 Subject: [PATCH] Add dark mode, export preview, and keyboard navigation - Add theme store with light/dark/system modes and ThemeToggle component - Prevent flash of wrong theme on initial load via inline script - Add ExportPreviewModal for previewing session exports before download - Add copy-to-clipboard functionality to session export - Implement keyboard shortcuts for tree navigation (1-9 options, Esc back, Enter continue) - Display keyboard hints in tree navigation UI - Fix findNode to safely handle undefined structure parameter - Update page title to "Apoklisis" Co-Authored-By: Claude Opus 4.5 --- frontend/index.html | 14 ++- frontend/src/App.tsx | 15 +++ .../src/components/common/ThemeToggle.tsx | 36 ++++++ frontend/src/components/layout/AppLayout.tsx | 2 + .../components/session/ExportPreviewModal.tsx | 103 ++++++++++++++++++ frontend/src/hooks/useKeyboardShortcuts.ts | 90 +++++++++++++++ frontend/src/pages/SessionDetailPage.tsx | 102 +++++++++++++---- frontend/src/pages/TreeNavigationPage.tsx | 80 +++++++++++--- frontend/src/store/themeStore.ts | 53 +++++++++ 9 files changed, 455 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/common/ThemeToggle.tsx create mode 100644 frontend/src/components/session/ExportPreviewModal.tsx create mode 100644 frontend/src/hooks/useKeyboardShortcuts.ts create mode 100644 frontend/src/store/themeStore.ts diff --git a/frontend/index.html b/frontend/index.html index 072a57e8..134bd76d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,19 @@ - frontend + Apoklisis +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3a27f1c..8e776237 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,9 +2,11 @@ import { useEffect } from 'react' import { RouterProvider } from 'react-router-dom' import { router } from '@/router' import { useAuthStore } from '@/store/authStore' +import { useThemeStore } from '@/store/themeStore' function App() { const { isAuthenticated, fetchUser, setLoading } = useAuthStore() + const { theme, setTheme } = useThemeStore() useEffect(() => { // On app load, check if we have a token and fetch user data @@ -18,6 +20,19 @@ function App() { } }, []) + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = () => { + if (theme === 'system') { + setTheme('system') // Re-apply to update resolvedTheme + } + } + + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, [theme, setTheme]) + return } diff --git a/frontend/src/components/common/ThemeToggle.tsx b/frontend/src/components/common/ThemeToggle.tsx new file mode 100644 index 00000000..4ecd2f50 --- /dev/null +++ b/frontend/src/components/common/ThemeToggle.tsx @@ -0,0 +1,36 @@ +import { Sun, Moon, Monitor } from 'lucide-react' +import { useThemeStore } from '@/store/themeStore' +import { cn } from '@/lib/utils' + +export function ThemeToggle() { + const { theme, setTheme } = useThemeStore() + + const options = [ + { value: 'light' as const, icon: Sun, label: 'Light' }, + { value: 'dark' as const, icon: Moon, label: 'Dark' }, + { value: 'system' as const, icon: Monitor, label: 'System' }, + ] + + return ( +
+ {options.map(({ value, icon: Icon, label }) => ( + + ))} +
+ ) +} + +export default ThemeToggle diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 99a72b9b..4b26e09d 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,5 +1,6 @@ import { Link, useLocation, useNavigate, Outlet } from 'react-router-dom' import { useAuthStore } from '@/store/authStore' +import { ThemeToggle } from '@/components/common/ThemeToggle' import { cn } from '@/lib/utils' export function AppLayout() { @@ -48,6 +49,7 @@ export function AppLayout() { {user?.name || user?.email} + + + + + ) +} + +export default ExportPreviewModal diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..0590998a --- /dev/null +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,90 @@ +import { useEffect, useCallback } from 'react' + +interface ShortcutConfig { + key: string + ctrl?: boolean + shift?: boolean + alt?: boolean + handler: () => void + enabled?: boolean +} + +export function useKeyboardShortcuts(shortcuts: ShortcutConfig[]) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Don't trigger shortcuts when typing in inputs + const target = e.target as HTMLElement + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ) { + return + } + + for (const shortcut of shortcuts) { + if (shortcut.enabled === false) continue + + const keyMatch = e.key === shortcut.key || e.key === shortcut.key.toLowerCase() + const ctrlMatch = !!shortcut.ctrl === (e.ctrlKey || e.metaKey) + const shiftMatch = !!shortcut.shift === e.shiftKey + const altMatch = !!shortcut.alt === e.altKey + + if (keyMatch && ctrlMatch && shiftMatch && altMatch) { + e.preventDefault() + shortcut.handler() + return + } + } + }, + [shortcuts] + ) + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) +} + +// Convenience hook for tree navigation specifically +export interface TreeNavigationShortcutsConfig { + onSelectOption: (index: number) => void + onGoBack: () => void + onContinue: () => void + optionCount: number + canGoBack: boolean + canContinue: boolean +} + +export function useTreeNavigationShortcuts({ + onSelectOption, + onGoBack, + onContinue, + optionCount, + canGoBack, + canContinue, +}: TreeNavigationShortcutsConfig) { + const shortcuts: ShortcutConfig[] = [ + // Number keys 1-9 for options + ...Array.from({ length: Math.min(optionCount, 9) }, (_, i) => ({ + key: String(i + 1), + handler: () => onSelectOption(i), + })), + // Escape to go back + { + key: 'Escape', + handler: onGoBack, + enabled: canGoBack, + }, + // Enter to continue (for action nodes) + { + key: 'Enter', + handler: onContinue, + enabled: canContinue, + }, + ] + + useKeyboardShortcuts(shortcuts) +} + +export default useKeyboardShortcuts diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx index ece83077..97014d99 100644 --- a/frontend/src/pages/SessionDetailPage.tsx +++ b/frontend/src/pages/SessionDetailPage.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' +import { Copy, Check, Eye } from 'lucide-react' import { sessionsApi } from '@/api' +import { ExportPreviewModal } from '@/components/session/ExportPreviewModal' import type { Session, SessionExport } from '@/types' import { cn } from '@/lib/utils' @@ -12,6 +14,9 @@ export function SessionDetailPage() { const [error, setError] = useState(null) const [isExporting, setIsExporting] = useState(false) const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html'>('markdown') + const [exportContent, setExportContent] = useState(null) + const [showPreview, setShowPreview] = useState(false) + const [copied, setCopied] = useState(false) useEffect(() => { if (id) { @@ -33,27 +38,30 @@ export function SessionDetailPage() { } } - const handleExport = async () => { - if (!session) return + const getFilename = () => { + if (!session) return 'export.txt' + const ext = exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt' + return `session-${session.ticket_number || session.id}.${ext}` + } + + const fetchExportContent = async () => { + if (!session) return null + const options: SessionExport = { + format: exportFormat, + include_timestamps: true, + include_tree_info: true, + } + return await sessionsApi.export(session.id, options) + } + + const handlePreview = async () => { setIsExporting(true) try { - const options: SessionExport = { - format: exportFormat, - include_timestamps: true, - include_tree_info: true, + const content = await fetchExportContent() + if (content) { + setExportContent(content) + setShowPreview(true) } - const content = await sessionsApi.export(session.id, options) - - // Create download - const blob = new Blob([content], { type: 'text/plain' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `session-${session.ticket_number || session.id}.${exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt'}` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) } catch (err) { console.error('Export failed:', err) } finally { @@ -61,6 +69,35 @@ export function SessionDetailPage() { } } + const handleCopy = async () => { + setIsExporting(true) + try { + const content = await fetchExportContent() + if (content) { + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } catch (err) { + console.error('Copy failed:', err) + } finally { + setIsExporting(false) + } + } + + const handleDownload = () => { + if (!exportContent || !session) return + const blob = new Blob([exportContent], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = getFilename() + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString() } @@ -127,6 +164,7 @@ export function SessionDetailPage() { + @@ -199,6 +249,16 @@ export function SessionDetailPage() { )} + + {/* Export Preview Modal */} + setShowPreview(false)} + content={exportContent || ''} + filename={getFilename()} + format={exportFormat} + onDownload={handleDownload} + /> ) } diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 0625bb2d..90847ec3 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate, useLocation } from 'react-router-dom' import { treesApi, sessionsApi } from '@/api' +import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts' import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types' import { cn } from '@/lib/utils' @@ -80,7 +81,8 @@ export function TreeNavigationPage() { } } - const findNode = (nodeId: string, structure: TreeStructure = tree?.tree_structure!): TreeStructure | null => { + const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => { + if (!structure) return null if (structure.id === nodeId) return structure if (structure.children) { for (const child of structure.children) { @@ -91,15 +93,16 @@ export function TreeNavigationPage() { return null } + // Handler functions - defined before hook call to avoid temporal dead zone const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => { if (!session || !tree) return - const currentNode = findNode(currentNodeId) - if (!currentNode) return + const node = findNode(currentNodeId, tree.tree_structure) + if (!node) return const newDecision: DecisionRecord = { node_id: currentNodeId, - question: currentNode.question || null, + question: node.question || null, answer: optionLabel, action_performed: null, notes: notes || null, @@ -130,26 +133,26 @@ export function TreeNavigationPage() { const handleContinue = async (actionPerformed?: string) => { if (!session || !tree) return - const currentNode = findNode(currentNodeId) - if (!currentNode || !currentNode.next_node_id) return + const node = findNode(currentNodeId, tree.tree_structure) + if (!node || !node.next_node_id) return const newDecision: DecisionRecord = { node_id: currentNodeId, question: null, answer: null, - action_performed: actionPerformed || currentNode.title || 'Action completed', + action_performed: actionPerformed || node.title || 'Action completed', notes: notes || null, automation_used: false, timestamp: new Date().toISOString(), attachments: [], } - const newPath = [...pathTaken, currentNode.next_node_id] + const newPath = [...pathTaken, node.next_node_id] const newDecisions = [...decisions, newDecision] setPathTaken(newPath) setDecisions(newDecisions) - setCurrentNodeId(currentNode.next_node_id) + setCurrentNodeId(node.next_node_id) setNotes('') try { @@ -163,18 +166,18 @@ export function TreeNavigationPage() { } const handleComplete = async () => { - if (!session) return + if (!session || !tree) return setIsCompleting(true) setError(null) try { // Add final decision - const currentNode = findNode(currentNodeId) - if (currentNode) { + const node = findNode(currentNodeId, tree.tree_structure) + if (node) { const finalDecision: DecisionRecord = { node_id: currentNodeId, question: null, answer: null, - action_performed: currentNode.title || 'Session completed', + action_performed: node.title || 'Session completed', notes: notes || null, automation_used: false, timestamp: new Date().toISOString(), @@ -204,6 +207,31 @@ export function TreeNavigationPage() { setCurrentNodeId(newPath[newPath.length - 1]) } + // Compute current node for keyboard shortcuts (must be before any returns for hooks rules) + const currentNode = tree ? findNode(currentNodeId, tree.tree_structure) : null + const currentOptions = currentNode?.options || [] + + // Keyboard shortcuts - must be called unconditionally (React hooks rules) + useTreeNavigationShortcuts({ + onSelectOption: (index) => { + const option = currentOptions[index] + if (option && session && tree) { + handleSelectOption(option.id, option.label, option.next_node_id) + } + }, + onGoBack: handleGoBack, + onContinue: () => { + if (currentNode?.type === 'action' && currentNode.next_node_id) { + handleContinue() + } else if (currentNode?.type === 'solution') { + handleComplete() + } + }, + optionCount: currentOptions.length, + canGoBack: pathTaken.length > 1 && !showMetadataForm && !isLoading, + canContinue: !showMetadataForm && !isLoading && (currentNode?.type === 'action' || currentNode?.type === 'solution'), + }) + if (isLoading) { return (
@@ -289,8 +317,6 @@ export function TreeNavigationPage() { ) } - const currentNode = findNode(currentNodeId) - if (!currentNode) { return (
@@ -357,16 +383,22 @@ export function TreeNavigationPage() {

{currentNode.help_text}

)}
- {currentNode.options?.map((option) => ( + {currentNode.options?.map((option, index) => ( ))}
@@ -476,6 +508,18 @@ export function TreeNavigationPage() { ← Go back )} + + {/* Keyboard Shortcuts Hint */} +
+ Keyboard:{' '} + {currentNode.type === 'decision' && currentOptions.length > 0 && ( + 1-{Math.min(currentOptions.length, 9)} select option + )} + {pathTaken.length > 1 && , Esc go back} + {(currentNode.type === 'action' || currentNode.type === 'solution') && ( + , Enter {currentNode.type === 'solution' ? 'complete' : 'continue'} + )} +
) diff --git a/frontend/src/store/themeStore.ts b/frontend/src/store/themeStore.ts new file mode 100644 index 00000000..383314b0 --- /dev/null +++ b/frontend/src/store/themeStore.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +type Theme = 'light' | 'dark' | 'system' + +interface ThemeState { + theme: Theme + resolvedTheme: 'light' | 'dark' + setTheme: (theme: Theme) => void +} + +const getSystemTheme = (): 'light' | 'dark' => { + if (typeof window === 'undefined') return 'light' + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +const applyTheme = (theme: Theme): 'light' | 'dark' => { + const resolved = theme === 'system' ? getSystemTheme() : theme + const root = document.documentElement + + if (resolved === 'dark') { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } + + return resolved +} + +export const useThemeStore = create()( + persist( + (set) => ({ + theme: 'system', + resolvedTheme: getSystemTheme(), + + setTheme: (theme: Theme) => { + const resolvedTheme = applyTheme(theme) + set({ theme, resolvedTheme }) + }, + }), + { + name: 'theme-storage', + onRehydrateStorage: () => (state) => { + // Apply theme on initial load after rehydration + if (state) { + applyTheme(state.theme) + } + }, + } + ) +) + +export default useThemeStore