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": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"check": "tsc --noEmit",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
@@ -37,9 +38,12 @@
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.0.0",
"eslint-plugin-react": "^7.37.0",
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"sass": "^1.81.0", "sass": "^1.81.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"typescript-eslint": "^8.0.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^3.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 { Button } from '@/components';
import { useThemeStore } from '@/stores'; import { useThemeStore } from '@/stores';
import { api } from '@/services'; import { api } from '@/services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Drawing } from '@/types'; import type { Drawing } from '@/types';
import styles from './Layout.module.scss'; import styles from './Layout.module.scss';
+5 -1
View File
@@ -32,7 +32,11 @@ export const Modal: React.FC<ModalProps> = ({
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
onCancel?.() ?? onClose?.(); if (onCancel) {
onCancel();
} else {
onClose?.();
}
} }
}; };
if (isOpen) { if (isOpen) {
@@ -11,12 +11,14 @@ interface TemplatePickerProps {
onSelect: (template: PickedTemplate) => void; onSelect: (template: PickedTemplate) => void;
} }
type RawElement = Record<string, unknown>;
interface TemplateOption { interface TemplateOption {
id: PickedTemplate; id: PickedTemplate;
label: string; label: string;
description: string; description: string;
icon: React.ElementType; icon: React.ElementType;
elements: any[]; elements: RawElement[];
} }
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) { 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) { function makeCheckbox(x: number, y: number, checked = false) {
const box = makeHandDrawnRect(x, y, 20, 20); const box = makeHandDrawnRect(x, y, 20, 20);
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent'; return Object.assign(box, {
(box as any).customData = { backgroundColor: checked ? '#a5eba8' : 'transparent',
customData: {
templateRole: 'checkbox', templateRole: 'checkbox',
checked, checked,
}; },
return box; });
} }
export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = { export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
blank: [], blank: [],
todo: [ todo: [
makeHandDrawnRect(50, 50, 500, 50), makeHandDrawnRect(50, 50, 500, 50),
+3 -1
View File
@@ -5,7 +5,9 @@ import './i18n';
import { App } from './App'; import { App } from './App';
import './styles/global.scss'; 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> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> <App />
+34 -39
View File
@@ -12,45 +12,40 @@ import styles from './Editor.module.scss';
// Dynamic import for Excalidraw to avoid SSR issues // Dynamic import for Excalidraw to avoid SSR issues
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw }))); const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
interface ExcalidrawElement { import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
id: string; import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
type: string;
x: number;
y: number;
width: number;
height: number;
[key: string]: unknown;
}
interface ExcalidrawState { type LooseElement = Record<string, unknown>;
interface EditorState {
elements: ExcalidrawElement[]; elements: ExcalidrawElement[];
appState: Record<string, unknown>; appState: Record<string, unknown>;
files: Record<string, { dataURL: string; mimeType: string }>; 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 []; if (!sourceElements || !sourceElements.length) return [];
const idMap = new Map<string, string>(); const idMap = new Map<string, string>();
sourceElements.forEach((el: any) => { sourceElements.forEach((el) => {
idMap.set(el.id, `${el.type}-${Math.random().toString(36).slice(2, 9)}`); idMap.set(el.id as string, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
}); });
return sourceElements.map((el: any) => { return sourceElements.map((el) => {
const newEl = { ...el }; const newEl: LooseElement = { ...el };
newEl.id = idMap.get(el.id) || el.id; newEl.id = idMap.get(el.id as string) || el.id;
newEl.x = (el.x || 0) + offsetX; newEl.x = ((el.x as number) || 0) + offsetX;
newEl.y = (el.y || 0) + offsetY; newEl.y = ((el.y as number) || 0) + offsetY;
newEl.version = (el.version || 1) + 1; newEl.version = ((el.version as number) || 1) + 1;
newEl.versionNonce = Math.floor(Math.random() * 1000000); newEl.versionNonce = Math.floor(Math.random() * 1000000);
newEl.updated = Date.now(); newEl.updated = Date.now();
newEl.seed = Math.floor(Math.random() * 100000); newEl.seed = Math.floor(Math.random() * 100000);
if (newEl.boundElements) { if (newEl.boundElements) {
newEl.boundElements = newEl.boundElements.map((be: any) => ({ newEl.boundElements = (newEl.boundElements as LooseElement[]).map((be) => ({
...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)) { if (newEl.containerId && idMap.has(newEl.containerId as string)) {
newEl.containerId = idMap.get(newEl.containerId); newEl.containerId = idMap.get(newEl.containerId as string);
} }
return newEl; return newEl;
}); });
@@ -71,7 +66,7 @@ export const Editor: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [drawing, setDrawing] = useState<Drawing | null>(null); const [drawing, setDrawing] = useState<Drawing | null>(null);
const [revisions, setRevisions] = useState<DrawingRevision[]>([]); const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
const [initialData, setInitialData] = useState<any>(null); const [initialData, setInitialData] = useState<ExcalidrawInitialDataState | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved'); const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
@@ -81,11 +76,11 @@ export const Editor: React.FC = () => {
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [selectedRevision, setSelectedRevision] = useState<string | null>(null); const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
const { theme: appTheme } = useThemeStore(); 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 saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedDataRef = useRef<string>(''); const lastSavedDataRef = useRef<string>('');
const lastToggledCheckboxRef = useRef<string | null>(null); const lastToggledCheckboxRef = useRef<string | null>(null);
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null); const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
const [showTemplates, setShowTemplates] = useState(false); const [showTemplates, setShowTemplates] = useState(false);
@@ -144,13 +139,13 @@ export const Editor: React.FC = () => {
}, [id]); }, [id]);
// Handle changes from Excalidraw // 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 selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
const selectedCheckbox = selectedIds.length === 1 const selectedCheckbox = selectedIds.length === 1
? (elements as any[]).find((el) => ( ? elements.find((el) => (
el.id === selectedIds[0] && el.id === selectedIds[0] &&
!el.isDeleted && !el.isDeleted &&
el.customData?.templateRole === 'checkbox' (el.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox'
)) ))
: null; : null;
@@ -158,23 +153,23 @@ export const Editor: React.FC = () => {
lastToggledCheckboxRef.current = null; lastToggledCheckboxRef.current = null;
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) { } else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
lastToggledCheckboxRef.current = selectedCheckbox.id; lastToggledCheckboxRef.current = selectedCheckbox.id;
const nextChecked = !selectedCheckbox.customData?.checked; const nextChecked = !((selectedCheckbox.customData as Record<string, unknown> | undefined)?.checked as boolean);
const nextElements = (elements as any[]).map((el) => ( const nextElements = elements.map((el) => (
el.id === selectedCheckbox.id el.id === selectedCheckbox.id
? { ? {
...el, ...el,
backgroundColor: nextChecked ? '#a5eba8' : 'transparent', backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
customData: { customData: {
...(el.customData || {}), ...((el.customData as Record<string, unknown> | undefined) || {}),
checked: nextChecked, checked: nextChecked,
}, },
version: (el.version || 1) + 1, version: el.version + 1,
versionNonce: Math.floor(Math.random() * 1000000), versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(), updated: Date.now(),
} }
: el : el
)); ));
excalidrawAPI.updateScene({ elements: nextElements }); excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
currentStateRef.current = { currentStateRef.current = {
elements: nextElements, elements: nextElements,
appState: appStateWithoutGrid(appState), appState: appStateWithoutGrid(appState),
@@ -185,7 +180,7 @@ export const Editor: React.FC = () => {
} }
currentStateRef.current = { currentStateRef.current = {
elements: elements as ExcalidrawElement[], elements: elements as unknown as ExcalidrawElement[],
appState: appStateWithoutGrid(appState), appState: appStateWithoutGrid(appState),
files, files,
}; };
@@ -286,14 +281,14 @@ export const Editor: React.FC = () => {
if (!templateElements || !excalidrawAPI) return; if (!templateElements || !excalidrawAPI) return;
const currentElements = excalidrawAPI.getSceneElements?.() || []; const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100; let offsetX = 100;
let offsetY = 100; const offsetY = 100;
if (currentElements.length > 0) { 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; offsetX = maxX + 100;
} }
const newElements = prepareElementsForImport(templateElements, offsetX, offsetY); const newElements = prepareElementsForImport(templateElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements]; const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements }); excalidrawAPI.updateScene({ elements: mergedElements as ExcalidrawElement[] });
setShowTemplates(false); setShowTemplates(false);
setSaveStatus('unsaved'); setSaveStatus('unsaved');
}; };
@@ -407,7 +402,7 @@ export const Editor: React.FC = () => {
{initialData && ( {initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}> <React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw <Excalidraw
excalidrawAPI={(api: any) => setExcalidrawAPI(api)} excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
initialData={initialData} initialData={initialData}
onChange={handleExcalidrawChange} onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'} theme={appTheme === 'dark' ? 'dark' : 'light'}
+12 -3
View File
@@ -273,7 +273,12 @@ export const FileBrowser: React.FC = () => {
<select <select
className={styles.filterSelect} className={styles.filterSelect}
value={visibilityFilter} 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" aria-label="Filter by visibility"
title="Filter by visibility" title="Filter by visibility"
> >
@@ -287,8 +292,12 @@ export const FileBrowser: React.FC = () => {
value={`${sortBy}-${sortOrder}`} value={`${sortBy}-${sortOrder}`}
onChange={(e) => { onChange={(e) => {
const [sb, so] = e.target.value.split('-'); const [sb, so] = e.target.value.split('-');
setSortBy(sb as any); if (sb === 'name' || sb === 'updated' || sb === 'created') {
setSortOrder(so as any); setSortBy(sb);
}
if (so === 'asc' || so === 'desc') {
setSortOrder(so);
}
}} }}
aria-label="Sort drawings" aria-label="Sort drawings"
title="Sort drawings" title="Sort drawings"
+3 -2
View File
@@ -70,8 +70,9 @@ export const TeamSettings: React.FC = () => {
setNewEmail(''); setNewEmail('');
setNewPassword(''); setNewPassword('');
setNewRole('editor'); setNewRole('editor');
} catch (err: any) { } catch (err: unknown) {
setError(err?.message || 'Failed to create user'); const message = err instanceof Error ? err.message : 'Failed to create user';
setError(message);
} finally { } finally {
setSending(false); setSending(false);
} }
+1 -1
View File
@@ -36,7 +36,7 @@ export const Templates: React.FC = () => {
try { try {
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' }); const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
setTemplates([t, ...templates]); setShowModal(false); setName(''); setTemplates([t, ...templates]); setShowModal(false); setName('');
} catch (err) { setError('Create failed'); } } catch { setError('Create failed'); }
finally { setCreating(false); } finally { setCreating(false); }
}; };