refactor(frontend): improve type safety and migrate to ESLint 9

Migrate the frontend to ESLint 9, add `eslint.config.mjs`, and replace various `any` types with more specific or safer alternatives across the codebase.

- Update `package.json` with new ESLint and TypeScript-ESLint dependencies
- Replace `any` with `Record<string, unknown>` or specific types in `TemplatePicker`, `Editor`, and `FileBrowser`
- Improve error handling in `TeamSettings` and `Templates` using type guards
- Add explicit type imports for Excalidraw elements in `Editor.tsx`
- Refactor `Modal.tsx` for cleaner conditional logic
- Add `check` script to `package.json` for type checking
This commit is contained in:
Tomas Dvorak
2026-04-29 13:30:24 +02:00
parent ef0b519058
commit f3f9e99a97
11 changed files with 3189 additions and 59 deletions
+19
View File
@@ -0,0 +1,19 @@
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
export default tseslint.config(
{ ignores: ['dist/', 'node_modules/', 'dist-test/', '*.config.*', 'eslint.config.mjs', 'e2e/', 'playwright/'] },
tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
plugins: { react: pluginReact },
languageOptions: {
parserOptions: { project: './tsconfig.json' },
},
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
'react/react-in-jsx-scope': 'off',
},
}
);
+3096 -2
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -6,8 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"check": "tsc --noEmit",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
@@ -37,9 +38,12 @@
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.0.0",
"eslint-plugin-react": "^7.37.0",
"jsdom": "^25.0.0",
"sass": "^1.81.0",
"typescript": "^5.6.0",
"typescript-eslint": "^8.0.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
@@ -5,7 +5,6 @@ import { Search, Bell, Plus, FileText, Loader2, Sun, Moon } from 'lucide-react';
import { Button } from '@/components';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Drawing } from '@/types';
import styles from './Layout.module.scss';
+5 -1
View File
@@ -32,7 +32,11 @@ export const Modal: React.FC<ModalProps> = ({
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel?.() ?? onClose?.();
if (onCancel) {
onCancel();
} else {
onClose?.();
}
}
};
if (isOpen) {
@@ -11,12 +11,14 @@ interface TemplatePickerProps {
onSelect: (template: PickedTemplate) => void;
}
type RawElement = Record<string, unknown>;
interface TemplateOption {
id: PickedTemplate;
label: string;
description: string;
icon: React.ElementType;
elements: any[];
elements: RawElement[];
}
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) {
@@ -84,15 +86,16 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
function makeCheckbox(x: number, y: number, checked = false) {
const box = makeHandDrawnRect(x, y, 20, 20);
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
(box as any).customData = {
templateRole: 'checkbox',
checked,
};
return box;
return Object.assign(box, {
backgroundColor: checked ? '#a5eba8' : 'transparent',
customData: {
templateRole: 'checkbox',
checked,
},
});
}
export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = {
export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
blank: [],
todo: [
makeHandDrawnRect(50, 50, 500, 50),
+3 -1
View File
@@ -5,7 +5,9 @@ import './i18n';
import { App } from './App';
import './styles/global.scss';
ReactDOM.createRoot(document.getElementById('root')!).render(
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Root element not found');
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
+34 -39
View File
@@ -12,45 +12,40 @@ import styles from './Editor.module.scss';
// Dynamic import for Excalidraw to avoid SSR issues
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
interface ExcalidrawElement {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
[key: string]: unknown;
}
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
interface ExcalidrawState {
type LooseElement = Record<string, unknown>;
interface EditorState {
elements: ExcalidrawElement[];
appState: Record<string, unknown>;
files: Record<string, { dataURL: string; mimeType: string }>;
}
function prepareElementsForImport(sourceElements: any[], offsetX: number, offsetY: number): any[] {
function prepareElementsForImport(sourceElements: LooseElement[], offsetX: number, offsetY: number): LooseElement[] {
if (!sourceElements || !sourceElements.length) return [];
const idMap = new Map<string, string>();
sourceElements.forEach((el: any) => {
idMap.set(el.id, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
sourceElements.forEach((el) => {
idMap.set(el.id as string, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
});
return sourceElements.map((el: any) => {
const newEl = { ...el };
newEl.id = idMap.get(el.id) || el.id;
newEl.x = (el.x || 0) + offsetX;
newEl.y = (el.y || 0) + offsetY;
newEl.version = (el.version || 1) + 1;
return sourceElements.map((el) => {
const newEl: LooseElement = { ...el };
newEl.id = idMap.get(el.id as string) || el.id;
newEl.x = ((el.x as number) || 0) + offsetX;
newEl.y = ((el.y as number) || 0) + offsetY;
newEl.version = ((el.version as number) || 1) + 1;
newEl.versionNonce = Math.floor(Math.random() * 1000000);
newEl.updated = Date.now();
newEl.seed = Math.floor(Math.random() * 100000);
if (newEl.boundElements) {
newEl.boundElements = newEl.boundElements.map((be: any) => ({
newEl.boundElements = (newEl.boundElements as LooseElement[]).map((be) => ({
...be,
id: idMap.get(be.id) || be.id,
id: idMap.get(be.id as string) || be.id,
}));
}
if (newEl.containerId && idMap.has(newEl.containerId)) {
newEl.containerId = idMap.get(newEl.containerId);
if (newEl.containerId && idMap.has(newEl.containerId as string)) {
newEl.containerId = idMap.get(newEl.containerId as string);
}
return newEl;
});
@@ -71,7 +66,7 @@ export const Editor: React.FC = () => {
const navigate = useNavigate();
const [drawing, setDrawing] = useState<Drawing | null>(null);
const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
const [initialData, setInitialData] = useState<any>(null);
const [initialData, setInitialData] = useState<ExcalidrawInitialDataState | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
@@ -81,11 +76,11 @@ export const Editor: React.FC = () => {
const [notes, setNotes] = useState('');
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
const { theme: appTheme } = useThemeStore();
const currentStateRef = useRef<ExcalidrawState | null>(null);
const currentStateRef = useRef<EditorState | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedDataRef = useRef<string>('');
const lastToggledCheckboxRef = useRef<string | null>(null);
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
const [showTemplates, setShowTemplates] = useState(false);
@@ -144,13 +139,13 @@ export const Editor: React.FC = () => {
}, [id]);
// Handle changes from Excalidraw
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
const selectedCheckbox = selectedIds.length === 1
? (elements as any[]).find((el) => (
? elements.find((el) => (
el.id === selectedIds[0] &&
!el.isDeleted &&
el.customData?.templateRole === 'checkbox'
(el.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox'
))
: null;
@@ -158,23 +153,23 @@ export const Editor: React.FC = () => {
lastToggledCheckboxRef.current = null;
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
lastToggledCheckboxRef.current = selectedCheckbox.id;
const nextChecked = !selectedCheckbox.customData?.checked;
const nextElements = (elements as any[]).map((el) => (
const nextChecked = !((selectedCheckbox.customData as Record<string, unknown> | undefined)?.checked as boolean);
const nextElements = elements.map((el) => (
el.id === selectedCheckbox.id
? {
...el,
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
customData: {
...(el.customData || {}),
...((el.customData as Record<string, unknown> | undefined) || {}),
checked: nextChecked,
},
version: (el.version || 1) + 1,
version: el.version + 1,
versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(),
}
: el
));
excalidrawAPI.updateScene({ elements: nextElements });
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
currentStateRef.current = {
elements: nextElements,
appState: appStateWithoutGrid(appState),
@@ -185,7 +180,7 @@ export const Editor: React.FC = () => {
}
currentStateRef.current = {
elements: elements as ExcalidrawElement[],
elements: elements as unknown as ExcalidrawElement[],
appState: appStateWithoutGrid(appState),
files,
};
@@ -286,14 +281,14 @@ export const Editor: React.FC = () => {
if (!templateElements || !excalidrawAPI) return;
const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100;
let offsetY = 100;
const offsetY = 100;
if (currentElements.length > 0) {
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
const maxX = Math.max(...currentElements.map((el) => (el.x + el.width)));
offsetX = maxX + 100;
}
const newElements = prepareElementsForImport(templateElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
excalidrawAPI.updateScene({ elements: mergedElements as ExcalidrawElement[] });
setShowTemplates(false);
setSaveStatus('unsaved');
};
@@ -407,7 +402,7 @@ export const Editor: React.FC = () => {
{initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw
excalidrawAPI={(api: any) => setExcalidrawAPI(api)}
excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
initialData={initialData}
onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'}
+12 -3
View File
@@ -273,7 +273,12 @@ export const FileBrowser: React.FC = () => {
<select
className={styles.filterSelect}
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value as any)}
onChange={(e) => {
const v = e.target.value;
if (v === 'all' || v === 'private' || v === 'team' || v === 'public-link') {
setVisibilityFilter(v);
}
}}
aria-label="Filter by visibility"
title="Filter by visibility"
>
@@ -287,8 +292,12 @@ export const FileBrowser: React.FC = () => {
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [sb, so] = e.target.value.split('-');
setSortBy(sb as any);
setSortOrder(so as any);
if (sb === 'name' || sb === 'updated' || sb === 'created') {
setSortBy(sb);
}
if (so === 'asc' || so === 'desc') {
setSortOrder(so);
}
}}
aria-label="Sort drawings"
title="Sort drawings"
+3 -2
View File
@@ -70,8 +70,9 @@ export const TeamSettings: React.FC = () => {
setNewEmail('');
setNewPassword('');
setNewRole('editor');
} catch (err: any) {
setError(err?.message || 'Failed to create user');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to create user';
setError(message);
} finally {
setSending(false);
}
+1 -1
View File
@@ -36,7 +36,7 @@ export const Templates: React.FC = () => {
try {
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
setTemplates([t, ...templates]); setShowModal(false); setName('');
} catch (err) { setError('Create failed'); }
} catch { setError('Create failed'); }
finally { setCreating(false); }
};