diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6309eac --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "printWidth": 100, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf" +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index d94e7de..84f9297 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,12 +12,92 @@ export default tseslint.config([ extends: [ js.configs.recommended, tseslint.configs.recommended, + tseslint.configs.strict, + tseslint.configs.stylistic, reactHooks.configs['recommended-latest'], reactRefresh.configs.vite, ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-function-return-type': ['warn', { + allowExpressions: true, + allowTypedFunctionExpressions: true, + allowHigherOrderFunctions: true, + allowDirectConstAssertionInArrowFunctions: true, + allowConciseArrowFunctionExpressionsStartingWithVoid: true, + }], + '@typescript-eslint/no-non-null-assertion': 'warn', + '@typescript-eslint/strict-boolean-expressions': ['warn', { + allowNullableObject: true, + allowNullableBoolean: true, + allowNullableString: true, + allowNullableNumber: false, + allowAny: false, + }], + '@typescript-eslint/no-unnecessary-condition': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/require-await': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/prefer-readonly': 'error', + '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], + '@typescript-eslint/consistent-type-imports': ['error', { + prefer: 'type-imports', + disallowTypeAnnotations: true, + fixStyle: 'separate-type-imports', + }], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'variable', + format: ['camelCase', 'PascalCase', 'UPPER_CASE'], + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + { + selector: 'typeLike', + format: ['PascalCase'], + }, + { + selector: 'enum', + format: ['PascalCase'], + }, + { + selector: 'enumMember', + format: ['UPPER_CASE'], + }, + ], + 'eqeqeq': ['error', 'always'], + 'no-console': 'warn', + 'no-debugger': 'error', + 'no-alert': 'error', + 'prefer-const': 'error', + 'no-var': 'error', + 'object-shorthand': 'error', + 'prefer-template': 'error', + 'prefer-destructuring': ['error', { + array: true, + object: true, + }], + 'no-nested-ternary': 'error', + 'no-unneeded-ternary': 'error', + 'curly': ['error', 'all'], + 'brace-style': ['error', '1tbs'], }, }, ]) diff --git a/package.json b/package.json index ffc8a72..8028a98 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "lint:fix": "eslint . --fix", + "typecheck": "tsc -b --noEmit", "preview": "vite preview" }, "dependencies": { diff --git a/src/components/NavigationConsole.tsx b/src/components/NavigationConsole.tsx index 48ebc7a..d3f09b7 100644 --- a/src/components/NavigationConsole.tsx +++ b/src/components/NavigationConsole.tsx @@ -52,7 +52,9 @@ export function NavigationConsole({ isOpen, onClose }: NavigationConsoleProps) { }, [input, existingSpaces]) const handleShare = async () => { - if (!space) return + if (!space) { +return +} const shareLink = generateShareLink(space) const success = await copyToClipboard(shareLink) @@ -109,7 +111,9 @@ export function NavigationConsole({ isOpen, onClose }: NavigationConsoleProps) { onClose() } - if (!isOpen) return null + if (!isOpen) { +return null +} return ( <> diff --git a/src/components/Note.tsx b/src/components/Note.tsx index 27dc9d5..449cd9e 100644 --- a/src/components/Note.tsx +++ b/src/components/Note.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react' import { updateNote, deleteNote, editingNoteId, navigateToSpace, selectedNoteIds, selectNote, moveSelectedNotesLocal, saveSelectedNotesToDB, existingSpaceIds } from '../store' import { useStore } from '@nanostores/react' -type NoteType = { +interface NoteType { id: string x: number y: number @@ -29,10 +29,18 @@ export function Note({ note }: NoteProps) { // Check if note content is a single word that matches an existing space const isSpaceLink = () => { - if (!note.content) return false + if (!note.content) { +return false +} const words = note.content.trim().split(/\s+/) - if (words.length !== 1) return false - const spaceId = words[0].toLowerCase().replace(/[^a-z0-9]/g, '-') + if (words.length !== 1) { +return false +} + const firstWord = words[0] + if (!firstWord) { +return false +} + const spaceId = firstWord.toLowerCase().replace(/[^a-z0-9]/g, '-') return $existingSpaceIds.includes(spaceId) } @@ -72,7 +80,9 @@ export function Note({ note }: NoteProps) { // Selection const handleClick = (e: React.MouseEvent) => { - if (isEditing) return + if (isEditing) { +return +} e.preventDefault() e.stopPropagation() @@ -93,13 +103,19 @@ export function Note({ note }: NoteProps) { // Navigation const handleDoubleClick = (e: React.MouseEvent) => { - if (isEditing) return + if (isEditing) { +return +} e.stopPropagation() if (note.content.trim()) { const words = note.content.trim().split(/\s+/) if (words.length > 0) { - const spaceId = words[0].toLowerCase().replace(/[^a-z0-9]/g, '-') + const firstWord = words[0] + if (!firstWord) { +return +} + const spaceId = firstWord.toLowerCase().replace(/[^a-z0-9]/g, '-') navigateToSpace(spaceId) } } @@ -107,7 +123,9 @@ export function Note({ note }: NoteProps) { // Drag and drop const handleMouseDown = (e: React.MouseEvent) => { - if (isEditing) return + if (isEditing) { +return +} e.preventDefault() e.stopPropagation() @@ -122,7 +140,9 @@ export function Note({ note }: NoteProps) { } const handleMouseMove = (e: MouseEvent) => { - if (!isDragging) return + if (!isDragging) { +return +} const deltaX = e.clientX - dragStart.x const deltaY = e.clientY - dragStart.y @@ -162,6 +182,7 @@ export function Note({ note }: NoteProps) { document.removeEventListener('mouseup', handleMouseUp) } } + return undefined }, [isDragging, dragStart]) if (isEditing) { diff --git a/src/components/Space.tsx b/src/components/Space.tsx index e0a056f..c20b962 100644 --- a/src/components/Space.tsx +++ b/src/components/Space.tsx @@ -49,7 +49,9 @@ export function Space() { // Drag in empty space - rectangle selection const handleMouseDown = (e: React.MouseEvent) => { - if (e.detail === 2) return // Ignore double-click + if (e.detail === 2) { +return +} // Ignore double-click const rect = e.currentTarget.getBoundingClientRect() const x = e.clientX - rect.left @@ -61,10 +63,14 @@ export function Space() { } const handleMouseMove = (e: MouseEvent) => { - if (!isSelecting) return + if (!isSelecting) { +return +} const spaceElement = document.querySelector('[data-space-canvas]') as HTMLElement - if (!spaceElement) return + if (!spaceElement) { +return +} const rect = spaceElement.getBoundingClientRect() const x = e.clientX - rect.left @@ -74,13 +80,17 @@ export function Space() { } const handleMouseUp = (e: MouseEvent) => { - if (!isSelecting) return + if (!isSelecting) { +return +} setIsSelecting(false) // Get final mouse position directly from event const spaceElement = document.querySelector('[data-space-canvas]') as HTMLElement - if (!spaceElement) return + if (!spaceElement) { +return +} const rect = spaceElement.getBoundingClientRect() const finalX = e.clientX - rect.left @@ -114,13 +124,16 @@ export function Space() { document.removeEventListener('mouseup', handleMouseUp) } } + return undefined }, [isSelecting, selectionStart]) // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't handle shortcuts if editing a note, navigation console or welcome modal is open - if ($editingNoteId || showNavigationConsole || showWelcomeModal) return + if ($editingNoteId || showNavigationConsole || showWelcomeModal) { +return +} if (e.key === ':' && !e.shiftKey) { e.preventDefault() diff --git a/src/components/WelcomeModal.tsx b/src/components/WelcomeModal.tsx index 92336e7..1f2781c 100644 --- a/src/components/WelcomeModal.tsx +++ b/src/components/WelcomeModal.tsx @@ -21,7 +21,9 @@ export function WelcomeModal({ isOpen, onClose }: WelcomeModalProps) { } } - if (!isOpen) return null + if (!isOpen) { +return null +} return (
{ - if (!this.db) await this.init() + if (!this.db) { +await this.init() +} return new Promise((resolve, reject) => { const transaction = this.db!.transaction(['spaces'], 'readonly') const store = transaction.objectStore('spaces') @@ -59,7 +61,9 @@ class IndexedDBAdapter implements StorageAdapter { } async saveSpace(space: Space): Promise { - if (!this.db) await this.init() + if (!this.db) { +await this.init() +} return new Promise((resolve, reject) => { const transaction = this.db!.transaction(['spaces'], 'readwrite') const store = transaction.objectStore('spaces') @@ -97,7 +101,7 @@ class IndexedDBAdapter implements StorageAdapter { if (noteIndex === -1) { throw new Error(`Note ${noteId} not found in space ${spaceId}`) } - space.notes[noteIndex] = { ...space.notes[noteIndex], ...updates } + space.notes[noteIndex] = { ...space.notes[noteIndex]!, ...updates } as Note await this.saveSpace(space) } @@ -111,7 +115,9 @@ class IndexedDBAdapter implements StorageAdapter { } async getAllSpaceIds(): Promise { - if (!this.db) await this.init() + if (!this.db) { +await this.init() +} return new Promise((resolve, reject) => { const transaction = this.db!.transaction(['spaces'], 'readonly') const store = transaction.objectStore('spaces') @@ -123,7 +129,9 @@ class IndexedDBAdapter implements StorageAdapter { } async deleteSpace(id: string): Promise { - if (!this.db) await this.init() + if (!this.db) { +await this.init() +} return new Promise((resolve, reject) => { const transaction = this.db!.transaction(['spaces'], 'readwrite') const store = transaction.objectStore('spaces') @@ -136,7 +144,7 @@ class IndexedDBAdapter implements StorageAdapter { } class DatabaseAdapter implements StorageAdapter { - private apiUrl: string + private readonly apiUrl: string constructor(apiUrl?: string) { this.apiUrl = apiUrl || this.getApiUrl() @@ -155,8 +163,12 @@ class DatabaseAdapter implements StorageAdapter { async getSpace(id: string): Promise { const response = await fetch(`${this.apiUrl}/spaces/${id}`) - if (response.status === 404) return undefined - if (!response.ok) throw new Error(`Failed to get space: ${response.statusText}`) + if (response.status === 404) { +return undefined +} + if (!response.ok) { +throw new Error(`Failed to get space: ${response.statusText}`) +} return await response.json() } @@ -166,7 +178,9 @@ class DatabaseAdapter implements StorageAdapter { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(space) }) - if (!response.ok) throw new Error(`Failed to save space: ${response.statusText}`) + if (!response.ok) { +throw new Error(`Failed to save space: ${response.statusText}`) +} } async createSpace(id: string): Promise { @@ -181,7 +195,9 @@ class DatabaseAdapter implements StorageAdapter { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(note) }) - if (!response.ok) throw new Error(`Failed to add note: ${response.statusText}`) + if (!response.ok) { +throw new Error(`Failed to add note: ${response.statusText}`) +} } async updateNoteInSpace(spaceId: string, noteId: string, updates: Partial): Promise { @@ -190,19 +206,25 @@ class DatabaseAdapter implements StorageAdapter { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }) - if (!response.ok) throw new Error(`Failed to update note: ${response.statusText}`) + if (!response.ok) { +throw new Error(`Failed to update note: ${response.statusText}`) +} } async deleteNoteFromSpace(spaceId: string, noteId: string): Promise { const response = await fetch(`${this.apiUrl}/spaces/${spaceId}/notes/${noteId}`, { method: 'DELETE' }) - if (!response.ok) throw new Error(`Failed to delete note: ${response.statusText}`) + if (!response.ok) { +throw new Error(`Failed to delete note: ${response.statusText}`) +} } async getAllSpaceIds(): Promise { const response = await fetch(`${this.apiUrl}/spaces`) - if (!response.ok) throw new Error(`Failed to get spaces: ${response.statusText}`) + if (!response.ok) { +throw new Error(`Failed to get spaces: ${response.statusText}`) +} const spaces = await response.json() return spaces.map((space: Space) => space.id) } @@ -211,7 +233,9 @@ class DatabaseAdapter implements StorageAdapter { const response = await fetch(`${this.apiUrl}/spaces/${id}`, { method: 'DELETE' }) - if (!response.ok) throw new Error(`Failed to delete space: ${response.statusText}`) + if (!response.ok) { +throw new Error(`Failed to delete space: ${response.statusText}`) +} } } diff --git a/src/store.ts b/src/store.ts index 5d8682f..4947d6a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,14 +2,14 @@ import { atom } from 'nanostores' import { db } from './db' import { decompressSpace } from './utils/shareSpace' -type Note = { +interface Note { id: string x: number y: number content: string } -type Space = { +interface Space { id: string notes: Note[] } @@ -93,13 +93,17 @@ export const goBack = async () => { const newHistory = history.slice(0, -1) navigationHistory.set(newHistory) const previousSpaceId = newHistory[newHistory.length - 1] - await loadSpace(previousSpaceId) + if (previousSpaceId !== undefined) { + await loadSpace(previousSpaceId) + } } } export const createNote = async (x: number, y: number) => { const space = currentSpace.get() - if (!space) return null + if (!space) { +return null +} const note: Note = { id: `note-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, @@ -117,7 +121,9 @@ export const createNote = async (x: number, y: number) => { export const updateNote = async (noteId: string, updates: Partial) => { const space = currentSpace.get() - if (!space) return + if (!space) { +return +} await db.updateNoteInSpace(space.id, noteId, updates) const updatedSpace = await db.getSpace(space.id) @@ -126,7 +132,9 @@ export const updateNote = async (noteId: string, updates: Partial) => { export const deleteNote = async (noteId: string) => { const space = currentSpace.get() - if (!space) return + if (!space) { +return +} await db.deleteNoteFromSpace(space.id, noteId) const updatedSpace = await db.getSpace(space.id) @@ -145,7 +153,7 @@ export const getSpaceFromUrl = () => { } // Selection management -export const selectNote = (noteId: string, multiSelect: boolean = false) => { +export const selectNote = (noteId: string, multiSelect = false) => { const currentSelection = selectedNoteIds.get() if (multiSelect) { @@ -163,7 +171,9 @@ export const selectNote = (noteId: string, multiSelect: boolean = false) => { export const selectNotesInRect = (minX: number, minY: number, maxX: number, maxY: number) => { const space = currentSpace.get() - if (!space) return + if (!space) { +return +} const selectedIds = space.notes .filter(note => { @@ -186,7 +196,9 @@ export const clearSelection = () => { export const deleteSelectedNotes = async () => { const selectedIds = selectedNoteIds.get() const space = currentSpace.get() - if (!space || selectedIds.length === 0) return + if (!space || selectedIds.length === 0) { +return +} for (const noteId of selectedIds) { await db.deleteNoteFromSpace(space.id, noteId) @@ -200,7 +212,9 @@ export const deleteSelectedNotes = async () => { export const moveSelectedNotesLocal = (deltaX: number, deltaY: number) => { const selectedIds = selectedNoteIds.get() const space = currentSpace.get() - if (!space || selectedIds.length === 0) return + if (!space || selectedIds.length === 0) { +return +} // Update positions locally without DB calls const updatedNotes = space.notes.map(note => { @@ -216,7 +230,9 @@ export const moveSelectedNotesLocal = (deltaX: number, deltaY: number) => { export const saveSelectedNotesToDB = async () => { const selectedIds = selectedNoteIds.get() const space = currentSpace.get() - if (!space || selectedIds.length === 0) return + if (!space || selectedIds.length === 0) { +return +} // Save to DB for (const noteId of selectedIds) { diff --git a/tsconfig.app.json b/tsconfig.app.json index 227a6c6..5ac0514 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,7 +21,21 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + /* Additional strict checks */ + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true }, "include": ["src"] } diff --git a/tsconfig.node.json b/tsconfig.node.json index f85a399..5448547 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -19,7 +19,21 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + /* Additional strict checks */ + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true }, "include": ["vite.config.ts"] }