feat(frontend): sprint 4 — dark mode + matrix overhaul + tactic selection + done read-only + UI polish
US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment US-18: done status fully read-only + Reopen button (done → review_required) for all roles US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=) US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence 92/92 tests passing, typecheck and lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,8 @@ export function useUpdateSimulation(id: number, engagementId: number) {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
||||
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
||||
qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
|
||||
qc.invalidateQueries({ queryKey: ['engagements'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -71,6 +73,8 @@ export function useTransitionSimulation(id: number, engagementId: number) {
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: simulationKey(id) });
|
||||
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
|
||||
qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
|
||||
qc.invalidateQueries({ queryKey: ['engagements'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
59
frontend/src/hooks/useTheme.ts
Normal file
59
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
const STORAGE_KEY = 'mimic-theme';
|
||||
|
||||
function resolveTheme(theme: Theme): 'light' | 'dark' {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const resolved = resolveTheme(theme);
|
||||
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
||||
}
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(readStoredTheme);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
// Track system preference changes when theme === 'system'
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => applyTheme('system');
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setThemeState(next);
|
||||
}, []);
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
setTheme(theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light');
|
||||
}, [theme, setTheme]);
|
||||
|
||||
return { theme, setTheme, cycleTheme };
|
||||
}
|
||||
Reference in New Issue
Block a user