From 49ecaac720b6d6d7105f0ebdee6520957601d88f Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 3 Apr 2025 20:31:00 +0200 Subject: [PATCH 01/55] Initial typescript setup --- app/package-lock.json | 111 +++++++++++++++---------- app/package.json | 12 ++- app/tsconfig.json | 36 ++++++++ app/tsconfig.node.json | 10 +++ app/{vite.config.js => vite.config.ts} | 4 +- 5 files changed, 122 insertions(+), 51 deletions(-) create mode 100644 app/tsconfig.json create mode 100644 app/tsconfig.node.json rename app/{vite.config.js => vite.config.ts} (96%) diff --git a/app/package-lock.json b/app/package-lock.json index b288f6e..631e70b 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -36,13 +36,15 @@ "unist-util-visit": "^5.0.0" }, "devDependencies": { - "@types/react": "^18.2.67", - "@types/react-dom": "^18.2.22", + "@types/node": "^22.14.0", + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.6", "@vitejs/plugin-react": "^4.3.4", "postcss": "^8.4.47", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "sass": "^1.80.4", + "typescript": "^5.8.2", "vite": "^6.2.4", "vite-plugin-compression2": "^1.3.0" } @@ -235,27 +237,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -309,15 +311,15 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -343,9 +345,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "dev": true, "license": "MIT", "dependencies": { @@ -2037,14 +2039,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.7.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.0.tgz", - "integrity": "sha512-MOdOibwBs6KW1vfqz2uKMlxq5xAfAZ98SZjO8e3XnAbFnTJtAspqhWk7hrdSAs9/Y14ZWMiy7/MxMUzAOadYEw==", + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", + "devOptional": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/prop-types": { @@ -2055,9 +2056,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.9.tgz", - "integrity": "sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ==", + "version": "18.3.20", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", + "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2066,13 +2067,13 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "version": "18.3.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.6.tgz", + "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==", "dev": true, "license": "MIT", - "dependencies": { - "@types/react": "*" + "peerDependencies": { + "@types/react": "^18.0.0" } }, "node_modules/@types/unist": { @@ -3098,6 +3099,12 @@ "node": "*" } }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -4391,9 +4398,9 @@ } }, "node_modules/prismjs": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", - "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "engines": { "node": ">=6" @@ -4640,13 +4647,14 @@ } }, "node_modules/react-syntax-highlighter": { - "version": "15.5.0", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz", - "integrity": "sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==", + "version": "15.6.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz", + "integrity": "sha512-OqJ2/vL7lEeV5zTJyG7kmARppUjiB9h9udl4qHQjjgEos66z00Ia0OckwYfRxCSFrW8RJIBnsBwQsHZbVPspqg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.27.0", "refractor": "^3.6.0" @@ -5422,13 +5430,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "license": "MIT", - "optional": true, - "peer": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" }, "node_modules/unified": { "version": "11.0.5", diff --git a/app/package.json b/app/package.json index 8f716c9..4860e06 100644 --- a/app/package.json +++ b/app/package.json @@ -5,8 +5,10 @@ "type": "module", "scripts": { "start": "vite", - "build": "vite build", - "preview": "vite preview" + "build": "tsc && vite build", + "preview": "vite preview", + "type-check": "tsc --noEmit", + "migrate": "tsc --noEmit --allowJs --checkJs false" }, "repository": { "type": "git", @@ -50,13 +52,15 @@ "unist-util-visit": "^5.0.0" }, "devDependencies": { - "@types/react": "^18.2.67", - "@types/react-dom": "^18.2.22", + "@types/node": "^22.14.0", + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.6", "@vitejs/plugin-react": "^4.3.4", "postcss": "^8.4.47", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "sass": "^1.80.4", + "typescript": "^5.8.2", "vite": "^6.2.4", "vite-plugin-compression2": "^1.3.0" }, diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000..8f73c95 --- /dev/null +++ b/app/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + + /* Paths */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + /* Type checking */ + "allowJs": true, + "checkJs": false, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/app/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/app/vite.config.js b/app/vite.config.ts similarity index 96% rename from app/vite.config.js rename to app/vite.config.ts index 85e6a8d..db02699 100644 --- a/app/vite.config.js +++ b/app/vite.config.ts @@ -9,7 +9,7 @@ import { compression } from 'vite-plugin-compression2'; export default defineConfig(({ mode }) => ({ plugins: [ react({ - include: ['**/*.jsx', '**/*.js'], + include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'], }), compression(), ], @@ -124,7 +124,7 @@ export default defineConfig(({ mode }) => ({ alias: { '@': path.resolve(__dirname, './src'), }, - extensions: ['.js', '.jsx', '.json'], + extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], }, // Add performance optimization options From e4fb276cf7f1e03249a4139fb26b9bd955ebf507 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Fri, 4 Apr 2025 19:23:31 +0200 Subject: [PATCH 02/55] Migrate utils to ts --- app/src/types/api.ts | 7 + app/src/types/file.ts | 36 ++++ app/src/types/markdown.ts | 18 ++ app/src/types/modal.ts | 5 + app/src/types/theme.ts | 4 + app/src/types/workspace.ts | 32 ++++ app/src/utils/constants.js | 67 ------- app/src/utils/fileHelpers.js | 5 - app/src/utils/fileHelpers.ts | 10 + app/src/utils/formatBytes.js | 10 - app/src/utils/formatBytes.ts | 24 +++ ...{remarkWikiLinks.js => remarkWikiLinks.ts} | 180 +++++++++++++++--- 12 files changed, 287 insertions(+), 111 deletions(-) create mode 100644 app/src/types/api.ts create mode 100644 app/src/types/file.ts create mode 100644 app/src/types/markdown.ts create mode 100644 app/src/types/modal.ts create mode 100644 app/src/types/theme.ts create mode 100644 app/src/types/workspace.ts delete mode 100644 app/src/utils/constants.js delete mode 100644 app/src/utils/fileHelpers.js create mode 100644 app/src/utils/fileHelpers.ts delete mode 100644 app/src/utils/formatBytes.js create mode 100644 app/src/utils/formatBytes.ts rename app/src/utils/{remarkWikiLinks.js => remarkWikiLinks.ts} (51%) diff --git a/app/src/types/api.ts b/app/src/types/api.ts new file mode 100644 index 0000000..77996bd --- /dev/null +++ b/app/src/types/api.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + API_BASE_URL: string; + } +} + +export const API_BASE_URL = window.API_BASE_URL; diff --git a/app/src/types/file.ts b/app/src/types/file.ts new file mode 100644 index 0000000..64741dd --- /dev/null +++ b/app/src/types/file.ts @@ -0,0 +1,36 @@ +export enum FileAction { + Create = 'create', + Delete = 'delete', + Rename = 'rename', +} + +export enum FileExtension { + Markdown = '.md', + JPG = '.jpg', + JPEG = '.jpeg', + PNG = '.png', + GIF = '.gif', + WebP = '.webp', + SVG = '.svg', +} + +export const IMAGE_EXTENSIONS = [ + FileExtension.JPG, + FileExtension.JPEG, + FileExtension.PNG, + FileExtension.GIF, + FileExtension.WebP, + FileExtension.SVG, +]; + +export interface DefaultFile { + name: string; + path: string; + content: string; +} + +export const DEFAULT_FILE: DefaultFile = { + name: 'New File.md', + path: 'New File.md', + content: '# Welcome to NovaMD\n\nStart editing here!', +}; diff --git a/app/src/types/markdown.ts b/app/src/types/markdown.ts new file mode 100644 index 0000000..96a9a77 --- /dev/null +++ b/app/src/types/markdown.ts @@ -0,0 +1,18 @@ +export enum InlineContainerType { + Paragraph = 'paragraph', + ListItem = 'listItem', + TableCell = 'tableCell', + Blockquote = 'blockquote', + Heading = 'heading', + Emphasis = 'emphasis', + Strong = 'strong', + Delete = 'delete', +} + +export const INLINE_CONTAINER_TYPES = new Set( + Object.values(InlineContainerType) +); + +export const MARKDOWN_REGEX = { + WIKILINK: /(!?)\[\[(.*?)\]\]/g, +} as const; diff --git a/app/src/types/modal.ts b/app/src/types/modal.ts new file mode 100644 index 0000000..984334e --- /dev/null +++ b/app/src/types/modal.ts @@ -0,0 +1,5 @@ +export enum ModalType { + NewFile = 'newFile', + DeleteFile = 'deleteFile', + CommitMessage = 'commitMessage', +} diff --git a/app/src/types/theme.ts b/app/src/types/theme.ts new file mode 100644 index 0000000..b6f3c1e --- /dev/null +++ b/app/src/types/theme.ts @@ -0,0 +1,4 @@ +export enum Theme { + Light = 'light', + Dark = 'dark', +} diff --git a/app/src/types/workspace.ts b/app/src/types/workspace.ts new file mode 100644 index 0000000..a757e7e --- /dev/null +++ b/app/src/types/workspace.ts @@ -0,0 +1,32 @@ +import { Theme } from './theme'; + +export interface WorkspaceSettings { + theme: Theme; + autoSave: boolean; + gitEnabled: boolean; + gitUrl: string; + gitUser: string; + gitToken: string; + gitAutoCommit: boolean; + gitCommitMsgTemplate: string; +} + +export const DEFAULT_WORKSPACE_SETTINGS: WorkspaceSettings = { + theme: Theme.Light, + autoSave: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', +}; + +export interface Workspace extends WorkspaceSettings { + name: string; +} + +export const DEFAULT_WORKSPACE: Workspace = { + name: '', + ...DEFAULT_WORKSPACE_SETTINGS, +}; diff --git a/app/src/utils/constants.js b/app/src/utils/constants.js deleted file mode 100644 index 201ab56..0000000 --- a/app/src/utils/constants.js +++ /dev/null @@ -1,67 +0,0 @@ -export const API_BASE_URL = window.API_BASE_URL; - -export const THEMES = { - LIGHT: 'light', - DARK: 'dark', -}; - -export const FILE_ACTIONS = { - CREATE: 'create', - DELETE: 'delete', - RENAME: 'rename', -}; - -export const MODAL_TYPES = { - NEW_FILE: 'newFile', - DELETE_FILE: 'deleteFile', - COMMIT_MESSAGE: 'commitMessage', -}; - -export const IMAGE_EXTENSIONS = [ - '.jpg', - '.jpeg', - '.png', - '.gif', - '.webp', - '.svg', -]; - -// Renamed from DEFAULT_SETTINGS to be more specific -export const DEFAULT_WORKSPACE_SETTINGS = { - theme: THEMES.LIGHT, - autoSave: false, - gitEnabled: false, - gitUrl: '', - gitUser: '', - gitToken: '', - gitAutoCommit: false, - gitCommitMsgTemplate: '${action} ${filename}', -}; - -// Template for creating new workspaces -export const DEFAULT_WORKSPACE = { - name: '', - ...DEFAULT_WORKSPACE_SETTINGS, -}; - -export const DEFAULT_FILE = { - name: 'New File.md', - path: 'New File.md', - content: '# Welcome to Lemma\n\nStart editing here!', -}; - -export const MARKDOWN_REGEX = { - WIKILINK: /(!?)\[\[(.*?)\]\]/g, -}; - -// List of element types that can contain inline content -export const INLINE_CONTAINER_TYPES = new Set([ - 'paragraph', - 'listItem', - 'tableCell', - 'blockquote', - 'heading', - 'emphasis', - 'strong', - 'delete', -]); diff --git a/app/src/utils/fileHelpers.js b/app/src/utils/fileHelpers.js deleted file mode 100644 index 7d0c1a0..0000000 --- a/app/src/utils/fileHelpers.js +++ /dev/null @@ -1,5 +0,0 @@ -import { IMAGE_EXTENSIONS } from './constants'; - -export const isImageFile = (filePath) => { - return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext)); -}; diff --git a/app/src/utils/fileHelpers.ts b/app/src/utils/fileHelpers.ts new file mode 100644 index 0000000..7ed9c0b --- /dev/null +++ b/app/src/utils/fileHelpers.ts @@ -0,0 +1,10 @@ +import { IMAGE_EXTENSIONS } from '../types/file'; + +/** + * Checks if the given file path has an image extension. + * @param filePath - The file path to check. + * @returns True if the file path has an image extension, false otherwise. + */ +export const isImageFile = (filePath: string): boolean => { + return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext)); +}; diff --git a/app/src/utils/formatBytes.js b/app/src/utils/formatBytes.js deleted file mode 100644 index dde9f21..0000000 --- a/app/src/utils/formatBytes.js +++ /dev/null @@ -1,10 +0,0 @@ -export const formatBytes = (bytes) => { - const units = ['B', 'KB', 'MB', 'GB']; - let size = bytes; - let unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - return `${size.toFixed(1)} ${units[unitIndex]}`; -}; diff --git a/app/src/utils/formatBytes.ts b/app/src/utils/formatBytes.ts new file mode 100644 index 0000000..595c61a --- /dev/null +++ b/app/src/utils/formatBytes.ts @@ -0,0 +1,24 @@ +/** + * Units for file size display. + */ +type ByteUnit = 'B' | 'KB' | 'MB' | 'GB'; + +/** + * An array of size units in ascending order. + */ +const UNITS: readonly ByteUnit[] = ['B', 'KB', 'MB', 'GB'] as const; + +/** + * Formats a number of bytes into a human-readable string. + * @param bytes - The number of bytes to format. + * @returns A string representing the formatted file size. + */ +export const formatBytes = (bytes: number): string => { + let size: number = bytes; + let unitIndex: number = 0; + while (size >= 1024 && unitIndex < UNITS.length - 1) { + size /= 1024; + unitIndex++; + } + return `${size.toFixed(1)} ${UNITS[unitIndex]}`; +}; diff --git a/app/src/utils/remarkWikiLinks.js b/app/src/utils/remarkWikiLinks.ts similarity index 51% rename from app/src/utils/remarkWikiLinks.js rename to app/src/utils/remarkWikiLinks.ts index 15d66aa..9b7cf6f 100644 --- a/app/src/utils/remarkWikiLinks.js +++ b/app/src/utils/remarkWikiLinks.ts @@ -1,19 +1,108 @@ import { visit } from 'unist-util-visit'; import { lookupFileByName, getFileUrl } from '../services/api'; -import { INLINE_CONTAINER_TYPES, MARKDOWN_REGEX } from './constants'; +import { MARKDOWN_REGEX } from '../types/markdown'; +import { Node } from 'unist'; +import { Parent } from 'unist'; +import { Text } from 'mdast'; -function createNotFoundLink(fileName, displayText, baseUrl) { +/** + * Represents a wiki link match from the regex + */ +interface WikiLinkMatch { + fullMatch: string; + isImage: string; + fileName: string; + displayText: string; + heading?: string; + index: number; +} + +/** + * Node replacement information for processing + */ +interface ReplacementInfo { + matches: WikiLinkMatch[]; + parent: Parent; + index: number; +} + +/** + * Properties for link nodes + */ +interface LinkNodeProps { + style?: { + color?: string; + textDecoration?: string; + }; +} + +/** + * Link node with data properties + */ +interface LinkNode extends Node { + type: 'link'; + url: string; + children: Node[]; + data?: { + hProperties?: LinkNodeProps; + }; +} + +/** + * Image node + */ +interface ImageNode extends Node { + type: 'image'; + url: string; + alt?: string; + title?: string; +} + +/** + * Text node + */ +interface TextNode extends Node { + type: 'text'; + value: string; +} + +/** + * Creates a text node with the given value + */ +function createTextNode(value: string): TextNode { + return { + type: 'text', + value, + }; +} + +/** + * Creates a link node for files that don't exist + */ +function createNotFoundLink( + fileName: string, + displayText: string, + baseUrl: string +): LinkNode { return { type: 'link', url: `${baseUrl}/notfound/${encodeURIComponent(fileName)}`, - children: [{ type: 'text', value: displayText }], + children: [createTextNode(displayText)], data: { hProperties: { style: { color: 'red', textDecoration: 'underline' } }, }, }; } -function createFileLink(filePath, displayText, heading, baseUrl) { +/** + * Creates a link node for existing files + */ +function createFileLink( + filePath: string, + displayText: string, + heading: string | undefined, + baseUrl: string +): LinkNode { const url = heading ? `${baseUrl}/internal/${encodeURIComponent(filePath)}#${encodeURIComponent( heading @@ -23,11 +112,18 @@ function createFileLink(filePath, displayText, heading, baseUrl) { return { type: 'link', url, - children: [{ type: 'text', value: displayText }], + children: [createTextNode(displayText)], }; } -function createImageNode(workspaceName, filePath, displayText) { +/** + * Creates an image node + */ +function createImageNode( + workspaceName: string, + filePath: string, + displayText: string +): ImageNode { return { type: 'image', url: getFileUrl(workspaceName, filePath), @@ -36,35 +132,59 @@ function createImageNode(workspaceName, filePath, displayText) { }; } -function addMarkdownExtension(fileName) { +/** + * Adds markdown extension to a filename if it doesn't have one + */ +function addMarkdownExtension(fileName: string): string { if (fileName.includes('.')) { return fileName; } return `${fileName}.md`; } -export function remarkWikiLinks(workspaceName) { - return async function transformer(tree) { +/** + * Determines if a node type can contain inline content + */ +function canContainInline(type: string): boolean { + return [ + 'paragraph', + 'listItem', + 'tableCell', + 'blockquote', + 'heading', + 'emphasis', + 'strong', + 'delete', + ].includes(type); +} + +/** + * Plugin for processing wiki-style links in markdown + */ +export function remarkWikiLinks(workspaceName: string) { + return async function transformer(tree: Node): Promise { if (!workspaceName) { console.warn('No workspace ID provided to remarkWikiLinks plugin'); return; } - const baseUrl = window.API_BASE_URL; - const replacements = new Map(); + const baseUrl: string = window.API_BASE_URL; + const replacements = new Map(); // Find all wiki links - visit(tree, 'text', function (node, index, parent) { + visit(tree, 'text', function (node: Text, index: number, parent: Parent) { const regex = MARKDOWN_REGEX.WIKILINK; - let match; - const matches = []; + let match: RegExpExecArray | null; + const matches: WikiLinkMatch[] = []; while ((match = regex.exec(node.value)) !== null) { const [fullMatch, isImage, innerContent] = match; - let fileName, displayText, heading; + let fileName: string; + let displayText: string; + let heading: string | undefined; - const pipeIndex = innerContent.indexOf('|'); - const hashIndex = innerContent.indexOf('#'); + const pipeIndex: number = innerContent.indexOf('|'); + const hashIndex: number = innerContent.indexOf('#'); if (pipeIndex !== -1) { displayText = innerContent.slice(pipeIndex + 1).trim(); @@ -96,8 +216,8 @@ export function remarkWikiLinks(workspaceName) { // Process all matches for (const [node, { matches, parent }] of replacements) { - const newNodes = []; - let lastIndex = 0; + const newNodes: (LinkNode | ImageNode | TextNode)[] = []; + let lastIndex: number = 0; for (const match of matches) { // Add text before the match @@ -109,14 +229,17 @@ export function remarkWikiLinks(workspaceName) { } try { - const lookupFileName = match.isImage + const lookupFileName: string = match.isImage ? match.fileName : addMarkdownExtension(match.fileName); - const paths = await lookupFileByName(workspaceName, lookupFileName); + const paths: string[] = await lookupFileByName( + workspaceName, + lookupFileName + ); if (paths && paths.length > 0) { - const filePath = paths[0]; + const filePath: string = paths[0]; if (match.isImage) { newNodes.push( createImageNode(workspaceName, filePath, match.displayText) @@ -154,20 +277,19 @@ export function remarkWikiLinks(workspaceName) { }); } - // If the parent is a container that can have inline content, - // replace the text node directly with the new nodes - if (parent && INLINE_CONTAINER_TYPES.has(parent.type)) { - const nodeIndex = parent.children.indexOf(node); + // Replace nodes in parent + if (parent && canContainInline(parent.type)) { + const nodeIndex: number = parent.children.indexOf(node); if (nodeIndex !== -1) { parent.children.splice(nodeIndex, 1, ...newNodes); } } else { - // For other types of parents, wrap the nodes in a paragraph - const paragraph = { + // Wrap in paragraph for other types + const paragraph: Parent = { type: 'paragraph', children: newNodes, }; - const nodeIndex = parent.children.indexOf(node); + const nodeIndex: number = parent.children.indexOf(node); if (nodeIndex !== -1) { parent.children.splice(nodeIndex, 1, paragraph); } From 46e4897881c92debdb1e7ff9c1857c2b0f909bb7 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 15 Apr 2025 20:00:20 +0200 Subject: [PATCH 03/55] Add TypeScript types for App component --- app/package-lock.json | 1 + app/package.json | 1 + app/src/{App.jsx => App.tsx} | 12 ++++++++---- app/src/{index.jsx => index.tsx} | 4 +++- 4 files changed, 13 insertions(+), 5 deletions(-) rename app/src/{App.jsx => App.tsx} (87%) rename app/src/{index.jsx => index.tsx} (58%) diff --git a/app/package-lock.json b/app/package-lock.json index 631e70b..9c3fdba 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -36,6 +36,7 @@ "unist-util-visit": "^5.0.0" }, "devDependencies": { + "@types/babel__core": "^7.20.5", "@types/node": "^22.14.0", "@types/react": "^18.3.20", "@types/react-dom": "^18.3.6", diff --git a/app/package.json b/app/package.json index 4860e06..a75a83e 100644 --- a/app/package.json +++ b/app/package.json @@ -52,6 +52,7 @@ "unist-util-visit": "^5.0.0" }, "devDependencies": { + "@types/babel__core": "^7.20.5", "@types/node": "^22.14.0", "@types/react": "^18.3.20", "@types/react-dom": "^18.3.6", diff --git a/app/src/App.jsx b/app/src/App.tsx similarity index 87% rename from app/src/App.jsx rename to app/src/App.tsx index 5e6baad..4848f68 100644 --- a/app/src/App.jsx +++ b/app/src/App.tsx @@ -11,7 +11,9 @@ import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import './App.scss'; -function AuthenticatedContent() { +interface AuthenticatedContentProps {} + +const AuthenticatedContent: React.FC = () => { const { user, loading, initialized } = useAuth(); if (!initialized) { @@ -33,9 +35,11 @@ function AuthenticatedContent() { ); -} +}; -function App() { +interface AppProps {} + +const App: React.FC = () => { return ( <> @@ -49,6 +53,6 @@ function App() { ); -} +}; export default App; diff --git a/app/src/index.jsx b/app/src/index.tsx similarity index 58% rename from app/src/index.jsx rename to app/src/index.tsx index 593edf1..3701c7f 100644 --- a/app/src/index.jsx +++ b/app/src/index.tsx @@ -2,7 +2,9 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; -const root = ReactDOM.createRoot(document.getElementById('root')); +const rootElement = document.getElementById('root') as HTMLElement; +const root = ReactDOM.createRoot(rootElement); + root.render( From 0769aa2bac4899045d5ef774add50a15fd54fccc Mon Sep 17 00:00:00 2001 From: LordMathis Date: Fri, 18 Apr 2025 19:21:06 +0200 Subject: [PATCH 04/55] Fix default database URL and type in Config --- server/internal/app/config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/internal/app/config.go b/server/internal/app/config.go index 218b923..e8ab943 100644 --- a/server/internal/app/config.go +++ b/server/internal/app/config.go @@ -34,7 +34,8 @@ type Config struct { // DefaultConfig returns a new Config instance with default values func DefaultConfig() *Config { return &Config{ - DBURL: "sqlite://lemma.db", + DBURL: "./lemma.db", + DBType: db.DBTypeSQLite, WorkDir: "./data", StaticPath: "../app/dist", Port: "8080", From e789025cd110e9d5c4b66bedca962afd25f32735 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Fri, 18 Apr 2025 19:37:39 +0200 Subject: [PATCH 05/55] Refactor authentication API service to TypeScript --- app/src/services/authApi.js | 98 ------------------------- app/src/services/authApi.ts | 138 ++++++++++++++++++++++++++++++++++++ app/src/types/api.ts | 52 ++++++++++++++ 3 files changed, 190 insertions(+), 98 deletions(-) delete mode 100644 app/src/services/authApi.js create mode 100644 app/src/services/authApi.ts diff --git a/app/src/services/authApi.js b/app/src/services/authApi.js deleted file mode 100644 index 9a65633..0000000 --- a/app/src/services/authApi.js +++ /dev/null @@ -1,98 +0,0 @@ -import { API_BASE_URL } from '../utils/constants'; - -export const apiCall = async (url, options = {}) => { - try { - const headers = { - 'Content-Type': 'application/json', - ...options.headers, - }; - - if (options.method && options.method !== 'GET') { - const csrfToken = document.cookie - .split('; ') - .find((row) => row.startsWith('csrf_token=')) - ?.split('=')[1]; - if (csrfToken) { - headers['X-CSRF-Token'] = csrfToken; - } - } - - const response = await fetch(url, { - ...options, - headers, - credentials: 'include', - }); - - if (response.status === 429) { - throw new Error('Rate limit exceeded'); - } - - // Handle 401 responses - if (response.status === 401) { - const isRefreshEndpoint = url.endsWith('/auth/refresh'); - if (!isRefreshEndpoint) { - // Attempt token refresh and retry the request - const refreshSuccess = await refreshToken(); - if (refreshSuccess) { - // Retry the original request - return apiCall(url, options); - } - } - throw new Error('Authentication failed'); - } - - // Handle other error responses - if (!response.ok && response.status !== 204) { - const errorData = await response.json().catch(() => null); - throw new Error( - errorData?.message || `HTTP error! status: ${response.status}` - ); - } - - // Return null for 204 responses - if (response.status === 204) { - return null; - } - - return response; - } catch (error) { - console.error(`API call failed: ${error.message}`); - throw error; - } -}; - -// Authentication endpoints -export const login = async (email, password) => { - const response = await apiCall(`${API_BASE_URL}/auth/login`, { - method: 'POST', - body: JSON.stringify({ email, password }), - }); - - const data = await response.json(); - // No need to store tokens as they're in cookies now - return data; -}; - -export const logout = async () => { - await apiCall(`${API_BASE_URL}/auth/logout`, { - method: 'POST', - }); - return; -}; - -export const refreshToken = async () => { - try { - const response = await apiCall(`${API_BASE_URL}/auth/refresh`, { - method: 'POST', - }); - return response.status === 200; - } catch (error) { - console.error('Token refresh failed:', error); - return false; - } -}; - -export const getCurrentUser = async () => { - const response = await apiCall(`${API_BASE_URL}/auth/me`); - return response.json(); -}; diff --git a/app/src/services/authApi.ts b/app/src/services/authApi.ts new file mode 100644 index 0000000..1bed5a9 --- /dev/null +++ b/app/src/services/authApi.ts @@ -0,0 +1,138 @@ +import { + API_BASE_URL, + User, + LoginRequest, + LoginResponse, + ApiCallOptions, + ErrorResponse +} from '../types/api'; + +let authToken: string | null = null; + +/** + * Sets the authentication token for API requests + */ +export const setAuthToken = (token: string): void => { + authToken = token; +}; + +/** + * Clears the authentication token + */ +export const clearAuthToken = (): void => { + authToken = null; +}; + +/** + * Gets headers for API requests including auth token if present + */ +export const getAuthHeaders = (): HeadersInit => { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + return headers; +}; + +/** + * Makes an API call with authentication and error handling + */ +export const apiCall = async ( + url: string, + options: ApiCallOptions = {} +): Promise => { + try { + const headers = { + ...getAuthHeaders(), + ...options.headers, + }; + + const response = await fetch(url, { + ...options, + headers: headers as HeadersInit, + }); + + // Handle 401 responses + if (response.status === 401) { + const isRefreshEndpoint = url.endsWith('/auth/refresh'); + if (!isRefreshEndpoint) { + // Attempt token refresh and retry the request + const refreshSuccess = await refreshToken(); + if (refreshSuccess) { + // Retry the original request with the new token + return apiCall(url, options); + } + } + throw new Error('Authentication failed'); + } + + if (!response.ok && response.status !== 204) { + const errorData = await response.json() as ErrorResponse; + throw new Error( + errorData?.message || `HTTP error! status: ${response.status}` + ); + } + + return response; + } catch (error) { + console.error(`API call failed: ${(error as Error).message}`); + throw error; + } +}; + +/** + * Logs in a user with email and password + */ +export const login = async ( + email: string, + password: string +): Promise => { + const loginData: LoginRequest = { email, password }; + const response = await apiCall(`${API_BASE_URL}/auth/login`, { + method: 'POST', + body: JSON.stringify(loginData), + }); + return response.json(); +}; + +/** + * Logs out the current user + */ +export const logout = async (): Promise => { + const sessionId = localStorage.getItem('sessionId'); + await apiCall(`${API_BASE_URL}/auth/logout`, { + method: 'POST', + headers: { + 'X-Session-ID': sessionId || '', + }, + }); +}; + +/** + * Refreshes the auth token using a refresh token + */ +export const refreshToken = async (): Promise => { + const refreshToken = localStorage.getItem('refreshToken'); + try { + const response = await apiCall(`${API_BASE_URL}/auth/refresh`, { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }); + const data = await response.json(); + return !!data.accessToken; + } catch (error) { + return false; + } +}; + +/** + * Gets the currently authenticated user + */ +export const getCurrentUser = async (): Promise => { + const response = await apiCall(`${API_BASE_URL}/auth/me`); + return response.json(); +}; \ No newline at end of file diff --git a/app/src/types/api.ts b/app/src/types/api.ts index 77996bd..d556639 100644 --- a/app/src/types/api.ts +++ b/app/src/types/api.ts @@ -5,3 +5,55 @@ declare global { } export const API_BASE_URL = window.API_BASE_URL; + +/** + * User role in the system + */ +export enum UserRole { + Admin = 'admin', + Editor = 'editor', + Viewer = 'viewer' +} + +/** + * User model from the API + */ +export interface User { + id: number; + email: string; + displayName?: string; + role: UserRole; + createdAt: string; + lastWorkspaceId: number; +} + +/** + * Error response from the API + */ +export interface ErrorResponse { + message: string; +} + +/** + * Login request parameters + */ +export interface LoginRequest { + email: string; + password: string; +} + +/** + * Login response from the API + */ +export interface LoginResponse { + user: User; + sessionId: string; + expiresAt: string; +} + +/** + * API call options extending the standard RequestInit + */ +export interface ApiCallOptions extends RequestInit { + headers?: HeadersInit; +} \ No newline at end of file From 043eab423fa55fc2bcfe9e7667952a2f631a3a7d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sat, 3 May 2025 21:28:41 +0200 Subject: [PATCH 06/55] Migrating from services to dedicated API files --- .../{services/adminApi.js => api/admin.js} | 2 +- app/src/api/api.ts | 47 ++++++ app/src/api/auth.ts | 93 ++++++++++++ app/src/{services/api.js => api/notes.js} | 2 +- app/src/components/editor/ContentView.jsx | 2 +- .../modals/workspace/CreateWorkspaceModal.jsx | 2 +- .../navigation/WorkspaceSwitcher.jsx | 2 +- app/src/contexts/AuthContext.jsx | 2 +- app/src/contexts/WorkspaceContext.jsx | 2 +- app/src/hooks/useAdminData.js | 2 +- app/src/hooks/useFileContent.js | 2 +- app/src/hooks/useFileList.js | 2 +- app/src/hooks/useFileOperations.js | 2 +- app/src/hooks/useGitOperations.js | 2 +- app/src/hooks/useLastOpenedFile.js | 2 +- app/src/hooks/useProfileSettings.js | 2 +- app/src/hooks/useUserAdmin.js | 2 +- app/src/services/authApi.ts | 138 ------------------ app/src/types/api.ts | 59 -------- app/src/types/authApi.ts | 105 +++++++++++++ app/src/types/markdown.ts | 4 - app/src/utils/remarkWikiLinks.ts | 17 +-- 22 files changed, 265 insertions(+), 228 deletions(-) rename app/src/{services/adminApi.js => api/admin.js} (97%) create mode 100644 app/src/api/api.ts create mode 100644 app/src/api/auth.ts rename app/src/{services/api.js => api/notes.js} (99%) delete mode 100644 app/src/services/authApi.ts delete mode 100644 app/src/types/api.ts create mode 100644 app/src/types/authApi.ts diff --git a/app/src/services/adminApi.js b/app/src/api/admin.js similarity index 97% rename from app/src/services/adminApi.js rename to app/src/api/admin.js index 3c011d0..d8b93cc 100644 --- a/app/src/services/adminApi.js +++ b/app/src/api/admin.js @@ -1,4 +1,4 @@ -import { apiCall } from './authApi'; +import { apiCall } from './auth'; import { API_BASE_URL } from '../utils/constants'; const ADMIN_BASE_URL = `${API_BASE_URL}/admin`; diff --git a/app/src/api/api.ts b/app/src/api/api.ts new file mode 100644 index 0000000..c56f23f --- /dev/null +++ b/app/src/api/api.ts @@ -0,0 +1,47 @@ +import { refreshToken } from './auth'; + +/** + * Makes an API call with proper cookie handling and error handling + */ +export const apiCall = async ( + url: string, + options: RequestInit = {} +): Promise => { + try { + const response = await fetch(url, { + ...options, + // Include credentials to send/receive cookies + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + // Handle 401 responses + if (response.status === 401) { + const isRefreshEndpoint = url.endsWith('/auth/refresh'); + if (!isRefreshEndpoint) { + // Attempt token refresh and retry the request + const refreshSuccess = await refreshToken(); + if (refreshSuccess) { + // Retry the original request + return apiCall(url, options); + } + } + throw new Error('Authentication failed'); + } + + if (!response.ok && response.status !== 204) { + const errorData = (await response.json()) as { message: string }; + throw new Error( + errorData?.message || `HTTP error! status: ${response.status}` + ); + } + + return response; + } catch (error) { + console.error(`API call failed: ${(error as Error).message}`); + throw error; + } +}; diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts new file mode 100644 index 0000000..6eb0a05 --- /dev/null +++ b/app/src/api/auth.ts @@ -0,0 +1,93 @@ +import { + API_BASE_URL, + User, + LoginRequest, + LoginResponse, + isLoginResponse, + ErrorResponse, + isUser, +} from '../types/authApi'; +import { apiCall } from './api'; + +/** + * Logs in a user with email and password + */ +export const login = async ( + email: string, + password: string +): Promise => { + const loginData: LoginRequest = { email, password }; + const response = await apiCall(`${API_BASE_URL}/auth/login`, { + method: 'POST', + body: JSON.stringify(loginData), + }); + + if (!response.ok) { + const data = await response.json(); + const errorData = data as ErrorResponse; + throw new Error(errorData.message || 'Login failed'); + } + + const data = await response.json(); + if (!isLoginResponse(data)) { + throw new Error('Invalid login response received from API'); + } + + return data; +}; + +/** + * Logs out the current user + */ +export const logout = async (): Promise => { + const response = await apiCall(`${API_BASE_URL}/auth/logout`, { + method: 'POST', + }); + + if (!response.ok) { + const data = await response.json(); + const errorData = data as ErrorResponse; + throw new Error(errorData.message || 'Logout failed'); + } +}; + +/** + * Refreshes the auth token + * @returns true if refresh was successful, false otherwise + */ +export const refreshToken = async (): Promise => { + try { + const response = await apiCall(`${API_BASE_URL}/auth/refresh`, { + method: 'POST', + }); + + if (!response.ok) { + const data = await response.json(); + const errorData = data as ErrorResponse; + throw new Error(errorData.message || 'Token refresh failed'); + } + + return true; + } catch (error) { + return false; + } +}; + +/** + * Gets the currently authenticated user + */ +export const getCurrentUser = async (): Promise => { + const response = await apiCall(`${API_BASE_URL}/auth/me`); + const data = await response.json(); + + if (!response.ok) { + const errorData = data as ErrorResponse; + throw new Error(errorData.message || 'Failed to get current user'); + } + + if (!isUser(data)) { + throw new Error('Invalid user data received from API'); + } + + return data; +}; diff --git a/app/src/services/api.js b/app/src/api/notes.js similarity index 99% rename from app/src/services/api.js rename to app/src/api/notes.js index 626f684..a635da9 100644 --- a/app/src/services/api.js +++ b/app/src/api/notes.js @@ -1,5 +1,5 @@ import { API_BASE_URL } from '../utils/constants'; -import { apiCall } from './authApi'; +import { apiCall } from './auth'; export const updateProfile = async (updates) => { const response = await apiCall(`${API_BASE_URL}/profile`, { diff --git a/app/src/components/editor/ContentView.jsx b/app/src/components/editor/ContentView.jsx index 11eda30..7c075b5 100644 --- a/app/src/components/editor/ContentView.jsx +++ b/app/src/components/editor/ContentView.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Text, Center } from '@mantine/core'; import Editor from './Editor'; import MarkdownPreview from './MarkdownPreview'; -import { getFileUrl } from '../../services/api'; +import { getFileUrl } from '../../api/notes'; import { isImageFile } from '../../utils/fileHelpers'; const ContentView = ({ diff --git a/app/src/components/modals/workspace/CreateWorkspaceModal.jsx b/app/src/components/modals/workspace/CreateWorkspaceModal.jsx index d847af1..75048f2 100644 --- a/app/src/components/modals/workspace/CreateWorkspaceModal.jsx +++ b/app/src/components/modals/workspace/CreateWorkspaceModal.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; import { useModalContext } from '../../../contexts/ModalContext'; -import { createWorkspace } from '../../../services/api'; +import { createWorkspace } from '../../../api/notes'; import { notifications } from '@mantine/notifications'; const CreateWorkspaceModal = ({ onWorkspaceCreated }) => { diff --git a/app/src/components/navigation/WorkspaceSwitcher.jsx b/app/src/components/navigation/WorkspaceSwitcher.jsx index 6ddc0af..77be4ee 100644 --- a/app/src/components/navigation/WorkspaceSwitcher.jsx +++ b/app/src/components/navigation/WorkspaceSwitcher.jsx @@ -17,7 +17,7 @@ import { import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useModalContext } from '../../contexts/ModalContext'; -import { listWorkspaces } from '../../services/api'; +import { listWorkspaces } from '../../api/notes'; import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal'; const WorkspaceSwitcher = () => { diff --git a/app/src/contexts/AuthContext.jsx b/app/src/contexts/AuthContext.jsx index 4878e05..20faa34 100644 --- a/app/src/contexts/AuthContext.jsx +++ b/app/src/contexts/AuthContext.jsx @@ -6,7 +6,7 @@ import React, { useEffect, } from 'react'; import { notifications } from '@mantine/notifications'; -import * as authApi from '../services/authApi'; +import * as authApi from '../api/auth'; const AuthContext = createContext(null); diff --git a/app/src/contexts/WorkspaceContext.jsx b/app/src/contexts/WorkspaceContext.jsx index beddf4f..5fee428 100644 --- a/app/src/contexts/WorkspaceContext.jsx +++ b/app/src/contexts/WorkspaceContext.jsx @@ -14,7 +14,7 @@ import { updateLastWorkspaceName, deleteWorkspace, listWorkspaces, -} from '../services/api'; +} from '../api/notes'; import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants'; const WorkspaceContext = createContext(); diff --git a/app/src/hooks/useAdminData.js b/app/src/hooks/useAdminData.js index 9669ad3..6357be3 100644 --- a/app/src/hooks/useAdminData.js +++ b/app/src/hooks/useAdminData.js @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { notifications } from '@mantine/notifications'; -import { getUsers, getWorkspaces, getSystemStats } from '../services/adminApi'; +import { getUsers, getWorkspaces, getSystemStats } from '../api/admin'; // Hook for admin data fetching (stats and workspaces) export const useAdminData = (type) => { diff --git a/app/src/hooks/useFileContent.js b/app/src/hooks/useFileContent.js index 21b8776..4c20937 100644 --- a/app/src/hooks/useFileContent.js +++ b/app/src/hooks/useFileContent.js @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { fetchFileContent } from '../services/api'; +import { fetchFileContent } from '../api/notes'; import { isImageFile } from '../utils/fileHelpers'; import { DEFAULT_FILE } from '../utils/constants'; import { useWorkspace } from '../contexts/WorkspaceContext'; diff --git a/app/src/hooks/useFileList.js b/app/src/hooks/useFileList.js index 4557f1f..2505f30 100644 --- a/app/src/hooks/useFileList.js +++ b/app/src/hooks/useFileList.js @@ -1,5 +1,5 @@ import { useState, useCallback } from 'react'; -import { fetchFileList } from '../services/api'; +import { fetchFileList } from '../api/notes'; import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileList = () => { diff --git a/app/src/hooks/useFileOperations.js b/app/src/hooks/useFileOperations.js index 6df5dae..5f86469 100644 --- a/app/src/hooks/useFileOperations.js +++ b/app/src/hooks/useFileOperations.js @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; -import { saveFileContent, deleteFile } from '../services/api'; +import { saveFileContent, deleteFile } from '../api/notes'; import { useWorkspace } from '../contexts/WorkspaceContext'; import { useGitOperations } from './useGitOperations'; diff --git a/app/src/hooks/useGitOperations.js b/app/src/hooks/useGitOperations.js index 9a3e5b3..7399e28 100644 --- a/app/src/hooks/useGitOperations.js +++ b/app/src/hooks/useGitOperations.js @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; -import { pullChanges, commitAndPush } from '../services/api'; +import { pullChanges, commitAndPush } from '../api/notes'; import { useWorkspace } from '../contexts/WorkspaceContext'; export const useGitOperations = () => { diff --git a/app/src/hooks/useLastOpenedFile.js b/app/src/hooks/useLastOpenedFile.js index d1b8bef..e49ff02 100644 --- a/app/src/hooks/useLastOpenedFile.js +++ b/app/src/hooks/useLastOpenedFile.js @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { getLastOpenedFile, updateLastOpenedFile } from '../services/api'; +import { getLastOpenedFile, updateLastOpenedFile } from '../api/notes'; import { useWorkspace } from '../contexts/WorkspaceContext'; export const useLastOpenedFile = () => { diff --git a/app/src/hooks/useProfileSettings.js b/app/src/hooks/useProfileSettings.js index 381e784..27a1155 100644 --- a/app/src/hooks/useProfileSettings.js +++ b/app/src/hooks/useProfileSettings.js @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { notifications } from '@mantine/notifications'; -import { updateProfile, deleteProfile } from '../services/api'; +import { updateProfile, deleteProfile } from '../api/notes'; export function useProfileSettings() { const [loading, setLoading] = useState(false); diff --git a/app/src/hooks/useUserAdmin.js b/app/src/hooks/useUserAdmin.js index f8e0a1d..777a6e3 100644 --- a/app/src/hooks/useUserAdmin.js +++ b/app/src/hooks/useUserAdmin.js @@ -1,5 +1,5 @@ import { useAdminData } from './useAdminData'; -import { createUser, updateUser, deleteUser } from '../services/adminApi'; +import { createUser, updateUser, deleteUser } from '../api/admin'; import { notifications } from '@mantine/notifications'; export const useUserAdmin = () => { diff --git a/app/src/services/authApi.ts b/app/src/services/authApi.ts deleted file mode 100644 index 1bed5a9..0000000 --- a/app/src/services/authApi.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - API_BASE_URL, - User, - LoginRequest, - LoginResponse, - ApiCallOptions, - ErrorResponse -} from '../types/api'; - -let authToken: string | null = null; - -/** - * Sets the authentication token for API requests - */ -export const setAuthToken = (token: string): void => { - authToken = token; -}; - -/** - * Clears the authentication token - */ -export const clearAuthToken = (): void => { - authToken = null; -}; - -/** - * Gets headers for API requests including auth token if present - */ -export const getAuthHeaders = (): HeadersInit => { - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - return headers; -}; - -/** - * Makes an API call with authentication and error handling - */ -export const apiCall = async ( - url: string, - options: ApiCallOptions = {} -): Promise => { - try { - const headers = { - ...getAuthHeaders(), - ...options.headers, - }; - - const response = await fetch(url, { - ...options, - headers: headers as HeadersInit, - }); - - // Handle 401 responses - if (response.status === 401) { - const isRefreshEndpoint = url.endsWith('/auth/refresh'); - if (!isRefreshEndpoint) { - // Attempt token refresh and retry the request - const refreshSuccess = await refreshToken(); - if (refreshSuccess) { - // Retry the original request with the new token - return apiCall(url, options); - } - } - throw new Error('Authentication failed'); - } - - if (!response.ok && response.status !== 204) { - const errorData = await response.json() as ErrorResponse; - throw new Error( - errorData?.message || `HTTP error! status: ${response.status}` - ); - } - - return response; - } catch (error) { - console.error(`API call failed: ${(error as Error).message}`); - throw error; - } -}; - -/** - * Logs in a user with email and password - */ -export const login = async ( - email: string, - password: string -): Promise => { - const loginData: LoginRequest = { email, password }; - const response = await apiCall(`${API_BASE_URL}/auth/login`, { - method: 'POST', - body: JSON.stringify(loginData), - }); - return response.json(); -}; - -/** - * Logs out the current user - */ -export const logout = async (): Promise => { - const sessionId = localStorage.getItem('sessionId'); - await apiCall(`${API_BASE_URL}/auth/logout`, { - method: 'POST', - headers: { - 'X-Session-ID': sessionId || '', - }, - }); -}; - -/** - * Refreshes the auth token using a refresh token - */ -export const refreshToken = async (): Promise => { - const refreshToken = localStorage.getItem('refreshToken'); - try { - const response = await apiCall(`${API_BASE_URL}/auth/refresh`, { - method: 'POST', - body: JSON.stringify({ refreshToken }), - }); - const data = await response.json(); - return !!data.accessToken; - } catch (error) { - return false; - } -}; - -/** - * Gets the currently authenticated user - */ -export const getCurrentUser = async (): Promise => { - const response = await apiCall(`${API_BASE_URL}/auth/me`); - return response.json(); -}; \ No newline at end of file diff --git a/app/src/types/api.ts b/app/src/types/api.ts deleted file mode 100644 index d556639..0000000 --- a/app/src/types/api.ts +++ /dev/null @@ -1,59 +0,0 @@ -declare global { - interface Window { - API_BASE_URL: string; - } -} - -export const API_BASE_URL = window.API_BASE_URL; - -/** - * User role in the system - */ -export enum UserRole { - Admin = 'admin', - Editor = 'editor', - Viewer = 'viewer' -} - -/** - * User model from the API - */ -export interface User { - id: number; - email: string; - displayName?: string; - role: UserRole; - createdAt: string; - lastWorkspaceId: number; -} - -/** - * Error response from the API - */ -export interface ErrorResponse { - message: string; -} - -/** - * Login request parameters - */ -export interface LoginRequest { - email: string; - password: string; -} - -/** - * Login response from the API - */ -export interface LoginResponse { - user: User; - sessionId: string; - expiresAt: string; -} - -/** - * API call options extending the standard RequestInit - */ -export interface ApiCallOptions extends RequestInit { - headers?: HeadersInit; -} \ No newline at end of file diff --git a/app/src/types/authApi.ts b/app/src/types/authApi.ts new file mode 100644 index 0000000..ca5337a --- /dev/null +++ b/app/src/types/authApi.ts @@ -0,0 +1,105 @@ +declare global { + interface Window { + API_BASE_URL: string; + } +} + +export const API_BASE_URL = window.API_BASE_URL; + +/** + * User role in the system + */ +export enum UserRole { + Admin = 'admin', + Editor = 'editor', + Viewer = 'viewer', +} + +/** + * Type guard to check if a value is a valid UserRole + */ +export function isUserRole(value: unknown): value is UserRole { + return typeof value === 'string' && value in UserRole; +} + +/** + * User model from the API + */ +export interface User { + id: number; + email: string; + displayName?: string; + role: UserRole; + createdAt: string; + lastWorkspaceId: number; +} + +/** + * Type guard to check if a value is a valid User + */ +export function isUser(value: unknown): value is User { + return ( + typeof value === 'object' && + value !== null && + 'id' in value && + typeof (value as User).id === 'number' && + 'email' in value && + typeof (value as User).email === 'string' && + ('displayName' in value + ? typeof (value as User).displayName === 'string' + : true) && + 'role' in value && + isUserRole((value as User).role) && + 'createdAt' in value && + typeof (value as User).createdAt === 'string' && + 'lastWorkspaceId' in value && + typeof (value as User).lastWorkspaceId === 'number' + ); +} + +/** + * Error response from the API + */ +export interface ErrorResponse { + message: string; +} + +/** + * Login request parameters + */ +export interface LoginRequest { + email: string; + password: string; +} + +/** + * Login response from the API + */ +export interface LoginResponse { + user: User; + sessionId: string; + expiresAt: string; +} + +/** + * Type guard to check if a value is a valid LoginResponse + */ +export function isLoginResponse(value: unknown): value is LoginResponse { + return ( + typeof value === 'object' && + value !== null && + 'user' in value && + isUser((value as LoginResponse).user) && + 'sessionId' in value && + typeof (value as LoginResponse).sessionId === 'string' && + 'expiresAt' in value && + typeof (value as LoginResponse).expiresAt === 'string' + ); +} + +/** + * API call options extending the standard RequestInit + */ +export interface ApiCallOptions extends RequestInit { + headers?: HeadersInit; +} diff --git a/app/src/types/markdown.ts b/app/src/types/markdown.ts index 96a9a77..e36c59a 100644 --- a/app/src/types/markdown.ts +++ b/app/src/types/markdown.ts @@ -9,10 +9,6 @@ export enum InlineContainerType { Delete = 'delete', } -export const INLINE_CONTAINER_TYPES = new Set( - Object.values(InlineContainerType) -); - export const MARKDOWN_REGEX = { WIKILINK: /(!?)\[\[(.*?)\]\]/g, } as const; diff --git a/app/src/utils/remarkWikiLinks.ts b/app/src/utils/remarkWikiLinks.ts index 9b7cf6f..569501e 100644 --- a/app/src/utils/remarkWikiLinks.ts +++ b/app/src/utils/remarkWikiLinks.ts @@ -1,6 +1,6 @@ import { visit } from 'unist-util-visit'; -import { lookupFileByName, getFileUrl } from '../services/api'; -import { MARKDOWN_REGEX } from '../types/markdown'; +import { lookupFileByName, getFileUrl } from '../api/notes'; +import { InlineContainerType, MARKDOWN_REGEX } from '../types/markdown'; import { Node } from 'unist'; import { Parent } from 'unist'; import { Text } from 'mdast'; @@ -146,16 +146,9 @@ function addMarkdownExtension(fileName: string): string { * Determines if a node type can contain inline content */ function canContainInline(type: string): boolean { - return [ - 'paragraph', - 'listItem', - 'tableCell', - 'blockquote', - 'heading', - 'emphasis', - 'strong', - 'delete', - ].includes(type); + return Object.values(InlineContainerType).includes( + type as InlineContainerType + ); } /** From 8849deec215e37ddf85cfda449b1dc64d3e78865 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 5 May 2025 21:28:29 +0200 Subject: [PATCH 07/55] Migrate admin API to typescript --- app/src/api/admin.js | 49 ------------ app/src/api/admin.ts | 157 +++++++++++++++++++++++++++++++++++++ app/src/types/adminApi.ts | 60 ++++++++++++++ app/src/types/workspace.ts | 25 ++++++ 4 files changed, 242 insertions(+), 49 deletions(-) delete mode 100644 app/src/api/admin.js create mode 100644 app/src/api/admin.ts create mode 100644 app/src/types/adminApi.ts diff --git a/app/src/api/admin.js b/app/src/api/admin.js deleted file mode 100644 index d8b93cc..0000000 --- a/app/src/api/admin.js +++ /dev/null @@ -1,49 +0,0 @@ -import { apiCall } from './auth'; -import { API_BASE_URL } from '../utils/constants'; - -const ADMIN_BASE_URL = `${API_BASE_URL}/admin`; - -// User Management -export const getUsers = async () => { - const response = await apiCall(`${ADMIN_BASE_URL}/users`); - return response.json(); -}; - -export const createUser = async (userData) => { - const response = await apiCall(`${ADMIN_BASE_URL}/users`, { - method: 'POST', - body: JSON.stringify(userData), - }); - return response.json(); -}; - -export const deleteUser = async (userId) => { - const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, { - method: 'DELETE', - }); - if (response.status === 204) { - return; - } else { - throw new Error('Failed to delete user with status: ', response.status); - } -}; - -export const updateUser = async (userId, userData) => { - const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, { - method: 'PUT', - body: JSON.stringify(userData), - }); - return response.json(); -}; - -// Workspace Management -export const getWorkspaces = async () => { - const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`); - return response.json(); -}; - -// System Statistics -export const getSystemStats = async () => { - const response = await apiCall(`${ADMIN_BASE_URL}/stats`); - return response.json(); -}; diff --git a/app/src/api/admin.ts b/app/src/api/admin.ts new file mode 100644 index 0000000..f7de7b7 --- /dev/null +++ b/app/src/api/admin.ts @@ -0,0 +1,157 @@ +import { apiCall } from './api'; +import { API_BASE_URL, isUser, User } from '../types/authApi'; +import { + CreateUserRequest, + isSystemStats, + SystemStats, + UpdateUserRequest, +} from '@/types/adminApi'; +import { isWorkspace, Workspace } from '@/types/workspace'; + +const ADMIN_BASE_URL = `${API_BASE_URL}/admin`; + +// User Management + +/** + * Fetches all users from the API + * @returns {Promise} A promise that resolves to an array of users + * @throws {Error} If the API call fails or returns an invalid response + * */ +export const getUsers = async (): Promise => { + const response = await apiCall(`${ADMIN_BASE_URL}/users`); + + if (!response.ok) { + const data = await response.json(); + const errorData = data as { message: string }; + throw new Error(errorData.message || 'Failed to fetch users'); + } + + const data = await response.json(); + if (!Array.isArray(data)) { + throw new Error('Invalid users response received from API'); + } + return data.map((user) => { + if (!isUser(user)) { + throw new Error('Invalid user object received from API'); + } + return user as User; + }); +}; + +/** + * Creates a new user in the system + * @param {CreateUserRequest} userData The data for the new user + * @returns {Promise} A promise that resolves to the created user + * @throws {Error} If the API call fails or returns an invalid response + * */ +export const createUser = async ( + userData: CreateUserRequest +): Promise => { + const response = await apiCall(`${ADMIN_BASE_URL}/users`, { + method: 'POST', + body: JSON.stringify(userData), + }); + + if (!response.ok) { + const data = await response.json(); + const errorData = data as { message: string }; + throw new Error(errorData.message || 'Failed to create user'); + } + + const data = await response.json(); + if (!isUser(data)) { + throw new Error('Invalid user object received from API'); + } + return data as User; +}; + +/** + * Deletes a user from the system + * @param {number} userId The ID of the user to delete + * @throws {Error} If the API call fails or returns an invalid response + * */ +export const deleteUser = async (userId: number) => { + const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, { + method: 'DELETE', + }); + if (response.status === 204) { + return; + } else { + throw new Error('Failed to delete user with status: ' + response.status); + } +}; + +/** + * Updates an existing user in the system + * @param {number} userId The ID of the user to update + * @param {UpdateUserRequest} userData The data to update the user with + * @returns {Promise} A promise that resolves to the updated user + * @throws {Error} If the API call fails or returns an invalid response + * */ +export const updateUser = async ( + userId: number, + userData: UpdateUserRequest +): Promise => { + const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, { + method: 'PUT', + body: JSON.stringify(userData), + }); + + if (!response.ok) { + const data = await response.json(); + const errorData = data as { message: string }; + throw new Error(errorData.message || 'Failed to update user'); + } + const data = await response.json(); + if (!isUser(data)) { + throw new Error('Invalid user object received from API'); + } + return data as User; +}; + +// Workspace Management + +/** + * Fetches all workspaces from the API + * @returns {Promise} A promise that resolves to an array of workspaces + * @throws {Error} If the API call fails or returns an invalid response + * */ +export const getWorkspaces = async (): Promise => { + const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`); + if (!response.ok) { + const data = await response.json(); + const errorData = data as { message: string }; + throw new Error(errorData.message || 'Failed to fetch workspaces'); + } + const data = await response.json(); + if (!Array.isArray(data)) { + throw new Error('Invalid workspaces response received from API'); + } + return data.map((workspace) => { + if (!isWorkspace(workspace)) { + throw new Error('Invalid workspace object received from API'); + } + return workspace as Workspace; + }); +}; + +// System Statistics + +/** + * Fetches system-wide statistics from the API + * @returns {Promise} A promise that resolves to the system statistics + * @throws {Error} If the API call fails or returns an invalid response + * */ +export const getSystemStats = async (): Promise => { + const response = await apiCall(`${ADMIN_BASE_URL}/stats`); + if (!response.ok) { + const data = await response.json(); + const errorData = data as { message: string }; + throw new Error(errorData.message || 'Failed to fetch system stats'); + } + const data = await response.json(); + if (!isSystemStats(data)) { + throw new Error('Invalid system stats response received from API'); + } + return data as SystemStats; +}; diff --git a/app/src/types/adminApi.ts b/app/src/types/adminApi.ts new file mode 100644 index 0000000..b70f3c0 --- /dev/null +++ b/app/src/types/adminApi.ts @@ -0,0 +1,60 @@ +import { UserRole } from './authApi'; + +// CreateUserRequest holds the request fields for creating a new user +export interface CreateUserRequest { + email: string; + displayName: string; + password: string; + role: UserRole; +} + +// UpdateUserRequest holds the request fields for updating a user +export interface UpdateUserRequest { + email?: string; + displayName?: string; + password?: string; + role?: UserRole; +} + +// WorkspaceStats holds workspace statistics +export interface WorkspaceStats { + userID: number; + userEmail: string; + workspaceID: number; + workspaceName: string; + workspaceCreatedAt: string; // Using ISO string format for time.Time + fileCountStats?: FileCountStats; +} + +// Define FileCountStats based on the Go struct definition of storage.FileCountStats +export interface FileCountStats { + totalFiles: number; + totalSize: number; +} + +export interface UserStats { + totalUsers: number; + totalWorkspaces: number; + activeUsers: number; // Users with activity in last 30 days +} + +// SystemStats holds system-wide statistics +export interface SystemStats extends FileCountStats, UserStats {} + +// isSystemStats checks if the given object is a valid SystemStats object +export function isSystemStats(obj: unknown): obj is SystemStats { + return ( + typeof obj === 'object' && + obj !== null && + 'totalUsers' in obj && + typeof (obj as SystemStats).totalUsers === 'number' && + 'totalWorkspaces' in obj && + typeof (obj as SystemStats).totalWorkspaces === 'number' && + 'activeUsers' in obj && + typeof (obj as SystemStats).activeUsers === 'number' && + 'totalFiles' in obj && + typeof (obj as SystemStats).totalFiles === 'number' && + 'totalSize' in obj && + typeof (obj as SystemStats).totalSize === 'number' + ); +} diff --git a/app/src/types/workspace.ts b/app/src/types/workspace.ts index a757e7e..15ec4f2 100644 --- a/app/src/types/workspace.ts +++ b/app/src/types/workspace.ts @@ -30,3 +30,28 @@ export const DEFAULT_WORKSPACE: Workspace = { name: '', ...DEFAULT_WORKSPACE_SETTINGS, }; + +export function isWorkspace(obj: unknown): obj is Workspace { + return ( + typeof obj === 'object' && + obj !== null && + 'name' in obj && + typeof (obj as Workspace).name === 'string' && + 'theme' in obj && + typeof (obj as Workspace).theme === 'string' && + 'autoSave' in obj && + typeof (obj as Workspace).autoSave === 'boolean' && + 'gitEnabled' in obj && + typeof (obj as Workspace).gitEnabled === 'boolean' && + 'gitUrl' in obj && + typeof (obj as Workspace).gitUrl === 'string' && + 'gitUser' in obj && + typeof (obj as Workspace).gitUser === 'string' && + 'gitToken' in obj && + typeof (obj as Workspace).gitToken === 'string' && + 'gitAutoCommit' in obj && + typeof (obj as Workspace).gitAutoCommit === 'boolean' && + 'gitCommitMsgTemplate' in obj && + typeof (obj as Workspace).gitCommitMsgTemplate === 'string' + ); +} From 905df9f6dd1cd833a7dbe0f880d4c74201052b43 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 5 May 2025 22:20:11 +0200 Subject: [PATCH 08/55] Migrate file api ops to ts --- app/src/api/admin.ts | 29 +------ app/src/api/auth.ts | 37 ++++----- app/src/api/file.ts | 170 +++++++++++++++++++++++++++++++++++++++ app/src/api/notes.js | 64 --------------- app/src/types/fileApi.ts | 73 +++++++++++++++++ 5 files changed, 258 insertions(+), 115 deletions(-) create mode 100644 app/src/api/file.ts create mode 100644 app/src/types/fileApi.ts diff --git a/app/src/api/admin.ts b/app/src/api/admin.ts index f7de7b7..672145a 100644 --- a/app/src/api/admin.ts +++ b/app/src/api/admin.ts @@ -19,14 +19,8 @@ const ADMIN_BASE_URL = `${API_BASE_URL}/admin`; * */ export const getUsers = async (): Promise => { const response = await apiCall(`${ADMIN_BASE_URL}/users`); - - if (!response.ok) { - const data = await response.json(); - const errorData = data as { message: string }; - throw new Error(errorData.message || 'Failed to fetch users'); - } - const data = await response.json(); + if (!Array.isArray(data)) { throw new Error('Invalid users response received from API'); } @@ -52,12 +46,6 @@ export const createUser = async ( body: JSON.stringify(userData), }); - if (!response.ok) { - const data = await response.json(); - const errorData = data as { message: string }; - throw new Error(errorData.message || 'Failed to create user'); - } - const data = await response.json(); if (!isUser(data)) { throw new Error('Invalid user object received from API'); @@ -97,11 +85,6 @@ export const updateUser = async ( body: JSON.stringify(userData), }); - if (!response.ok) { - const data = await response.json(); - const errorData = data as { message: string }; - throw new Error(errorData.message || 'Failed to update user'); - } const data = await response.json(); if (!isUser(data)) { throw new Error('Invalid user object received from API'); @@ -118,11 +101,6 @@ export const updateUser = async ( * */ export const getWorkspaces = async (): Promise => { const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`); - if (!response.ok) { - const data = await response.json(); - const errorData = data as { message: string }; - throw new Error(errorData.message || 'Failed to fetch workspaces'); - } const data = await response.json(); if (!Array.isArray(data)) { throw new Error('Invalid workspaces response received from API'); @@ -144,11 +122,6 @@ export const getWorkspaces = async (): Promise => { * */ export const getSystemStats = async (): Promise => { const response = await apiCall(`${ADMIN_BASE_URL}/stats`); - if (!response.ok) { - const data = await response.json(); - const errorData = data as { message: string }; - throw new Error(errorData.message || 'Failed to fetch system stats'); - } const data = await response.json(); if (!isSystemStats(data)) { throw new Error('Invalid system stats response received from API'); diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index 6eb0a05..e76aaf3 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -4,13 +4,17 @@ import { LoginRequest, LoginResponse, isLoginResponse, - ErrorResponse, isUser, } from '../types/authApi'; import { apiCall } from './api'; /** * Logs in a user with email and password + * @param {string} email - The user's email + * @param {string} password - The user's password + * @returns {Promise} A promise that resolves to the login response + * @throws {Error} If the API call fails or returns an invalid response + * @throws {Error} If the login fails */ export const login = async ( email: string, @@ -22,12 +26,6 @@ export const login = async ( body: JSON.stringify(loginData), }); - if (!response.ok) { - const data = await response.json(); - const errorData = data as ErrorResponse; - throw new Error(errorData.message || 'Login failed'); - } - const data = await response.json(); if (!isLoginResponse(data)) { throw new Error('Invalid login response received from API'); @@ -38,16 +36,17 @@ export const login = async ( /** * Logs out the current user + * @returns {Promise} A promise that resolves when the logout is successful + * @throws {Error} If the API call fails or returns an invalid response + * @throws {Error} If the logout fails */ export const logout = async (): Promise => { const response = await apiCall(`${API_BASE_URL}/auth/logout`, { method: 'POST', }); - if (!response.ok) { - const data = await response.json(); - const errorData = data as ErrorResponse; - throw new Error(errorData.message || 'Logout failed'); + if (response.status !== 204) { + throw new Error('Failed to log out'); } }; @@ -57,16 +56,10 @@ export const logout = async (): Promise => { */ export const refreshToken = async (): Promise => { try { - const response = await apiCall(`${API_BASE_URL}/auth/refresh`, { + await apiCall(`${API_BASE_URL}/auth/refresh`, { method: 'POST', }); - if (!response.ok) { - const data = await response.json(); - const errorData = data as ErrorResponse; - throw new Error(errorData.message || 'Token refresh failed'); - } - return true; } catch (error) { return false; @@ -75,16 +68,14 @@ export const refreshToken = async (): Promise => { /** * Gets the currently authenticated user + * @returns {Promise} A promise that resolves to the current user + * @throws {Error} If the API call fails or returns an invalid response + * @throws {Error} If the user data is invalid */ export const getCurrentUser = async (): Promise => { const response = await apiCall(`${API_BASE_URL}/auth/me`); const data = await response.json(); - if (!response.ok) { - const errorData = data as ErrorResponse; - throw new Error(errorData.message || 'Failed to get current user'); - } - if (!isUser(data)) { throw new Error('Invalid user data received from API'); } diff --git a/app/src/api/file.ts b/app/src/api/file.ts new file mode 100644 index 0000000..53602a8 --- /dev/null +++ b/app/src/api/file.ts @@ -0,0 +1,170 @@ +import { API_BASE_URL } from '@/types/authApi'; +import { apiCall } from './api'; +import { + FileNode, + isFileNode, + isLastOpenedFileResponse, + isLookupResponse, + isSaveFileResponse, + LastOpenedFileResponse, + LookupResponse, + SaveFileResponse, +} from '@/types/fileApi'; + +/** + * listFiles fetches the list of files in a workspace + * @param workspaceName - The name of the workspace + * @returns {Promise} A promise that resolves to an array of FileNode objects + * @throws {Error} If the API call fails or returns an invalid response + */ +export const listFiles = async (workspaceName: string): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files` + ); + const data = await response.json(); + if (!Array.isArray(data)) { + throw new Error('Invalid files response received from API'); + } + return data.map((file) => { + if (!isFileNode(file)) { + throw new Error('Invalid file object received from API'); + } + return file as FileNode; + }); +}; + +/** + * lookupFileByName fetches the file paths that match the given filename in a workspace + * @param workspaceName - The name of the workspace + * @param filename - The name of the file to look up + * @returns {Promise} A promise that resolves to an array of file paths + * @throws {Error} If the API call fails or returns an invalid response + */ +export const lookupFileByName = async ( + workspaceName: string, + filename: string +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/lookup?filename=${encodeURIComponent(filename)}` + ); + const data = await response.json(); + if (!isLookupResponse(data)) { + throw new Error('Invalid lookup response received from API'); + } + const lookupResponse = data as LookupResponse; + return lookupResponse.paths; +}; + +/** + * getFileContent fetches the content of a file in a workspace + * @param workspaceName - The name of the workspace + * @param filePath - The path of the file to fetch + * @returns {Promise} A promise that resolves to the file content + * @throws {Error} If the API call fails or returns an invalid response + */ +export const getFileContent = async ( + workspaceName: string, + filePath: string +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/${encodeURIComponent(filePath)}` + ); + return response.text(); +}; + +/** + * saveFile saves the content to a file in a workspace + * @param workspaceName - The name of the workspace + * @param filePath - The path of the file to save + * @param content - The content to save in the file + * @returns {Promise} A promise that resolves to the save file response + * @throws {Error} If the API call fails or returns an invalid response + */ +export const saveFile = async ( + workspaceName: string, + filePath: string, + content: string +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/${encodeURIComponent(filePath)}`, + { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: content, + } + ); + const data = await response.json(); + if (!isSaveFileResponse(data)) { + throw new Error('Invalid save file response received from API'); + } + return data as SaveFileResponse; +}; + +/** + * deleteFile deletes a file in a workspace + * @param workspaceName - The name of the workspace + * @param filePath - The path of the file to delete + * @throws {Error} If the API call fails or returns an invalid response + */ +export const deleteFile = async (workspaceName: string, filePath: string) => { + await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/${encodeURIComponent(filePath)}`, + { + method: 'DELETE', + } + ); +}; + +/** + * getLastOpenedFile fetches the last opened file in a workspace + * @param workspaceName - The name of the workspace + * @returns {Promise} A promise that resolves to the last opened file path + * @throws {Error} If the API call fails or returns an invalid response + */ +export const getLastOpenedFile = async ( + workspaceName: string +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files/last` + ); + const data = await response.json(); + if (!isLastOpenedFileResponse(data)) { + throw new Error('Invalid last opened file response received from API'); + } + const lastOpenedFileResponse = data as LastOpenedFileResponse; + return lastOpenedFileResponse.lastOpenedFilePath; +}; + +/** + * updateLastOpenedFile updates the last opened file in a workspace + * @param workspaceName - The name of the workspace + * @param filePath - The path of the file to set as last opened + * @throws {Error} If the API call fails or returns an invalid response + */ +export const updateLastOpenedFile = async ( + workspaceName: string, + filePath: string +) => { + await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/last`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filePath }), + } + ); +}; diff --git a/app/src/api/notes.js b/app/src/api/notes.js index a635da9..cb8d8d4 100644 --- a/app/src/api/notes.js +++ b/app/src/api/notes.js @@ -22,43 +22,6 @@ export const fetchLastWorkspaceName = async () => { return response.json(); }; -export const fetchFileList = async (workspaceName) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/files` - ); - return response.json(); -}; - -export const fetchFileContent = async (workspaceName, filePath) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}` - ); - return response.text(); -}; - -export const saveFileContent = async (workspaceName, filePath, content) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`, - { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - }, - body: content, - } - ); - return response.json(); -}; - -export const deleteFile = async (workspaceName, filePath) => { - await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`, - { - method: 'DELETE', - } - ); -}; - export const getWorkspace = async (workspaceName) => { const response = await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}`); return response.json(); @@ -107,33 +70,6 @@ export const getFileUrl = (workspaceName, filePath) => { return `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`; }; -export const lookupFileByName = async (workspaceName, filename) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/files/lookup?filename=${encodeURIComponent( - filename - )}` - ); - const data = await response.json(); - return data.paths; -}; - -export const updateLastOpenedFile = async (workspaceName, filePath) => { - await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}/files/last`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ filePath }), - }); -}; - -export const getLastOpenedFile = async (workspaceName) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/files/last` - ); - return response.json(); -}; - export const listWorkspaces = async () => { const response = await apiCall(`${API_BASE_URL}/workspaces`); return response.json(); diff --git a/app/src/types/fileApi.ts b/app/src/types/fileApi.ts new file mode 100644 index 0000000..ffe0c49 --- /dev/null +++ b/app/src/types/fileApi.ts @@ -0,0 +1,73 @@ +export interface LookupResponse { + paths: string[]; +} + +export function isLookupResponse(obj: unknown): obj is LookupResponse { + return ( + typeof obj === 'object' && + obj !== null && + 'paths' in obj && + Array.isArray((obj as LookupResponse).paths) && + (obj as LookupResponse).paths.every((path) => typeof path === 'string') + ); +} + +export interface SaveFileResponse { + filePath: string; + size: number; + updatedAt: string; // ISO 8601 string representation of the date +} + +export function isSaveFileResponse(obj: unknown): obj is SaveFileResponse { + return ( + typeof obj === 'object' && + obj !== null && + 'filePath' in obj && + typeof (obj as SaveFileResponse).filePath === 'string' && + 'size' in obj && + typeof (obj as SaveFileResponse).size === 'number' && + 'updatedAt' in obj && + typeof (obj as SaveFileResponse).updatedAt === 'string' + ); +} + +export interface LastOpenedFileResponse { + lastOpenedFilePath: string; +} + +export function isLastOpenedFileResponse( + obj: unknown +): obj is LastOpenedFileResponse { + return ( + typeof obj === 'object' && + obj !== null && + 'lastOpenedFilePath' in obj && + typeof (obj as LastOpenedFileResponse).lastOpenedFilePath === 'string' + ); +} + +export interface UpdateLastOpenedFileRequest { + filePath: string; +} + +export interface FileNode { + id: string; + name: string; + path: string; + children: FileNode[]; +} + +export function isFileNode(obj: unknown): obj is FileNode { + return ( + typeof obj === 'object' && + obj !== null && + 'id' in obj && + typeof (obj as FileNode).id === 'string' && + 'name' in obj && + typeof (obj as FileNode).name === 'string' && + 'path' in obj && + typeof (obj as FileNode).path === 'string' && + 'children' in obj && + Array.isArray((obj as FileNode).children) + ); +} From 66fe5e485b010b3db65e093d5a458c323881148c Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 6 May 2025 20:16:22 +0200 Subject: [PATCH 09/55] Migrate user api to ts --- app/src/api/notes.js | 16 ---------------- app/src/api/user.ts | 41 ++++++++++++++++++++++++++++++++++++++++ app/src/types/userApi.ts | 12 ++++++++++++ 3 files changed, 53 insertions(+), 16 deletions(-) create mode 100644 app/src/api/user.ts create mode 100644 app/src/types/userApi.ts diff --git a/app/src/api/notes.js b/app/src/api/notes.js index cb8d8d4..7e3a057 100644 --- a/app/src/api/notes.js +++ b/app/src/api/notes.js @@ -1,22 +1,6 @@ import { API_BASE_URL } from '../utils/constants'; import { apiCall } from './auth'; -export const updateProfile = async (updates) => { - const response = await apiCall(`${API_BASE_URL}/profile`, { - method: 'PUT', - body: JSON.stringify(updates), - }); - return response.json(); -}; - -export const deleteProfile = async (password) => { - const response = await apiCall(`${API_BASE_URL}/profile`, { - method: 'DELETE', - body: JSON.stringify({ password }), - }); - return response.json(); -}; - export const fetchLastWorkspaceName = async () => { const response = await apiCall(`${API_BASE_URL}/workspaces/last`); return response.json(); diff --git a/app/src/api/user.ts b/app/src/api/user.ts new file mode 100644 index 0000000..a8747a2 --- /dev/null +++ b/app/src/api/user.ts @@ -0,0 +1,41 @@ +import { API_BASE_URL, isUser, User } from '@/types/authApi'; +import { apiCall } from './api'; +import { UpdateProfileRequest } from '@/types/userApi'; + +/** + * updateProfile updates the user's profile information. + * @param updateRequest - The request object containing the updated profile information. + * @returns A promise that resolves to the updated user object. + * @throws An error if the response is not valid user data. + */ +export const updateProfile = async ( + updateRequest: UpdateProfileRequest +): Promise => { + const response = await apiCall(`${API_BASE_URL}/profile`, { + method: 'PUT', + body: JSON.stringify(updateRequest), + }); + const data = response.json(); + + if (!isUser(data)) { + throw new Error('Invalid user data'); + } + return data as User; +}; + +/** + * deleteProfile deletes the user's profile. + * @param password - The password of the user. + * @throws An error if the response status is not 204 (No Content). + */ +export const deleteUser = async (password: string) => { + const response = await apiCall(`${API_BASE_URL}/profile`, { + method: 'DELETE', + body: JSON.stringify({ password }), + }); + + if (response.status !== 204) { + throw new Error('Failed to delete profile'); + } + return; +}; diff --git a/app/src/types/userApi.ts b/app/src/types/userApi.ts new file mode 100644 index 0000000..2cec49d --- /dev/null +++ b/app/src/types/userApi.ts @@ -0,0 +1,12 @@ +// UpdateProfileRequest represents a user profile update request +export interface UpdateProfileRequest { + displayName?: string; + email?: string; + currentPassword?: string; + newPassword?: string; +} + +// DeleteAccountRequest represents a user account deletion request +export interface DeleteAccountRequest { + password: string; +} From 02c8100f0bd4ab9941485a792a0c1a0d6ab1d398 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 6 May 2025 20:42:12 +0200 Subject: [PATCH 10/55] Migrate workspace api to ts --- app/src/api/notes.js | 62 --------------- app/src/api/workspace.ts | 151 +++++++++++++++++++++++++++++++++++++ app/src/types/workspace.ts | 8 ++ 3 files changed, 159 insertions(+), 62 deletions(-) create mode 100644 app/src/api/workspace.ts diff --git a/app/src/api/notes.js b/app/src/api/notes.js index 7e3a057..01fe725 100644 --- a/app/src/api/notes.js +++ b/app/src/api/notes.js @@ -1,31 +1,6 @@ import { API_BASE_URL } from '../utils/constants'; import { apiCall } from './auth'; -export const fetchLastWorkspaceName = async () => { - const response = await apiCall(`${API_BASE_URL}/workspaces/last`); - return response.json(); -}; - -export const getWorkspace = async (workspaceName) => { - const response = await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}`); - return response.json(); -}; - -// Combined function to update workspace data including settings -export const updateWorkspace = async (workspaceName, workspaceData) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(workspaceData), - } - ); - return response.json(); -}; - export const pullChanges = async (workspaceName) => { const response = await apiCall( `${API_BASE_URL}/workspaces/${workspaceName}/git/pull`, @@ -53,40 +28,3 @@ export const commitAndPush = async (workspaceName, message) => { export const getFileUrl = (workspaceName, filePath) => { return `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`; }; - -export const listWorkspaces = async () => { - const response = await apiCall(`${API_BASE_URL}/workspaces`); - return response.json(); -}; - -export const createWorkspace = async (name) => { - const response = await apiCall(`${API_BASE_URL}/workspaces`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name }), - }); - return response.json(); -}; - -export const deleteWorkspace = async (workspaceName) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}`, - { - method: 'DELETE', - } - ); - return response.json(); -}; - -export const updateLastWorkspaceName = async (workspaceName) => { - const response = await apiCall(`${API_BASE_URL}/workspaces/last`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ workspaceName }), - }); - return response.json(); -}; diff --git a/app/src/api/workspace.ts b/app/src/api/workspace.ts new file mode 100644 index 0000000..135fbfc --- /dev/null +++ b/app/src/api/workspace.ts @@ -0,0 +1,151 @@ +import { API_BASE_URL } from '@/types/authApi'; +import { apiCall } from './api'; +import { + DeleteWorkspaceResponse, + isWorkspace, + LastWorkspaceNameResponse, + Workspace, +} from '@/types/workspace'; + +/** + * listWorkspaces fetches the list of workspaces + * @returns {Promise} A promise that resolves to an array of Workspace objects + * @throws {Error} If the API call fails or returns an invalid response + */ +export const listWorkspaces = async (): Promise => { + const response = await apiCall(`${API_BASE_URL}/workspaces`); + const data = await response.json(); + if (!Array.isArray(data)) { + throw new Error('Invalid workspaces response received from API'); + } + return data.map((workspace) => { + if (!isWorkspace(workspace)) { + throw new Error('Invalid workspace object received from API'); + } + return workspace as Workspace; + }); +}; + +/** + * createWorkspace creates a new workspace with the given name + * @param name - The name of the workspace to create + * @returns {Promise} A promise that resolves to the created Workspace object + * @throws {Error} If the API call fails or returns an invalid response + */ +export const createWorkspace = async (name: string): Promise => { + const response = await apiCall(`${API_BASE_URL}/workspaces`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }); + const data = await response.json(); + if (!isWorkspace(data)) { + throw new Error('Invalid workspace object received from API'); + } + return data as Workspace; +}; + +/** + * getWorkspace fetches the workspace with the given name + * @param workspaceName - The name of the workspace to fetch + * @returns {Promise} A promise that resolves to the Workspace object + * @throws {Error} If the API call fails or returns an invalid response + */ +export const getWorkspace = async ( + workspaceName: string +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}` + ); + const data = response.json(); + if (!isWorkspace(data)) { + throw new Error('Invalid workspace object received from API'); + } + return data as Workspace; +}; + +/** + * updateWorkspace updates the workspace with the given name + * @param workspaceName - The name of the workspace to update + * @param workspaceData - The updated Workspace object + * @returns {Promise} A promise that resolves to the updated Workspace object + * @throws {Error} If the API call fails or returns an invalid response + */ +export const updateWorkspace = async ( + workspaceName: string, + workspaceData: Workspace +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(workspaceData), + } + ); + const data = response.json(); + if (!isWorkspace(data)) { + throw new Error('Invalid workspace object received from API'); + } + return data as Workspace; +}; + +/** + * deleteWorkspace deletes the workspace with the given name + * @param workspaceName - The name of the workspace to delete + * @returns {Promise} A promise that resolves to the response object + * @throws {Error} If the API call fails or returns an invalid response + */ +export const deleteWorkspace = async ( + workspaceName: string +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`, + { + method: 'DELETE', + } + ); + const data = await response.json(); + if (!('nextWorkspaceName' in data)) { + throw new Error('Invalid delete workspace response received from API'); + } + return data as DeleteWorkspaceResponse; +}; + +/** + * getLastWorkspaceName fetches the last workspace name + * @returns {Promise} A promise that resolves to the last workspace name + * @throws {Error} If the API call fails or returns an invalid response + */ +export const getLastWorkspaceName = + async (): Promise => { + const response = await apiCall(`${API_BASE_URL}/workspaces/last`); + const data = await response.json(); + if (!('lastWorkspaceName' in data)) { + throw new Error('Invalid last workspace name response received from API'); + } + return data as LastWorkspaceNameResponse; + }; + +/** + * updateLastWorkspaceName updates the last workspace name + * @param workspaceName - The name of the workspace to set as last + * @throws {Error} If the API call fails or returns an invalid response + */ +export const updateLastWorkspaceName = async (workspaceName: string) => { + const response = await apiCall(`${API_BASE_URL}/workspaces/last`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workspaceName }), + }); + if (response.status !== 204) { + throw new Error('Failed to update last workspace name'); + } + return; +}; diff --git a/app/src/types/workspace.ts b/app/src/types/workspace.ts index 15ec4f2..b88c041 100644 --- a/app/src/types/workspace.ts +++ b/app/src/types/workspace.ts @@ -1,5 +1,13 @@ import { Theme } from './theme'; +export interface DeleteWorkspaceResponse { + nextWorkspaceName: string; +} + +export interface LastWorkspaceNameResponse { + lastWorkspaceName: string; +} + export interface WorkspaceSettings { theme: Theme; autoSave: boolean; From 1e350bb0cfb4b7fbd539815e16de2d4b48edf3c9 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 6 May 2025 21:02:09 +0200 Subject: [PATCH 11/55] Migrate git api to ts --- app/src/api/git.ts | 49 +++++++++++++++++++ app/src/api/notes.js | 30 ------------ app/src/components/editor/ContentView.jsx | 2 +- .../modals/workspace/CreateWorkspaceModal.jsx | 2 +- .../navigation/WorkspaceSwitcher.jsx | 2 +- app/src/contexts/WorkspaceContext.jsx | 2 +- app/src/hooks/useFileContent.js | 2 +- app/src/hooks/useFileList.js | 2 +- app/src/hooks/useFileOperations.js | 2 +- app/src/hooks/useGitOperations.js | 2 +- app/src/hooks/useLastOpenedFile.js | 2 +- app/src/hooks/useProfileSettings.js | 2 +- app/src/types/git.ts | 1 + app/src/utils/remarkWikiLinks.ts | 2 +- 14 files changed, 61 insertions(+), 41 deletions(-) create mode 100644 app/src/api/git.ts delete mode 100644 app/src/api/notes.js create mode 100644 app/src/types/git.ts diff --git a/app/src/api/git.ts b/app/src/api/git.ts new file mode 100644 index 0000000..d76091d --- /dev/null +++ b/app/src/api/git.ts @@ -0,0 +1,49 @@ +import { API_BASE_URL } from '@/types/authApi'; +import { apiCall } from './api'; + +/** + * pullChanges fetches the latest changes from the remote repository + * @param workspaceName - The name of the workspace + * @returns {Promise} A promise that resolves to a message indicating the result of the pull operation + */ +export const pullChanges = async (workspaceName: string): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/git/pull`, + { + method: 'POST', + } + ); + const data = await response.json(); + if (!('message' in data)) { + throw new Error('Invalid pull response received from API'); + } + return data.message; +}; + +/** + * pushChanges pushes the local changes to the remote repository + * @param workspaceName - The name of the workspace + * @returns {Promise} A promise that resolves to the commit hash of the pushed changes + */ +export const commitAndPush = async ( + workspaceName: string, + message: string +): Promise => { + const response = await apiCall( + `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/git/commit`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message }), + } + ); + const data = await response.json(); + if (!('commitHash' in data)) { + throw new Error('Invalid commit response received from API'); + } + return data.commitHash as CommitHash; +}; diff --git a/app/src/api/notes.js b/app/src/api/notes.js deleted file mode 100644 index 01fe725..0000000 --- a/app/src/api/notes.js +++ /dev/null @@ -1,30 +0,0 @@ -import { API_BASE_URL } from '../utils/constants'; -import { apiCall } from './auth'; - -export const pullChanges = async (workspaceName) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/git/pull`, - { - method: 'POST', - } - ); - return response.json(); -}; - -export const commitAndPush = async (workspaceName, message) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/git/commit`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ message }), - } - ); - return response.json(); -}; - -export const getFileUrl = (workspaceName, filePath) => { - return `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`; -}; diff --git a/app/src/components/editor/ContentView.jsx b/app/src/components/editor/ContentView.jsx index 7c075b5..5235339 100644 --- a/app/src/components/editor/ContentView.jsx +++ b/app/src/components/editor/ContentView.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Text, Center } from '@mantine/core'; import Editor from './Editor'; import MarkdownPreview from './MarkdownPreview'; -import { getFileUrl } from '../../api/notes'; +import { getFileUrl } from '../../api/git'; import { isImageFile } from '../../utils/fileHelpers'; const ContentView = ({ diff --git a/app/src/components/modals/workspace/CreateWorkspaceModal.jsx b/app/src/components/modals/workspace/CreateWorkspaceModal.jsx index 75048f2..1fd8e9b 100644 --- a/app/src/components/modals/workspace/CreateWorkspaceModal.jsx +++ b/app/src/components/modals/workspace/CreateWorkspaceModal.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; import { useModalContext } from '../../../contexts/ModalContext'; -import { createWorkspace } from '../../../api/notes'; +import { createWorkspace } from '../../../api/git'; import { notifications } from '@mantine/notifications'; const CreateWorkspaceModal = ({ onWorkspaceCreated }) => { diff --git a/app/src/components/navigation/WorkspaceSwitcher.jsx b/app/src/components/navigation/WorkspaceSwitcher.jsx index 77be4ee..6a0fd2a 100644 --- a/app/src/components/navigation/WorkspaceSwitcher.jsx +++ b/app/src/components/navigation/WorkspaceSwitcher.jsx @@ -17,7 +17,7 @@ import { import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useModalContext } from '../../contexts/ModalContext'; -import { listWorkspaces } from '../../api/notes'; +import { listWorkspaces } from '../../api/git'; import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal'; const WorkspaceSwitcher = () => { diff --git a/app/src/contexts/WorkspaceContext.jsx b/app/src/contexts/WorkspaceContext.jsx index 5fee428..af23277 100644 --- a/app/src/contexts/WorkspaceContext.jsx +++ b/app/src/contexts/WorkspaceContext.jsx @@ -14,7 +14,7 @@ import { updateLastWorkspaceName, deleteWorkspace, listWorkspaces, -} from '../api/notes'; +} from '../api/git'; import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants'; const WorkspaceContext = createContext(); diff --git a/app/src/hooks/useFileContent.js b/app/src/hooks/useFileContent.js index 4c20937..b524a12 100644 --- a/app/src/hooks/useFileContent.js +++ b/app/src/hooks/useFileContent.js @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { fetchFileContent } from '../api/notes'; +import { fetchFileContent } from '../api/git'; import { isImageFile } from '../utils/fileHelpers'; import { DEFAULT_FILE } from '../utils/constants'; import { useWorkspace } from '../contexts/WorkspaceContext'; diff --git a/app/src/hooks/useFileList.js b/app/src/hooks/useFileList.js index 2505f30..9e8e80a 100644 --- a/app/src/hooks/useFileList.js +++ b/app/src/hooks/useFileList.js @@ -1,5 +1,5 @@ import { useState, useCallback } from 'react'; -import { fetchFileList } from '../api/notes'; +import { fetchFileList } from '../api/git'; import { useWorkspace } from '../contexts/WorkspaceContext'; export const useFileList = () => { diff --git a/app/src/hooks/useFileOperations.js b/app/src/hooks/useFileOperations.js index 5f86469..7b313db 100644 --- a/app/src/hooks/useFileOperations.js +++ b/app/src/hooks/useFileOperations.js @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; -import { saveFileContent, deleteFile } from '../api/notes'; +import { saveFileContent, deleteFile } from '../api/git'; import { useWorkspace } from '../contexts/WorkspaceContext'; import { useGitOperations } from './useGitOperations'; diff --git a/app/src/hooks/useGitOperations.js b/app/src/hooks/useGitOperations.js index 7399e28..bf8d68c 100644 --- a/app/src/hooks/useGitOperations.js +++ b/app/src/hooks/useGitOperations.js @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; -import { pullChanges, commitAndPush } from '../api/notes'; +import { pullChanges, commitAndPush } from '../api/git'; import { useWorkspace } from '../contexts/WorkspaceContext'; export const useGitOperations = () => { diff --git a/app/src/hooks/useLastOpenedFile.js b/app/src/hooks/useLastOpenedFile.js index e49ff02..eee230e 100644 --- a/app/src/hooks/useLastOpenedFile.js +++ b/app/src/hooks/useLastOpenedFile.js @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { getLastOpenedFile, updateLastOpenedFile } from '../api/notes'; +import { getLastOpenedFile, updateLastOpenedFile } from '../api/git'; import { useWorkspace } from '../contexts/WorkspaceContext'; export const useLastOpenedFile = () => { diff --git a/app/src/hooks/useProfileSettings.js b/app/src/hooks/useProfileSettings.js index 27a1155..6ffcdec 100644 --- a/app/src/hooks/useProfileSettings.js +++ b/app/src/hooks/useProfileSettings.js @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { notifications } from '@mantine/notifications'; -import { updateProfile, deleteProfile } from '../api/notes'; +import { updateProfile, deleteProfile } from '../api/git'; export function useProfileSettings() { const [loading, setLoading] = useState(false); diff --git a/app/src/types/git.ts b/app/src/types/git.ts new file mode 100644 index 0000000..cb768b9 --- /dev/null +++ b/app/src/types/git.ts @@ -0,0 +1 @@ +type CommitHash = string; diff --git a/app/src/utils/remarkWikiLinks.ts b/app/src/utils/remarkWikiLinks.ts index 569501e..343eb34 100644 --- a/app/src/utils/remarkWikiLinks.ts +++ b/app/src/utils/remarkWikiLinks.ts @@ -1,5 +1,5 @@ import { visit } from 'unist-util-visit'; -import { lookupFileByName, getFileUrl } from '../api/notes'; +import { lookupFileByName, getFileUrl } from '../api/git'; import { InlineContainerType, MARKDOWN_REGEX } from '../types/markdown'; import { Node } from 'unist'; import { Parent } from 'unist'; From 1a06c31705c8fe9f369deb18a5a2ee1db1a74db3 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 8 May 2025 21:01:20 +0200 Subject: [PATCH 12/55] Migrate AuthContext --- app/src/api/auth.ts | 22 ++---- app/src/contexts/AuthContext.jsx | 108 ------------------------- app/src/contexts/AuthContext.tsx | 131 +++++++++++++++++++++++++++++++ app/src/types/authApi.ts | 25 ------ 4 files changed, 137 insertions(+), 149 deletions(-) delete mode 100644 app/src/contexts/AuthContext.jsx create mode 100644 app/src/contexts/AuthContext.tsx diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index e76aaf3..3fb3ff5 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -1,25 +1,15 @@ -import { - API_BASE_URL, - User, - LoginRequest, - LoginResponse, - isLoginResponse, - isUser, -} from '../types/authApi'; +import { API_BASE_URL, User, LoginRequest, isUser } from '../types/authApi'; import { apiCall } from './api'; /** * Logs in a user with email and password * @param {string} email - The user's email * @param {string} password - The user's password - * @returns {Promise} A promise that resolves to the login response + * @returns {Promise} A promise that resolves to the user * @throws {Error} If the API call fails or returns an invalid response * @throws {Error} If the login fails */ -export const login = async ( - email: string, - password: string -): Promise => { +export const login = async (email: string, password: string): Promise => { const loginData: LoginRequest = { email, password }; const response = await apiCall(`${API_BASE_URL}/auth/login`, { method: 'POST', @@ -27,11 +17,11 @@ export const login = async ( }); const data = await response.json(); - if (!isLoginResponse(data)) { - throw new Error('Invalid login response received from API'); + if (!('user' in data) || !isUser(data.user)) { + throw new Error('Invalid login response from API'); } - return data; + return data.user; }; /** diff --git a/app/src/contexts/AuthContext.jsx b/app/src/contexts/AuthContext.jsx deleted file mode 100644 index 20faa34..0000000 --- a/app/src/contexts/AuthContext.jsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { - createContext, - useContext, - useState, - useCallback, - useEffect, -} from 'react'; -import { notifications } from '@mantine/notifications'; -import * as authApi from '../api/auth'; - -const AuthContext = createContext(null); - -export const AuthProvider = ({ children }) => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [initialized, setInitialized] = useState(false); - - // Load user data on mount - useEffect(() => { - const initializeAuth = async () => { - try { - const userData = await authApi.getCurrentUser(); - setUser(userData); - } catch (error) { - console.error('Failed to initialize auth:', error); - } finally { - setLoading(false); - setInitialized(true); - } - }; - - initializeAuth(); - }, []); - - const login = useCallback(async (email, password) => { - try { - const { user: userData } = await authApi.login(email, password); - setUser(userData); - notifications.show({ - title: 'Success', - message: 'Logged in successfully', - color: 'green', - }); - return true; - } catch (error) { - console.error('Login failed:', error); - notifications.show({ - title: 'Error', - message: error.message || 'Login failed', - color: 'red', - }); - return false; - } - }, []); - - const logout = useCallback(async () => { - try { - await authApi.logout(); - } catch (error) { - console.error('Logout failed:', error); - } finally { - setUser(null); - } - }, []); - - const refreshToken = useCallback(async () => { - try { - const success = await authApi.refreshToken(); - if (!success) { - await logout(); - } - return success; - } catch (error) { - console.error('Token refresh failed:', error); - await logout(); - return false; - } - }, [logout]); - - const refreshUser = useCallback(async () => { - try { - const userData = await authApi.getCurrentUser(); - setUser(userData); - } catch (error) { - console.error('Failed to refresh user data:', error); - } - }, []); - - const value = { - user, - loading, - initialized, - login, - logout, - refreshToken, - refreshUser, - }; - - return {children}; -}; - -export const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -}; diff --git a/app/src/contexts/AuthContext.tsx b/app/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..42c771e --- /dev/null +++ b/app/src/contexts/AuthContext.tsx @@ -0,0 +1,131 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from 'react'; +import { notifications } from '@mantine/notifications'; +import { + login as apiLogin, + logout as apiLogout, + refreshToken as apiRefreshToken, + getCurrentUser, +} from '@/api/auth'; +import { User } from '@/types/authApi'; + +interface AuthContextType { + user: User | null; + loading: boolean; + initialized: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + refreshToken: () => Promise; + refreshUser: () => Promise; +} + +const AuthContext = createContext(null); + +interface AuthProviderProps { + children: React.ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [initialized, setInitialized] = useState(false); + + // Load user data on mount + useEffect(() => { + const initializeAuth = async (): Promise => { + try { + const userData = await getCurrentUser(); + setUser(userData); + } catch (error) { + console.error('Failed to initialize auth:', error); + } finally { + setLoading(false); + setInitialized(true); + } + }; + + initializeAuth(); + }, []); + + const login = useCallback( + async (email: string, password: string): Promise => { + try { + const userData = await apiLogin(email, password); + setUser(userData); + notifications.show({ + title: 'Success', + message: 'Logged in successfully', + color: 'green', + }); + return true; + } catch (error) { + console.error('Login failed:', error); + notifications.show({ + title: 'Error', + message: error instanceof Error ? error.message : 'Login failed', + color: 'red', + }); + return false; + } + }, + [] + ); + + const logout = useCallback(async (): Promise => { + try { + await apiLogout(); + } catch (error) { + console.error('Logout failed:', error); + } finally { + setUser(null); + } + }, []); + + const refreshToken = useCallback(async (): Promise => { + try { + const success = await apiRefreshToken(); + if (!success) { + await logout(); + } + return success; + } catch (error) { + console.error('Token refresh failed:', error); + await logout(); + return false; + } + }, [logout]); + + const refreshUser = useCallback(async (): Promise => { + try { + const userData = await getCurrentUser(); + setUser(userData); + } catch (error) { + console.error('Failed to refresh user data:', error); + } + }, []); + + const value: AuthContextType = { + user, + loading, + initialized, + login, + logout, + refreshToken, + refreshUser, + }; + + return {children}; +}; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/app/src/types/authApi.ts b/app/src/types/authApi.ts index ca5337a..3e40dce 100644 --- a/app/src/types/authApi.ts +++ b/app/src/types/authApi.ts @@ -72,31 +72,6 @@ export interface LoginRequest { password: string; } -/** - * Login response from the API - */ -export interface LoginResponse { - user: User; - sessionId: string; - expiresAt: string; -} - -/** - * Type guard to check if a value is a valid LoginResponse - */ -export function isLoginResponse(value: unknown): value is LoginResponse { - return ( - typeof value === 'object' && - value !== null && - 'user' in value && - isUser((value as LoginResponse).user) && - 'sessionId' in value && - typeof (value as LoginResponse).sessionId === 'string' && - 'expiresAt' in value && - typeof (value as LoginResponse).expiresAt === 'string' - ); -} - /** * API call options extending the standard RequestInit */ From 14b1a46508b506e8d4784974bb54b539327e946a Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 8 May 2025 21:42:13 +0200 Subject: [PATCH 13/55] Migrate ModalContext and WorkspaceContext --- app/src/api/workspace.ts | 32 ++-- app/src/contexts/ModalContext.jsx | 36 ----- app/src/contexts/ModalContext.tsx | 62 ++++++++ ...kspaceContext.jsx => WorkspaceContext.tsx} | 137 +++++++++++------- app/src/types/workspace.ts | 8 - 5 files changed, 160 insertions(+), 115 deletions(-) delete mode 100644 app/src/contexts/ModalContext.jsx create mode 100644 app/src/contexts/ModalContext.tsx rename app/src/contexts/{WorkspaceContext.jsx => WorkspaceContext.tsx} (55%) diff --git a/app/src/api/workspace.ts b/app/src/api/workspace.ts index 135fbfc..318523b 100644 --- a/app/src/api/workspace.ts +++ b/app/src/api/workspace.ts @@ -1,11 +1,6 @@ import { API_BASE_URL } from '@/types/authApi'; import { apiCall } from './api'; -import { - DeleteWorkspaceResponse, - isWorkspace, - LastWorkspaceNameResponse, - Workspace, -} from '@/types/workspace'; +import { isWorkspace, Workspace } from '@/types/workspace'; /** * listWorkspaces fetches the list of workspaces @@ -97,12 +92,12 @@ export const updateWorkspace = async ( /** * deleteWorkspace deletes the workspace with the given name * @param workspaceName - The name of the workspace to delete - * @returns {Promise} A promise that resolves to the response object + * @returns {Promise} A promise that resolves to the next workspace name to switch to * @throws {Error} If the API call fails or returns an invalid response */ export const deleteWorkspace = async ( workspaceName: string -): Promise => { +): Promise => { const response = await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`, { @@ -113,23 +108,22 @@ export const deleteWorkspace = async ( if (!('nextWorkspaceName' in data)) { throw new Error('Invalid delete workspace response received from API'); } - return data as DeleteWorkspaceResponse; + return data.nextWorkspaceName as string; }; /** * getLastWorkspaceName fetches the last workspace name - * @returns {Promise} A promise that resolves to the last workspace name + * @returns {Promise} A promise that resolves to the last workspace name * @throws {Error} If the API call fails or returns an invalid response */ -export const getLastWorkspaceName = - async (): Promise => { - const response = await apiCall(`${API_BASE_URL}/workspaces/last`); - const data = await response.json(); - if (!('lastWorkspaceName' in data)) { - throw new Error('Invalid last workspace name response received from API'); - } - return data as LastWorkspaceNameResponse; - }; +export const getLastWorkspaceName = async (): Promise => { + const response = await apiCall(`${API_BASE_URL}/workspaces/last`); + const data = await response.json(); + if (!('lastWorkspaceName' in data)) { + throw new Error('Invalid last workspace name response received from API'); + } + return data.lastWorkspaceName as string; +}; /** * updateLastWorkspaceName updates the last workspace name diff --git a/app/src/contexts/ModalContext.jsx b/app/src/contexts/ModalContext.jsx deleted file mode 100644 index 4865e6a..0000000 --- a/app/src/contexts/ModalContext.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { createContext, useContext, useState } from 'react'; - -const ModalContext = createContext(); - -export const ModalProvider = ({ children }) => { - const [newFileModalVisible, setNewFileModalVisible] = useState(false); - const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false); - const [commitMessageModalVisible, setCommitMessageModalVisible] = - useState(false); - const [settingsModalVisible, setSettingsModalVisible] = useState(false); - const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] = - useState(false); - const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] = - useState(false); - - const value = { - newFileModalVisible, - setNewFileModalVisible, - deleteFileModalVisible, - setDeleteFileModalVisible, - commitMessageModalVisible, - setCommitMessageModalVisible, - settingsModalVisible, - setSettingsModalVisible, - switchWorkspaceModalVisible, - setSwitchWorkspaceModalVisible, - createWorkspaceModalVisible, - setCreateWorkspaceModalVisible, - }; - - return ( - {children} - ); -}; - -export const useModalContext = () => useContext(ModalContext); diff --git a/app/src/contexts/ModalContext.tsx b/app/src/contexts/ModalContext.tsx new file mode 100644 index 0000000..bd2c813 --- /dev/null +++ b/app/src/contexts/ModalContext.tsx @@ -0,0 +1,62 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +interface ModalContextType { + newFileModalVisible: boolean; + setNewFileModalVisible: React.Dispatch>; + deleteFileModalVisible: boolean; + setDeleteFileModalVisible: React.Dispatch>; + commitMessageModalVisible: boolean; + setCommitMessageModalVisible: React.Dispatch>; + settingsModalVisible: boolean; + setSettingsModalVisible: React.Dispatch>; + switchWorkspaceModalVisible: boolean; + setSwitchWorkspaceModalVisible: React.Dispatch>; + createWorkspaceModalVisible: boolean; + setCreateWorkspaceModalVisible: React.Dispatch>; +} + +// Create the context with a default undefined value +const ModalContext = createContext(null); + +interface ModalProviderProps { + children: ReactNode; +} + +export const ModalProvider: React.FC = ({ children }) => { + const [newFileModalVisible, setNewFileModalVisible] = useState(false); + const [deleteFileModalVisible, setDeleteFileModalVisible] = useState(false); + const [commitMessageModalVisible, setCommitMessageModalVisible] = + useState(false); + const [settingsModalVisible, setSettingsModalVisible] = useState(false); + const [switchWorkspaceModalVisible, setSwitchWorkspaceModalVisible] = + useState(false); + const [createWorkspaceModalVisible, setCreateWorkspaceModalVisible] = + useState(false); + + const value: ModalContextType = { + newFileModalVisible, + setNewFileModalVisible, + deleteFileModalVisible, + setDeleteFileModalVisible, + commitMessageModalVisible, + setCommitMessageModalVisible, + settingsModalVisible, + setSettingsModalVisible, + switchWorkspaceModalVisible, + setSwitchWorkspaceModalVisible, + createWorkspaceModalVisible, + setCreateWorkspaceModalVisible, + }; + + return ( + {children} + ); +}; + +export const useModalContext = (): ModalContextType => { + const context = useContext(ModalContext); + if (context === null) { + throw new Error('useModalContext must be used within a ModalProvider'); + } + return context; +}; diff --git a/app/src/contexts/WorkspaceContext.jsx b/app/src/contexts/WorkspaceContext.tsx similarity index 55% rename from app/src/contexts/WorkspaceContext.jsx rename to app/src/contexts/WorkspaceContext.tsx index af23277..047b5b5 100644 --- a/app/src/contexts/WorkspaceContext.jsx +++ b/app/src/contexts/WorkspaceContext.tsx @@ -4,28 +4,53 @@ import React, { useState, useEffect, useCallback, + ReactNode, } from 'react'; -import { useMantineColorScheme } from '@mantine/core'; +import { MantineColorScheme, useMantineColorScheme } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { - fetchLastWorkspaceName, + getLastWorkspaceName, getWorkspace, updateWorkspace, updateLastWorkspaceName, deleteWorkspace, listWorkspaces, -} from '../api/git'; -import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants'; +} from '@/api/workspace'; +import { + Workspace, + DEFAULT_WORKSPACE_SETTINGS, + WorkspaceSettings, +} from '@/types/workspace'; -const WorkspaceContext = createContext(); +interface WorkspaceContextType { + currentWorkspace: Workspace | null; + workspaces: Workspace[]; + settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS; + updateSettings: (newSettings: Partial) => Promise; + loading: boolean; + colorScheme: MantineColorScheme; + updateColorScheme: (newTheme: MantineColorScheme) => void; + switchWorkspace: (workspaceName: string) => Promise; + deleteCurrentWorkspace: () => Promise; +} -export const WorkspaceProvider = ({ children }) => { - const [currentWorkspace, setCurrentWorkspace] = useState(null); - const [workspaces, setWorkspaces] = useState([]); - const [loading, setLoading] = useState(true); +const WorkspaceContext = createContext(null); + +interface WorkspaceProviderProps { + children: ReactNode; +} + +export const WorkspaceProvider: React.FC = ({ + children, +}) => { + const [currentWorkspace, setCurrentWorkspace] = useState( + null + ); + const [workspaces, setWorkspaces] = useState([]); + const [loading, setLoading] = useState(true); const { colorScheme, setColorScheme } = useMantineColorScheme(); - const loadWorkspaces = useCallback(async () => { + const loadWorkspaces = useCallback(async (): Promise => { try { const workspaceList = await listWorkspaces(); setWorkspaces(workspaceList); @@ -41,22 +66,25 @@ export const WorkspaceProvider = ({ children }) => { } }, []); - const loadWorkspaceData = useCallback(async (workspaceName) => { - try { - const workspace = await getWorkspace(workspaceName); - setCurrentWorkspace(workspace); - setColorScheme(workspace.theme); - } catch (error) { - console.error('Failed to load workspace data:', error); - notifications.show({ - title: 'Error', - message: 'Failed to load workspace data', - color: 'red', - }); - } - }, []); + const loadWorkspaceData = useCallback( + async (workspaceName: string): Promise => { + try { + const workspace = await getWorkspace(workspaceName); + setCurrentWorkspace(workspace); + setColorScheme(workspace.theme as MantineColorScheme); + } catch (error) { + console.error('Failed to load workspace data:', error); + notifications.show({ + title: 'Error', + message: 'Failed to load workspace data', + color: 'red', + }); + } + }, + [] + ); - const loadFirstAvailableWorkspace = useCallback(async () => { + const loadFirstAvailableWorkspace = useCallback(async (): Promise => { try { const allWorkspaces = await listWorkspaces(); if (allWorkspaces.length > 0) { @@ -75,9 +103,9 @@ export const WorkspaceProvider = ({ children }) => { }, []); useEffect(() => { - const initializeWorkspace = async () => { + const initializeWorkspace = async (): Promise => { try { - const { lastWorkspaceName } = await fetchLastWorkspaceName(); + const lastWorkspaceName = await getLastWorkspaceName(); if (lastWorkspaceName) { await loadWorkspaceData(lastWorkspaceName); } else { @@ -95,25 +123,28 @@ export const WorkspaceProvider = ({ children }) => { initializeWorkspace(); }, []); - const switchWorkspace = useCallback(async (workspaceName) => { - try { - setLoading(true); - await updateLastWorkspaceName(workspaceName); - await loadWorkspaceData(workspaceName); - await loadWorkspaces(); - } catch (error) { - console.error('Failed to switch workspace:', error); - notifications.show({ - title: 'Error', - message: 'Failed to switch workspace', - color: 'red', - }); - } finally { - setLoading(false); - } - }, []); + const switchWorkspace = useCallback( + async (workspaceName: string): Promise => { + try { + setLoading(true); + await updateLastWorkspaceName(workspaceName); + await loadWorkspaceData(workspaceName); + await loadWorkspaces(); + } catch (error) { + console.error('Failed to switch workspace:', error); + notifications.show({ + title: 'Error', + message: 'Failed to switch workspace', + color: 'red', + }); + } finally { + setLoading(false); + } + }, + [] + ); - const deleteCurrentWorkspace = useCallback(async () => { + const deleteCurrentWorkspace = useCallback(async (): Promise => { if (!currentWorkspace) return; try { @@ -129,10 +160,12 @@ export const WorkspaceProvider = ({ children }) => { } // Delete workspace and get the next workspace ID - const response = await deleteWorkspace(currentWorkspace.name); + const nextWorkspaceName: string = await deleteWorkspace( + currentWorkspace.name + ); // Load the new workspace data - await loadWorkspaceData(response.nextWorkspaceName); + await loadWorkspaceData(nextWorkspaceName); notifications.show({ title: 'Success', @@ -152,7 +185,7 @@ export const WorkspaceProvider = ({ children }) => { }, [currentWorkspace]); const updateSettings = useCallback( - async (newSettings) => { + async (newSettings: Partial): Promise => { if (!currentWorkspace) return; try { @@ -177,13 +210,13 @@ export const WorkspaceProvider = ({ children }) => { ); const updateColorScheme = useCallback( - (newTheme) => { + (newTheme: MantineColorScheme): void => { setColorScheme(newTheme); }, [setColorScheme] ); - const value = { + const value: WorkspaceContextType = { currentWorkspace, workspaces, settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS, @@ -202,9 +235,9 @@ export const WorkspaceProvider = ({ children }) => { ); }; -export const useWorkspace = () => { +export const useWorkspace = (): WorkspaceContextType => { const context = useContext(WorkspaceContext); - if (context === undefined) { + if (!context) { throw new Error('useWorkspace must be used within a WorkspaceProvider'); } return context; diff --git a/app/src/types/workspace.ts b/app/src/types/workspace.ts index b88c041..15ec4f2 100644 --- a/app/src/types/workspace.ts +++ b/app/src/types/workspace.ts @@ -1,13 +1,5 @@ import { Theme } from './theme'; -export interface DeleteWorkspaceResponse { - nextWorkspaceName: string; -} - -export interface LastWorkspaceNameResponse { - lastWorkspaceName: string; -} - export interface WorkspaceSettings { theme: Theme; autoSave: boolean; From bc60cb34516b61ea5ec420ba6b8959e2a45ac9e7 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 8 May 2025 22:04:49 +0200 Subject: [PATCH 14/55] Migrate useAdminData to TypeScript with improved type safety and error handling --- app/src/hooks/useAdminData.js | 48 ------------------ app/src/hooks/useAdminData.ts | 91 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 48 deletions(-) delete mode 100644 app/src/hooks/useAdminData.js create mode 100644 app/src/hooks/useAdminData.ts diff --git a/app/src/hooks/useAdminData.js b/app/src/hooks/useAdminData.js deleted file mode 100644 index 6357be3..0000000 --- a/app/src/hooks/useAdminData.js +++ /dev/null @@ -1,48 +0,0 @@ -import { useState, useEffect } from 'react'; -import { notifications } from '@mantine/notifications'; -import { getUsers, getWorkspaces, getSystemStats } from '../api/admin'; - -// Hook for admin data fetching (stats and workspaces) -export const useAdminData = (type) => { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const loadData = async () => { - setLoading(true); - setError(null); - try { - let response; - switch (type) { - case 'stats': - response = await getSystemStats(); - break; - case 'workspaces': - response = await getWorkspaces(); - break; - case 'users': - response = await getUsers(); - break; - default: - throw new Error('Invalid data type'); - } - setData(response); - } catch (err) { - const message = err.response?.data?.error || err.message; - setError(message); - notifications.show({ - title: 'Error', - message: `Failed to load ${type}: ${message}`, - color: 'red', - }); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadData(); - }, [type]); - - return { data, loading, error, reload: loadData }; -}; diff --git a/app/src/hooks/useAdminData.ts b/app/src/hooks/useAdminData.ts new file mode 100644 index 0000000..36bb66c --- /dev/null +++ b/app/src/hooks/useAdminData.ts @@ -0,0 +1,91 @@ +import { useState, useEffect } from 'react'; +import { notifications } from '@mantine/notifications'; +import { getUsers, getWorkspaces, getSystemStats } from '@/api/admin'; +import { SystemStats } from '@/types/adminApi'; +import { Workspace } from '@/types/workspace'; +import { User } from '@/types/authApi'; + +// Possible types of admin data +type AdminDataType = 'stats' | 'workspaces' | 'users'; + +// Define the return data type based on the requested data type +type AdminData = T extends 'stats' + ? SystemStats + : T extends 'workspaces' + ? Workspace[] + : T extends 'users' + ? User[] + : never; + +// Define the return type of the hook +interface AdminDataResult { + data: AdminData; + loading: boolean; + error: string | null; + reload: () => Promise; +} + +// Hook for admin data fetching (stats and workspaces) +export const useAdminData = ( + type: T +): AdminDataResult => { + // Initialize with the appropriate empty type + const getInitialData = (): AdminData => { + if (type === 'stats') { + return {} as SystemStats as AdminData; + } else if (type === 'workspaces') { + return [] as Workspace[] as AdminData; + } else if (type === 'users') { + return [] as User[] as AdminData; + } else { + // This case should never happen due to type constraints, + // but TypeScript requires us to handle it + return [] as unknown as AdminData; + } + }; + + const [data, setData] = useState>(getInitialData()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadData = async () => { + setLoading(true); + setError(null); + try { + let response; + switch (type) { + case 'stats': + response = await getSystemStats(); + break; + case 'workspaces': + response = await getWorkspaces(); + break; + case 'users': + response = await getUsers(); + break; + default: + throw new Error('Invalid data type'); + } + setData(response as AdminData); + } catch (err) { + const message = + err instanceof Error + ? (err as any)?.response?.data?.error || err.message + : 'An unknown error occurred'; + setError(message); + notifications.show({ + title: 'Error', + message: `Failed to load ${type}: ${message}`, + color: 'red', + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, [type]); + + return { data, loading, error, reload: loadData }; +}; From 1c477f1022ae40cab197698c14ee648cbb4aeff4 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 11 May 2025 12:24:12 +0200 Subject: [PATCH 15/55] Migrate useFileContent hook --- .../{useFileContent.js => useFileContent.ts} | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) rename app/src/hooks/{useFileContent.js => useFileContent.ts} (59%) diff --git a/app/src/hooks/useFileContent.js b/app/src/hooks/useFileContent.ts similarity index 59% rename from app/src/hooks/useFileContent.js rename to app/src/hooks/useFileContent.ts index b524a12..3bdd43c 100644 --- a/app/src/hooks/useFileContent.js +++ b/app/src/hooks/useFileContent.ts @@ -1,25 +1,38 @@ import { useState, useCallback, useEffect } from 'react'; -import { fetchFileContent } from '../api/git'; import { isImageFile } from '../utils/fileHelpers'; -import { DEFAULT_FILE } from '../utils/constants'; import { useWorkspace } from '../contexts/WorkspaceContext'; +import { DEFAULT_FILE } from '@/types/file'; +import { getFileContent } from '@/api/file'; -export const useFileContent = (selectedFile) => { +interface UseFileContentResult { + content: string; + setContent: React.Dispatch>; + hasUnsavedChanges: boolean; + setHasUnsavedChanges: React.Dispatch>; + loadFileContent: (filePath: string) => Promise; + handleContentChange: (newContent: string) => void; +} + +export const useFileContent = ( + selectedFile: string | null +): UseFileContentResult => { const { currentWorkspace } = useWorkspace(); - const [content, setContent] = useState(DEFAULT_FILE.content); - const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [content, setContent] = useState(DEFAULT_FILE.content); + const [originalContent, setOriginalContent] = useState( + DEFAULT_FILE.content + ); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const loadFileContent = useCallback( - async (filePath) => { + async (filePath: string) => { if (!currentWorkspace) return; try { - let newContent; + let newContent: string; if (filePath === DEFAULT_FILE.path) { newContent = DEFAULT_FILE.content; } else if (!isImageFile(filePath)) { - newContent = await fetchFileContent(currentWorkspace.name, filePath); + newContent = await getFileContent(currentWorkspace.name, filePath); } else { newContent = ''; // Set empty content for image files } @@ -43,7 +56,7 @@ export const useFileContent = (selectedFile) => { }, [selectedFile, currentWorkspace, loadFileContent]); const handleContentChange = useCallback( - (newContent) => { + (newContent: string) => { setContent(newContent); setHasUnsavedChanges(newContent !== originalContent); }, From a7c83d0c242cd56b3bf32fa60034a95c4838db73 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 11 May 2025 12:25:59 +0200 Subject: [PATCH 16/55] Migrate useFileList --- app/src/hooks/{useFileList.js => useFileList.ts} | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) rename app/src/hooks/{useFileList.js => useFileList.ts} (58%) diff --git a/app/src/hooks/useFileList.js b/app/src/hooks/useFileList.ts similarity index 58% rename from app/src/hooks/useFileList.js rename to app/src/hooks/useFileList.ts index 9e8e80a..093da84 100644 --- a/app/src/hooks/useFileList.js +++ b/app/src/hooks/useFileList.ts @@ -1,16 +1,22 @@ import { useState, useCallback } from 'react'; -import { fetchFileList } from '../api/git'; +import { listFiles } from '../api/file'; import { useWorkspace } from '../contexts/WorkspaceContext'; +import { FileNode } from '../types/fileApi'; -export const useFileList = () => { - const [files, setFiles] = useState([]); +interface UseFileListResult { + files: FileNode[]; + loadFileList: () => Promise; +} + +export const useFileList = (): UseFileListResult => { + const [files, setFiles] = useState([]); const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); - const loadFileList = useCallback(async () => { + const loadFileList = useCallback(async (): Promise => { if (!currentWorkspace || workspaceLoading) return; try { - const fileList = await fetchFileList(currentWorkspace.name); + const fileList = await listFiles(currentWorkspace.name); if (Array.isArray(fileList)) { setFiles(fileList); } else { From 5dc427ce0082c20d689777ae6c6a1431052d13b3 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 11 May 2025 12:37:28 +0200 Subject: [PATCH 17/55] Migrate useFileNavigation hook --- ...eFileNavigation.js => useFileNavigation.ts} | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) rename app/src/hooks/{useFileNavigation.js => useFileNavigation.ts} (67%) diff --git a/app/src/hooks/useFileNavigation.js b/app/src/hooks/useFileNavigation.ts similarity index 67% rename from app/src/hooks/useFileNavigation.js rename to app/src/hooks/useFileNavigation.ts index ed4ff76..37a3ce6 100644 --- a/app/src/hooks/useFileNavigation.js +++ b/app/src/hooks/useFileNavigation.ts @@ -1,16 +1,22 @@ import { useState, useCallback, useEffect } from 'react'; -import { DEFAULT_FILE } from '../utils/constants'; +import { DEFAULT_FILE } from '../types/file'; import { useWorkspace } from '../contexts/WorkspaceContext'; import { useLastOpenedFile } from './useLastOpenedFile'; -export const useFileNavigation = () => { - const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path); - const [isNewFile, setIsNewFile] = useState(true); +interface UseFileNavigationResult { + selectedFile: string; + isNewFile: boolean; + handleFileSelect: (filePath: string | null) => Promise; +} + +export const useFileNavigation = (): UseFileNavigationResult => { + const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path); + const [isNewFile, setIsNewFile] = useState(true); const { currentWorkspace } = useWorkspace(); const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile(); const handleFileSelect = useCallback( - async (filePath) => { + async (filePath: string | null): Promise => { const newPath = filePath || DEFAULT_FILE.path; setSelectedFile(newPath); setIsNewFile(!filePath); @@ -24,7 +30,7 @@ export const useFileNavigation = () => { // Load last opened file when workspace changes useEffect(() => { - const initializeFile = async () => { + const initializeFile = async (): Promise => { setSelectedFile(DEFAULT_FILE.path); setIsNewFile(true); From f3691d4dbfa9a02d94f749608d29b9f0b950197f Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 11 May 2025 12:55:18 +0200 Subject: [PATCH 18/55] Migrate useFileOperations --- ...FileOperations.js => useFileOperations.ts} | 29 ++++++++++++------- app/src/types/file.ts | 1 + 2 files changed, 19 insertions(+), 11 deletions(-) rename app/src/hooks/{useFileOperations.js => useFileOperations.ts} (72%) diff --git a/app/src/hooks/useFileOperations.js b/app/src/hooks/useFileOperations.ts similarity index 72% rename from app/src/hooks/useFileOperations.js rename to app/src/hooks/useFileOperations.ts index 7b313db..b9d2bf8 100644 --- a/app/src/hooks/useFileOperations.js +++ b/app/src/hooks/useFileOperations.ts @@ -1,15 +1,22 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; -import { saveFileContent, deleteFile } from '../api/git'; +import { saveFile, deleteFile } from '../api/file'; import { useWorkspace } from '../contexts/WorkspaceContext'; import { useGitOperations } from './useGitOperations'; +import { FileAction } from '../types/file'; -export const useFileOperations = () => { +interface UseFileOperationsResult { + handleSave: (filePath: string, content: string) => Promise; + handleDelete: (filePath: string) => Promise; + handleCreate: (fileName: string, initialContent?: string) => Promise; +} + +export const useFileOperations = (): UseFileOperationsResult => { const { currentWorkspace, settings } = useWorkspace(); const { handleCommitAndPush } = useGitOperations(); const autoCommit = useCallback( - async (filePath, action) => { + async (filePath: string, action: FileAction): Promise => { if (settings.gitAutoCommit && settings.gitEnabled) { let commitMessage = settings.gitCommitMsgTemplate .replace('${filename}', filePath) @@ -25,17 +32,17 @@ export const useFileOperations = () => { ); const handleSave = useCallback( - async (filePath, content) => { + async (filePath: string, content: string): Promise => { if (!currentWorkspace) return false; try { - await saveFileContent(currentWorkspace.name, filePath, content); + await saveFile(currentWorkspace.name, filePath, content); notifications.show({ title: 'Success', message: 'File saved successfully', color: 'green', }); - autoCommit(filePath, 'update'); + await autoCommit(filePath, FileAction.Update); return true; } catch (error) { console.error('Error saving file:', error); @@ -51,7 +58,7 @@ export const useFileOperations = () => { ); const handleDelete = useCallback( - async (filePath) => { + async (filePath: string): Promise => { if (!currentWorkspace) return false; try { @@ -61,7 +68,7 @@ export const useFileOperations = () => { message: 'File deleted successfully', color: 'green', }); - autoCommit(filePath, 'delete'); + await autoCommit(filePath, FileAction.Delete); return true; } catch (error) { console.error('Error deleting file:', error); @@ -77,17 +84,17 @@ export const useFileOperations = () => { ); const handleCreate = useCallback( - async (fileName, initialContent = '') => { + async (fileName: string, initialContent: string = ''): Promise => { if (!currentWorkspace) return false; try { - await saveFileContent(currentWorkspace.name, fileName, initialContent); + await saveFile(currentWorkspace.name, fileName, initialContent); notifications.show({ title: 'Success', message: 'File created successfully', color: 'green', }); - autoCommit(fileName, 'create'); + await autoCommit(fileName, FileAction.Create); return true; } catch (error) { console.error('Error creating new file:', error); diff --git a/app/src/types/file.ts b/app/src/types/file.ts index 64741dd..abfda40 100644 --- a/app/src/types/file.ts +++ b/app/src/types/file.ts @@ -1,5 +1,6 @@ export enum FileAction { Create = 'create', + Update = 'update', Delete = 'delete', Rename = 'rename', } From 32cb89d32958e9480340dfbaa4723c0be20fd3d1 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 11 May 2025 13:01:43 +0200 Subject: [PATCH 19/55] Migrate useGitOperations --- .../{useGitOperations.js => useGitOperations.ts} | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) rename app/src/hooks/{useGitOperations.js => useGitOperations.ts} (77%) diff --git a/app/src/hooks/useGitOperations.js b/app/src/hooks/useGitOperations.ts similarity index 77% rename from app/src/hooks/useGitOperations.js rename to app/src/hooks/useGitOperations.ts index bf8d68c..426c3f7 100644 --- a/app/src/hooks/useGitOperations.js +++ b/app/src/hooks/useGitOperations.ts @@ -3,10 +3,15 @@ import { notifications } from '@mantine/notifications'; import { pullChanges, commitAndPush } from '../api/git'; import { useWorkspace } from '../contexts/WorkspaceContext'; -export const useGitOperations = () => { +interface UseGitOperationsResult { + handlePull: () => Promise; + handleCommitAndPush: (message: string) => Promise; +} + +export const useGitOperations = (): UseGitOperationsResult => { const { currentWorkspace, settings } = useWorkspace(); - const handlePull = useCallback(async () => { + const handlePull = useCallback(async (): Promise => { if (!currentWorkspace || !settings.gitEnabled) return false; try { @@ -29,11 +34,14 @@ export const useGitOperations = () => { }, [currentWorkspace, settings.gitEnabled]); const handleCommitAndPush = useCallback( - async (message) => { + async (message: string): Promise => { if (!currentWorkspace || !settings.gitEnabled) return false; try { - await commitAndPush(currentWorkspace.name, message); + const commitHash: CommitHash = await commitAndPush( + currentWorkspace.name, + message + ); notifications.show({ title: 'Success', message: 'Successfully committed and pushed changes', From c6d46df7a0dee5d7bdfbb836dd51ab3ef4538095 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 11 May 2025 15:09:11 +0200 Subject: [PATCH 20/55] Migrate useLastOpenedFile hook --- app/src/api/file.ts | 7 ++----- ...seLastOpenedFile.js => useLastOpenedFile.ts} | 17 +++++++++++------ app/src/types/fileApi.ts | 15 --------------- 3 files changed, 13 insertions(+), 26 deletions(-) rename app/src/hooks/{useLastOpenedFile.js => useLastOpenedFile.ts} (62%) diff --git a/app/src/api/file.ts b/app/src/api/file.ts index 53602a8..cdfe123 100644 --- a/app/src/api/file.ts +++ b/app/src/api/file.ts @@ -3,10 +3,8 @@ import { apiCall } from './api'; import { FileNode, isFileNode, - isLastOpenedFileResponse, isLookupResponse, isSaveFileResponse, - LastOpenedFileResponse, LookupResponse, SaveFileResponse, } from '@/types/fileApi'; @@ -138,11 +136,10 @@ export const getLastOpenedFile = async ( `${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files/last` ); const data = await response.json(); - if (!isLastOpenedFileResponse(data)) { + if (!('lastOpenedFilePath' in data)) { throw new Error('Invalid last opened file response received from API'); } - const lastOpenedFileResponse = data as LastOpenedFileResponse; - return lastOpenedFileResponse.lastOpenedFilePath; + return data.lastOpenedFilePath; }; /** diff --git a/app/src/hooks/useLastOpenedFile.js b/app/src/hooks/useLastOpenedFile.ts similarity index 62% rename from app/src/hooks/useLastOpenedFile.js rename to app/src/hooks/useLastOpenedFile.ts index eee230e..4422818 100644 --- a/app/src/hooks/useLastOpenedFile.js +++ b/app/src/hooks/useLastOpenedFile.ts @@ -1,16 +1,21 @@ import { useCallback } from 'react'; -import { getLastOpenedFile, updateLastOpenedFile } from '../api/git'; +import { getLastOpenedFile, updateLastOpenedFile } from '../api/file'; import { useWorkspace } from '../contexts/WorkspaceContext'; -export const useLastOpenedFile = () => { +interface UseLastOpenedFileResult { + loadLastOpenedFile: () => Promise; + saveLastOpenedFile: (filePath: string) => Promise; +} + +export const useLastOpenedFile = (): UseLastOpenedFileResult => { const { currentWorkspace } = useWorkspace(); - const loadLastOpenedFile = useCallback(async () => { + const loadLastOpenedFile = useCallback(async (): Promise => { if (!currentWorkspace) return null; try { - const response = await getLastOpenedFile(currentWorkspace.name); - return response.lastOpenedFilePath || null; + const response: string = await getLastOpenedFile(currentWorkspace.name); + return response || null; } catch (error) { console.error('Failed to load last opened file:', error); return null; @@ -18,7 +23,7 @@ export const useLastOpenedFile = () => { }, [currentWorkspace]); const saveLastOpenedFile = useCallback( - async (filePath) => { + async (filePath: string): Promise => { if (!currentWorkspace) return; try { diff --git a/app/src/types/fileApi.ts b/app/src/types/fileApi.ts index ffe0c49..70d4261 100644 --- a/app/src/types/fileApi.ts +++ b/app/src/types/fileApi.ts @@ -31,21 +31,6 @@ export function isSaveFileResponse(obj: unknown): obj is SaveFileResponse { ); } -export interface LastOpenedFileResponse { - lastOpenedFilePath: string; -} - -export function isLastOpenedFileResponse( - obj: unknown -): obj is LastOpenedFileResponse { - return ( - typeof obj === 'object' && - obj !== null && - 'lastOpenedFilePath' in obj && - typeof (obj as LastOpenedFileResponse).lastOpenedFilePath === 'string' - ); -} - export interface UpdateLastOpenedFileRequest { filePath: string; } From 5fcd24db3e8c2aa793bb80925911f8980cca7f62 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 11 May 2025 15:36:30 +0200 Subject: [PATCH 21/55] Migrate useProfileSettings hook --- app/src/hooks/useProfileSettings.js | 71 ----------------------- app/src/hooks/useProfileSettings.ts | 88 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 71 deletions(-) delete mode 100644 app/src/hooks/useProfileSettings.js create mode 100644 app/src/hooks/useProfileSettings.ts diff --git a/app/src/hooks/useProfileSettings.js b/app/src/hooks/useProfileSettings.js deleted file mode 100644 index 6ffcdec..0000000 --- a/app/src/hooks/useProfileSettings.js +++ /dev/null @@ -1,71 +0,0 @@ -import { useState, useCallback } from 'react'; -import { notifications } from '@mantine/notifications'; -import { updateProfile, deleteProfile } from '../api/git'; - -export function useProfileSettings() { - const [loading, setLoading] = useState(false); - - const handleProfileUpdate = useCallback(async (updates) => { - setLoading(true); - try { - const updatedUser = await updateProfile(updates); - - notifications.show({ - title: 'Success', - message: 'Profile updated successfully', - color: 'green', - }); - - return { success: true, user: updatedUser }; - } catch (error) { - let errorMessage = 'Failed to update profile'; - - if (error.message.includes('password')) { - errorMessage = 'Current password is incorrect'; - } else if (error.message.includes('email')) { - errorMessage = 'Email is already in use'; - } - - notifications.show({ - title: 'Error', - message: errorMessage, - color: 'red', - }); - - return { success: false, error: error.message }; - } finally { - setLoading(false); - } - }, []); - - const handleAccountDeletion = useCallback(async (password) => { - setLoading(true); - try { - await deleteProfile(password); - - notifications.show({ - title: 'Success', - message: 'Account deleted successfully', - color: 'green', - }); - - return { success: true }; - } catch (error) { - notifications.show({ - title: 'Error', - message: error.message || 'Failed to delete account', - color: 'red', - }); - - return { success: false, error: error.message }; - } finally { - setLoading(false); - } - }, []); - - return { - loading, - updateProfile: handleProfileUpdate, - deleteAccount: handleAccountDeletion, - }; -} diff --git a/app/src/hooks/useProfileSettings.ts b/app/src/hooks/useProfileSettings.ts new file mode 100644 index 0000000..fd9ecc4 --- /dev/null +++ b/app/src/hooks/useProfileSettings.ts @@ -0,0 +1,88 @@ +import { useState, useCallback } from 'react'; +import { notifications } from '@mantine/notifications'; +import { updateProfile, deleteUser } from '../api/user'; +import { User } from '../types/authApi'; +import { UpdateProfileRequest } from '../types/userApi'; + +interface UseProfileSettingsResult { + loading: boolean; + updateProfile: (updates: UpdateProfileRequest) => Promise; + deleteAccount: (password: string) => Promise; +} + +export function useProfileSettings(): UseProfileSettingsResult { + const [loading, setLoading] = useState(false); + + const handleProfileUpdate = useCallback( + async (updates: UpdateProfileRequest): Promise => { + setLoading(true); + try { + const updatedUser = await updateProfile(updates); + + notifications.show({ + title: 'Success', + message: 'Profile updated successfully', + color: 'green', + }); + + return updatedUser; + } catch (error) { + let errorMessage = 'Failed to update profile'; + + if (error instanceof Error) { + if (error.message.includes('password')) { + errorMessage = 'Current password is incorrect'; + } else if (error.message.includes('email')) { + errorMessage = 'Email is already in use'; + } + } + + notifications.show({ + title: 'Error', + message: errorMessage, + color: 'red', + }); + + return null; + } finally { + setLoading(false); + } + }, + [] + ); + + const handleAccountDeletion = useCallback( + async (password: string): Promise => { + setLoading(true); + try { + await deleteUser(password); + + notifications.show({ + title: 'Success', + message: 'Account deleted successfully', + color: 'green', + }); + + return true; + } catch (error) { + notifications.show({ + title: 'Error', + message: + error instanceof Error ? error.message : 'Failed to delete account', + color: 'red', + }); + + return false; + } finally { + setLoading(false); + } + }, + [] + ); + + return { + loading, + updateProfile: handleProfileUpdate, + deleteAccount: handleAccountDeletion, + }; +} From b7be5a46a2d69fdf547b10845beb31e9e2f07e0d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 11 May 2025 15:44:27 +0200 Subject: [PATCH 22/55] Migrate useUserAdmin --- .../{useUserAdmin.js => useUserAdmin.ts} | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) rename app/src/hooks/{useUserAdmin.js => useUserAdmin.ts} (53%) diff --git a/app/src/hooks/useUserAdmin.js b/app/src/hooks/useUserAdmin.ts similarity index 53% rename from app/src/hooks/useUserAdmin.js rename to app/src/hooks/useUserAdmin.ts index 777a6e3..e6d6413 100644 --- a/app/src/hooks/useUserAdmin.js +++ b/app/src/hooks/useUserAdmin.ts @@ -1,11 +1,28 @@ import { useAdminData } from './useAdminData'; -import { createUser, updateUser, deleteUser } from '../api/admin'; +import { + createUser, + updateUser, + deleteUser as adminDeleteUser, +} from '../api/admin'; import { notifications } from '@mantine/notifications'; +import { User } from '../types/authApi'; +import { CreateUserRequest, UpdateUserRequest } from '../types/adminApi'; -export const useUserAdmin = () => { +interface UseUserAdminResult { + users: User[]; + loading: boolean; + error: string | null; + create: (userData: CreateUserRequest) => Promise; + update: (userId: number, userData: UpdateUserRequest) => Promise; + delete: (userId: number) => Promise; +} + +export const useUserAdmin = (): UseUserAdminResult => { const { data: users, loading, error, reload } = useAdminData('users'); - const handleCreate = async (userData) => { + const handleCreate = async ( + userData: CreateUserRequest + ): Promise => { try { await createUser(userData); notifications.show({ @@ -14,19 +31,22 @@ export const useUserAdmin = () => { color: 'green', }); reload(); - return { success: true }; + return true; } catch (err) { - const message = err.response?.data?.error || err.message; + const message = err instanceof Error ? err.message : String(err); notifications.show({ title: 'Error', message: `Failed to create user: ${message}`, color: 'red', }); - return { success: false, error: message }; + return false; } }; - const handleUpdate = async (userId, userData) => { + const handleUpdate = async ( + userId: number, + userData: UpdateUserRequest + ): Promise => { try { await updateUser(userId, userData); notifications.show({ @@ -35,36 +55,36 @@ export const useUserAdmin = () => { color: 'green', }); reload(); - return { success: true }; + return true; } catch (err) { - const message = err.response?.data?.error || err.message; + const message = err instanceof Error ? err.message : String(err); notifications.show({ title: 'Error', message: `Failed to update user: ${message}`, color: 'red', }); - return { success: false, error: message }; + return false; } }; - const handleDelete = async (userId) => { + const handleDelete = async (userId: number): Promise => { try { - await deleteUser(userId); + await adminDeleteUser(userId); notifications.show({ title: 'Success', message: 'User deleted successfully', color: 'green', }); reload(); - return { success: true }; + return true; } catch (err) { - const message = err.response?.data?.error || err.message; + const message = err instanceof Error ? err.message : String(err); notifications.show({ title: 'Error', message: `Failed to delete user: ${message}`, color: 'red', }); - return { success: false, error: message }; + return false; } }; From 924d710b2f86e2de13f8537a3c7bf38549e96e08 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 12 May 2025 21:26:07 +0200 Subject: [PATCH 23/55] Migrate all modals to ts --- .../auth/{LoginPage.jsx => LoginPage.tsx} | 12 ++--- ...ccountModal.jsx => DeleteAccountModal.tsx} | 14 +++++- ...sswordModal.jsx => EmailPasswordModal.tsx} | 16 ++++++- ...reateFileModal.jsx => CreateFileModal.tsx} | 10 ++-- ...eleteFileModal.jsx => DeleteFileModal.tsx} | 14 +++++- ...essageModal.jsx => CommitMessageModal.tsx} | 10 +++- ...reateUserModal.jsx => CreateUserModal.tsx} | 47 ++++++++++++++----- ...eleteUserModal.jsx => DeleteUserModal.tsx} | 17 ++++++- .../{EditUserModal.jsx => EditUserModal.tsx} | 42 ++++++++++++----- ...paceModal.jsx => CreateWorkspaceModal.tsx} | 17 +++++-- ...paceModal.jsx => DeleteWorkspaceModal.tsx} | 9 +++- 11 files changed, 160 insertions(+), 48 deletions(-) rename app/src/components/auth/{LoginPage.jsx => LoginPage.tsx} (80%) rename app/src/components/modals/account/{DeleteAccountModal.jsx => DeleteAccountModal.tsx} (79%) rename app/src/components/modals/account/{EmailPasswordModal.jsx => EmailPasswordModal.tsx} (77%) rename app/src/components/modals/file/{CreateFileModal.jsx => CreateFileModal.tsx} (80%) rename app/src/components/modals/file/{DeleteFileModal.jsx => DeleteFileModal.tsx} (74%) rename app/src/components/modals/git/{CommitMessageModal.jsx => CommitMessageModal.tsx} (84%) rename app/src/components/modals/user/{CreateUserModal.jsx => CreateUserModal.tsx} (55%) rename app/src/components/modals/user/{DeleteUserModal.jsx => DeleteUserModal.tsx} (69%) rename app/src/components/modals/user/{EditUserModal.jsx => EditUserModal.tsx} (68%) rename app/src/components/modals/workspace/{CreateWorkspaceModal.jsx => CreateWorkspaceModal.tsx} (81%) rename app/src/components/modals/workspace/{DeleteWorkspaceModal.jsx => DeleteWorkspaceModal.tsx} (78%) diff --git a/app/src/components/auth/LoginPage.jsx b/app/src/components/auth/LoginPage.tsx similarity index 80% rename from app/src/components/auth/LoginPage.jsx rename to app/src/components/auth/LoginPage.tsx index faa7d10..5e0d878 100644 --- a/app/src/components/auth/LoginPage.jsx +++ b/app/src/components/auth/LoginPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { FormEvent, useState } from 'react'; import { TextInput, PasswordInput, @@ -11,13 +11,13 @@ import { } from '@mantine/core'; import { useAuth } from '../../contexts/AuthContext'; -const LoginPage = () => { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); +const LoginPage: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); const { login } = useAuth(); - const handleSubmit = async (e) => { + const handleSubmit = async (e: FormEvent): Promise => { e.preventDefault(); setLoading(true); try { diff --git a/app/src/components/modals/account/DeleteAccountModal.jsx b/app/src/components/modals/account/DeleteAccountModal.tsx similarity index 79% rename from app/src/components/modals/account/DeleteAccountModal.jsx rename to app/src/components/modals/account/DeleteAccountModal.tsx index cc77459..0a2255c 100644 --- a/app/src/components/modals/account/DeleteAccountModal.jsx +++ b/app/src/components/modals/account/DeleteAccountModal.tsx @@ -8,8 +8,18 @@ import { Button, } from '@mantine/core'; -const DeleteAccountModal = ({ opened, onClose, onConfirm }) => { - const [password, setPassword] = useState(''); +interface DeleteAccountModalProps { + opened: boolean; + onClose: () => void; + onConfirm: (password: string) => Promise; +} + +const DeleteAccountModal: React.FC = ({ + opened, + onClose, + onConfirm, +}) => { + const [password, setPassword] = useState(''); return ( { - const [password, setPassword] = useState(''); +interface EmailPasswordModalProps { + opened: boolean; + onClose: () => void; + onConfirm: (password: string) => Promise; + email: string; +} + +const EmailPasswordModal: React.FC = ({ + opened, + onClose, + onConfirm, + email, +}) => { + const [password, setPassword] = useState(''); return ( { - const [fileName, setFileName] = useState(''); +interface CreateFileModalProps { + onCreateFile: (fileName: string) => Promise; +} + +const CreateFileModal: React.FC = ({ onCreateFile }) => { + const [fileName, setFileName] = useState(''); const { newFileModalVisible, setNewFileModalVisible } = useModalContext(); - const handleSubmit = async () => { + const handleSubmit = async (): Promise => { if (fileName) { await onCreateFile(fileName); setFileName(''); diff --git a/app/src/components/modals/file/DeleteFileModal.jsx b/app/src/components/modals/file/DeleteFileModal.tsx similarity index 74% rename from app/src/components/modals/file/DeleteFileModal.jsx rename to app/src/components/modals/file/DeleteFileModal.tsx index 056bb21..c21fe26 100644 --- a/app/src/components/modals/file/DeleteFileModal.jsx +++ b/app/src/components/modals/file/DeleteFileModal.tsx @@ -2,11 +2,21 @@ import React from 'react'; import { Modal, Text, Button, Group } from '@mantine/core'; import { useModalContext } from '../../../contexts/ModalContext'; -const DeleteFileModal = ({ onDeleteFile, selectedFile }) => { +interface DeleteFileModalProps { + onDeleteFile: (fileName: string) => Promise; + selectedFile: string | null; +} + +const DeleteFileModal: React.FC = ({ + onDeleteFile, + selectedFile, +}) => { const { deleteFileModalVisible, setDeleteFileModalVisible } = useModalContext(); - const handleConfirm = async () => { + const handleConfirm = async (): Promise => { + if (!selectedFile) return; + await onDeleteFile(selectedFile); setDeleteFileModalVisible(false); }; diff --git a/app/src/components/modals/git/CommitMessageModal.jsx b/app/src/components/modals/git/CommitMessageModal.tsx similarity index 84% rename from app/src/components/modals/git/CommitMessageModal.jsx rename to app/src/components/modals/git/CommitMessageModal.tsx index d0707b9..7443705 100644 --- a/app/src/components/modals/git/CommitMessageModal.jsx +++ b/app/src/components/modals/git/CommitMessageModal.tsx @@ -2,12 +2,18 @@ import React, { useState } from 'react'; import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; import { useModalContext } from '../../../contexts/ModalContext'; -const CommitMessageModal = ({ onCommitAndPush }) => { +interface CommitMessageModalProps { + onCommitAndPush: (message: string) => Promise; +} + +const CommitMessageModal: React.FC = ({ + onCommitAndPush, +}) => { const [message, setMessage] = useState(''); const { commitMessageModalVisible, setCommitMessageModalVisible } = useModalContext(); - const handleSubmit = async () => { + const handleSubmit = async (): Promise => { if (message) { await onCommitAndPush(message); setMessage(''); diff --git a/app/src/components/modals/user/CreateUserModal.jsx b/app/src/components/modals/user/CreateUserModal.tsx similarity index 55% rename from app/src/components/modals/user/CreateUserModal.jsx rename to app/src/components/modals/user/CreateUserModal.tsx index 23d5b5d..7a6dafe 100644 --- a/app/src/components/modals/user/CreateUserModal.jsx +++ b/app/src/components/modals/user/CreateUserModal.tsx @@ -8,20 +8,41 @@ import { Button, Group, } from '@mantine/core'; +import { CreateUserRequest } from '@/types/adminApi'; +import { UserRole } from '@/types/authApi'; -const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => { - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [displayName, setDisplayName] = useState(''); - const [role, setRole] = useState('viewer'); +interface CreateUserModalProps { + opened: boolean; + onClose: () => void; + onCreateUser: (userData: CreateUserRequest) => Promise; + loading: boolean; +} - const handleSubmit = async () => { - const result = await onCreateUser({ email, password, displayName, role }); - if (result.success) { +const CreateUserModal: React.FC = ({ + opened, + onClose, + onCreateUser, + loading, +}) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [role, setRole] = useState(UserRole.Viewer); + + const handleSubmit = async (): Promise => { + const userData: CreateUserRequest = { + email, + password, + displayName, + role, + }; + + const success = await onCreateUser(userData); + if (success) { setEmail(''); setPassword(''); setDisplayName(''); - setRole('viewer'); + setRole(UserRole.Viewer); onClose(); } }; @@ -53,11 +74,11 @@ const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => { label="Role" required value={role} - onChange={setRole} + onChange={(value) => value && setRole(value as UserRole)} data={[ - { value: 'admin', label: 'Admin' }, - { value: 'editor', label: 'Editor' }, - { value: 'viewer', label: 'Viewer' }, + { value: UserRole.Admin, label: 'Admin' }, + { value: UserRole.Editor, label: 'Editor' }, + { value: UserRole.Viewer, label: 'Viewer' }, ]} /> diff --git a/app/src/components/modals/user/DeleteUserModal.jsx b/app/src/components/modals/user/DeleteUserModal.tsx similarity index 69% rename from app/src/components/modals/user/DeleteUserModal.jsx rename to app/src/components/modals/user/DeleteUserModal.tsx index 5763a0a..d3f549b 100644 --- a/app/src/components/modals/user/DeleteUserModal.jsx +++ b/app/src/components/modals/user/DeleteUserModal.tsx @@ -1,7 +1,22 @@ import React from 'react'; import { Modal, Text, Button, Group, Stack } from '@mantine/core'; +import { User } from '@/types/authApi'; -const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => ( +interface DeleteUserModalProps { + opened: boolean; + onClose: () => void; + onConfirm: () => Promise; + user: User | null; + loading: boolean; +} + +const DeleteUserModal: React.FC = ({ + opened, + onClose, + onConfirm, + user, + loading, +}) => ( { - const [formData, setFormData] = useState({ +interface EditUserModalProps { + opened: boolean; + onClose: () => void; + onEditUser: (userId: number, userData: UpdateUserRequest) => Promise; + loading: boolean; + user: User | null; +} + +const EditUserModal: React.FC = ({ + opened, + onClose, + onEditUser, + loading, + user, +}) => { + const [formData, setFormData] = useState({ email: '', displayName: '', - role: '', + role: undefined, password: '', }); @@ -29,18 +45,20 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => { } }, [user]); - const handleSubmit = async () => { + const handleSubmit = async (): Promise => { + if (!user) return; + const updateData = { ...formData, ...(formData.password ? { password: formData.password } : {}), }; - const result = await onEditUser(user.id, updateData); - if (result.success) { + const success = await onEditUser(user.id, updateData); + if (success) { setFormData({ email: '', displayName: '', - role: '', + role: undefined, password: '', }); onClose(); @@ -71,11 +89,13 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => { label="Role" required value={formData.role} - onChange={(value) => setFormData({ ...formData, role: value })} + onChange={(value) => + setFormData({ ...formData, role: value as UserRole | undefined }) + } data={[ - { value: 'admin', label: 'Admin' }, - { value: 'editor', label: 'Editor' }, - { value: 'viewer', label: 'Viewer' }, + { value: UserRole.Admin, label: 'Admin' }, + { value: UserRole.Editor, label: 'Editor' }, + { value: UserRole.Viewer, label: 'Viewer' }, ]} /> { - const [name, setName] = useState(''); - const [loading, setLoading] = useState(false); +interface CreateWorkspaceModalProps { + onWorkspaceCreated?: (workspace: Workspace) => Promise; +} + +const CreateWorkspaceModal: React.FC = ({ + onWorkspaceCreated, +}) => { + const [name, setName] = useState(''); + const [loading, setLoading] = useState(false); const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } = useModalContext(); - const handleSubmit = async () => { + const handleSubmit = async (): Promise => { if (!name.trim()) { notifications.show({ title: 'Error', diff --git a/app/src/components/modals/workspace/DeleteWorkspaceModal.jsx b/app/src/components/modals/workspace/DeleteWorkspaceModal.tsx similarity index 78% rename from app/src/components/modals/workspace/DeleteWorkspaceModal.jsx rename to app/src/components/modals/workspace/DeleteWorkspaceModal.tsx index 8effeeb..8418fb1 100644 --- a/app/src/components/modals/workspace/DeleteWorkspaceModal.jsx +++ b/app/src/components/modals/workspace/DeleteWorkspaceModal.tsx @@ -1,7 +1,14 @@ import React from 'react'; import { Modal, Text, Button, Group, Stack } from '@mantine/core'; -const DeleteWorkspaceModal = ({ +interface DeleteUserModalProps { + opened: boolean; + onClose: () => void; + onConfirm: () => Promise; + workspaceName: string | undefined; +} + +const DeleteWorkspaceModal: React.FC = ({ opened, onClose, onConfirm, From c478e8e8a1cfc2283a10542d1f5f79e162741b54 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Fri, 16 May 2025 22:57:47 +0200 Subject: [PATCH 24/55] Migrate account settings --- ...ccountSettings.jsx => AccountSettings.tsx} | 87 ++++++++++++------- ...oneSettings.jsx => DangerZoneSettings.tsx} | 10 +-- ...rofileSettings.jsx => ProfileSettings.tsx} | 15 +++- ...uritySettings.jsx => SecuritySettings.tsx} | 17 +++- app/src/types/settings.ts | 23 +++++ 5 files changed, 112 insertions(+), 40 deletions(-) rename app/src/components/settings/account/{AccountSettings.jsx => AccountSettings.tsx} (76%) rename app/src/components/settings/account/{DangerZoneSettings.jsx => DangerZoneSettings.tsx} (79%) rename app/src/components/settings/account/{ProfileSettings.jsx => ProfileSettings.tsx} (58%) rename app/src/components/settings/account/{SecuritySettings.jsx => SecuritySettings.tsx} (80%) create mode 100644 app/src/types/settings.ts diff --git a/app/src/components/settings/account/AccountSettings.jsx b/app/src/components/settings/account/AccountSettings.tsx similarity index 76% rename from app/src/components/settings/account/AccountSettings.jsx rename to app/src/components/settings/account/AccountSettings.tsx index 098240b..71b2731 100644 --- a/app/src/components/settings/account/AccountSettings.jsx +++ b/app/src/components/settings/account/AccountSettings.tsx @@ -16,24 +16,38 @@ import SecuritySettings from './SecuritySettings'; import ProfileSettings from './ProfileSettings'; import DangerZoneSettings from './DangerZoneSettings'; import AccordionControl from '../AccordionControl'; +import { + SettingsActionType, + UserProfileSettings, + ProfileSettingsState, + SettingsAction, +} from '../../../types/settings'; + +interface AccountSettingsProps { + opened: boolean; + onClose: () => void; +} // Reducer for managing settings state -const initialState = { +const initialState: ProfileSettingsState = { localSettings: {}, initialSettings: {}, hasUnsavedChanges: false, }; -function settingsReducer(state, action) { +function settingsReducer( + state: ProfileSettingsState, + action: SettingsAction +): ProfileSettingsState { switch (action.type) { - case 'INIT_SETTINGS': + case SettingsActionType.INIT_SETTINGS: return { ...state, - localSettings: action.payload, - initialSettings: action.payload, + localSettings: action.payload || {}, + initialSettings: action.payload || {}, hasUnsavedChanges: false, }; - case 'UPDATE_LOCAL_SETTINGS': + case SettingsActionType.UPDATE_LOCAL_SETTINGS: const newLocalSettings = { ...state.localSettings, ...action.payload }; const hasChanges = JSON.stringify(newLocalSettings) !== @@ -43,7 +57,7 @@ function settingsReducer(state, action) { localSettings: newLocalSettings, hasUnsavedChanges: hasChanges, }; - case 'MARK_SAVED': + case SettingsActionType.MARK_SAVED: return { ...state, initialSettings: state.localSettings, @@ -54,33 +68,45 @@ function settingsReducer(state, action) { } } -const AccountSettings = ({ opened, onClose }) => { +const AccountSettings: React.FC = ({ + opened, + onClose, +}) => { const { user, refreshUser } = useAuth(); const { loading, updateProfile } = useProfileSettings(); const [state, dispatch] = useReducer(settingsReducer, initialState); - const isInitialMount = useRef(true); - const [emailModalOpened, setEmailModalOpened] = useState(false); + const isInitialMount = useRef(true); + const [emailModalOpened, setEmailModalOpened] = useState(false); // Initialize settings on mount useEffect(() => { if (isInitialMount.current && user) { isInitialMount.current = false; - const settings = { + const settings: UserProfileSettings = { displayName: user.displayName, email: user.email, currentPassword: '', newPassword: '', }; - dispatch({ type: 'INIT_SETTINGS', payload: settings }); + dispatch({ + type: SettingsActionType.INIT_SETTINGS, + payload: settings, + }); } }, [user]); - const handleInputChange = (key, value) => { - dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); + const handleInputChange = ( + key: keyof UserProfileSettings, + value: string + ): void => { + dispatch({ + type: SettingsActionType.UPDATE_LOCAL_SETTINGS, + payload: { [key]: value } as UserProfileSettings, + }); }; - const handleSubmit = async () => { - const updates = {}; + const handleSubmit = async (): Promise => { + const updates: UserProfileSettings = {}; const needsPasswordConfirmation = state.localSettings.email !== state.initialSettings.email; @@ -113,10 +139,10 @@ const AccountSettings = ({ opened, onClose }) => { } } - const result = await updateProfile(updates); - if (result.success) { + const updatedUser = await updateProfile(updates); + if (updatedUser) { await refreshUser(); - dispatch({ type: 'MARK_SAVED' }); + dispatch({ type: SettingsActionType.MARK_SAVED }); onClose(); } } else { @@ -125,17 +151,20 @@ const AccountSettings = ({ opened, onClose }) => { } }; - const handleEmailConfirm = async (password) => { - const updates = { + const handleEmailConfirm = async (password: string): Promise => { + const updates: UserProfileSettings = { ...state.localSettings, currentPassword: password, }; + // Remove any undefined/empty values Object.keys(updates).forEach((key) => { - if (updates[key] === undefined || updates[key] === '') { - delete updates[key]; + const typedKey = key as keyof UserProfileSettings; + if (updates[typedKey] === undefined || updates[typedKey] === '') { + delete updates[typedKey]; } }); + // Remove keys that haven't changed if (updates.displayName === state.initialSettings.displayName) { delete updates.displayName; @@ -144,10 +173,10 @@ const AccountSettings = ({ opened, onClose }) => { delete updates.email; } - const result = await updateProfile(updates); - if (result.success) { + const updatedUser = await updateProfile(updates); + if (updatedUser) { await refreshUser(); - dispatch({ type: 'MARK_SAVED' }); + dispatch({ type: SettingsActionType.MARK_SAVED }); setEmailModalOpened(false); onClose(); } @@ -162,7 +191,7 @@ const AccountSettings = ({ opened, onClose }) => { centered size="lg" > - + {state.hasUnsavedChanges && ( Unsaved Changes @@ -172,7 +201,7 @@ const AccountSettings = ({ opened, onClose }) => { ({ + styles={(theme: any) => ({ control: { paddingTop: theme.spacing.md, paddingBottom: theme.spacing.md, @@ -239,7 +268,7 @@ const AccountSettings = ({ opened, onClose }) => { opened={emailModalOpened} onClose={() => setEmailModalOpened(false)} onConfirm={handleEmailConfirm} - email={state.localSettings.email} + email={state.localSettings.email || ''} /> ); diff --git a/app/src/components/settings/account/DangerZoneSettings.jsx b/app/src/components/settings/account/DangerZoneSettings.tsx similarity index 79% rename from app/src/components/settings/account/DangerZoneSettings.jsx rename to app/src/components/settings/account/DangerZoneSettings.tsx index 9fed1bc..c324c01 100644 --- a/app/src/components/settings/account/DangerZoneSettings.jsx +++ b/app/src/components/settings/account/DangerZoneSettings.tsx @@ -4,14 +4,14 @@ import DeleteAccountModal from '../../modals/account/DeleteAccountModal'; import { useAuth } from '../../../contexts/AuthContext'; import { useProfileSettings } from '../../../hooks/useProfileSettings'; -const DangerZoneSettings = () => { +const DangerZoneSettings: React.FC = () => { const { logout } = useAuth(); const { deleteAccount } = useProfileSettings(); - const [deleteModalOpened, setDeleteModalOpened] = useState(false); + const [deleteModalOpened, setDeleteModalOpened] = useState(false); - const handleDelete = async (password) => { - const result = await deleteAccount(password); - if (result.success) { + const handleDelete = async (password: string): Promise => { + const success = await deleteAccount(password); + if (success) { setDeleteModalOpened(false); logout(); } diff --git a/app/src/components/settings/account/ProfileSettings.jsx b/app/src/components/settings/account/ProfileSettings.tsx similarity index 58% rename from app/src/components/settings/account/ProfileSettings.jsx rename to app/src/components/settings/account/ProfileSettings.tsx index 687df65..c8434b6 100644 --- a/app/src/components/settings/account/ProfileSettings.jsx +++ b/app/src/components/settings/account/ProfileSettings.tsx @@ -1,9 +1,18 @@ import React from 'react'; import { Box, Stack, TextInput } from '@mantine/core'; +import { UserProfileSettings } from '../../../types/settings'; -const ProfileSettings = ({ settings, onInputChange }) => ( +interface ProfileSettingsProps { + settings: UserProfileSettings; + onInputChange: (key: keyof UserProfileSettings, value: string) => void; +} + +const ProfileSettingsComponent: React.FC = ({ + settings, + onInputChange, +}) => ( - + ( ); -export default ProfileSettings; +export default ProfileSettingsComponent; diff --git a/app/src/components/settings/account/SecuritySettings.jsx b/app/src/components/settings/account/SecuritySettings.tsx similarity index 80% rename from app/src/components/settings/account/SecuritySettings.jsx rename to app/src/components/settings/account/SecuritySettings.tsx index 3568ebc..c11b1e1 100644 --- a/app/src/components/settings/account/SecuritySettings.jsx +++ b/app/src/components/settings/account/SecuritySettings.tsx @@ -1,11 +1,22 @@ import React, { useState } from 'react'; import { Box, PasswordInput, Stack, Text } from '@mantine/core'; +import { UserProfileSettings } from '@/types/settings'; -const SecuritySettings = ({ settings, onInputChange }) => { +interface SecuritySettingsProps { + settings: UserProfileSettings; + onInputChange: (key: keyof UserProfileSettings, value: string) => void; +} + +type PasswordField = 'currentPassword' | 'newPassword' | 'confirmNewPassword'; + +const SecuritySettings: React.FC = ({ + settings, + onInputChange, +}) => { const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(''); - const handlePasswordChange = (field, value) => { + const handlePasswordChange = (field: PasswordField, value: string) => { if (field === 'confirmNewPassword') { setConfirmPassword(value); // Check if passwords match when either password field changes @@ -27,7 +38,7 @@ const SecuritySettings = ({ settings, onInputChange }) => { return ( - + { + type: SettingsActionType; + payload?: T; +} From 7044e42e947ad36010825debb67af71183ce939f Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 18 May 2025 11:53:17 +0200 Subject: [PATCH 25/55] Migrate admin dashboard to ts --- ...{AdminDashboard.jsx => AdminDashboard.tsx} | 18 ++++- .../{AdminStatsTab.jsx => AdminStatsTab.tsx} | 13 +++- .../{AdminUsersTab.jsx => AdminUsersTab.tsx} | 39 ++++++---- .../settings/admin/AdminWorkspacesTab.jsx | 67 ----------------- .../settings/admin/AdminWorkspacesTab.tsx | 73 +++++++++++++++++++ app/src/hooks/useAdminData.ts | 12 ++- 6 files changed, 126 insertions(+), 96 deletions(-) rename app/src/components/settings/admin/{AdminDashboard.jsx => AdminDashboard.tsx} (72%) rename app/src/components/settings/admin/{AdminStatsTab.jsx => AdminStatsTab.tsx} (84%) rename app/src/components/settings/admin/{AdminUsersTab.jsx => AdminUsersTab.tsx} (76%) delete mode 100644 app/src/components/settings/admin/AdminWorkspacesTab.jsx create mode 100644 app/src/components/settings/admin/AdminWorkspacesTab.tsx diff --git a/app/src/components/settings/admin/AdminDashboard.jsx b/app/src/components/settings/admin/AdminDashboard.tsx similarity index 72% rename from app/src/components/settings/admin/AdminDashboard.jsx rename to app/src/components/settings/admin/AdminDashboard.tsx index e095dc5..d3d1e00 100644 --- a/app/src/components/settings/admin/AdminDashboard.jsx +++ b/app/src/components/settings/admin/AdminDashboard.tsx @@ -6,13 +6,23 @@ import AdminUsersTab from './AdminUsersTab'; import AdminWorkspacesTab from './AdminWorkspacesTab'; import AdminStatsTab from './AdminStatsTab'; -const AdminDashboard = ({ opened, onClose }) => { +interface AdminDashboardProps { + opened: boolean; + onClose: () => void; +} + +type AdminTabValue = 'users' | 'workspaces' | 'stats'; + +const AdminDashboard: React.FC = ({ opened, onClose }) => { const { user: currentUser } = useAuth(); - const [activeTab, setActiveTab] = useState('users'); + const [activeTab, setActiveTab] = useState('users'); return ( - + setActiveTab(value as AdminTabValue)} + > }> Users @@ -26,7 +36,7 @@ const AdminDashboard = ({ opened, onClose }) => { - + {currentUser && } diff --git a/app/src/components/settings/admin/AdminStatsTab.jsx b/app/src/components/settings/admin/AdminStatsTab.tsx similarity index 84% rename from app/src/components/settings/admin/AdminStatsTab.jsx rename to app/src/components/settings/admin/AdminStatsTab.tsx index 3b7346f..f661223 100644 --- a/app/src/components/settings/admin/AdminStatsTab.jsx +++ b/app/src/components/settings/admin/AdminStatsTab.tsx @@ -4,8 +4,13 @@ import { IconAlertCircle } from '@tabler/icons-react'; import { useAdminData } from '../../../hooks/useAdminData'; import { formatBytes } from '../../../utils/formatBytes'; -const AdminStatsTab = () => { - const { data: stats, loading, error } = useAdminData('stats'); +interface StatsRow { + label: string; + value: string | number; +} + +const AdminStatsTab: React.FC = () => { + const { data: stats, loading, error } = useAdminData<'stats'>('stats'); if (loading) { return ; @@ -19,7 +24,7 @@ const AdminStatsTab = () => { ); } - const statsRows = [ + const statsRows: StatsRow[] = [ { label: 'Total Users', value: stats.totalUsers }, { label: 'Active Users', value: stats.activeUsers }, { label: 'Total Workspaces', value: stats.totalWorkspaces }, @@ -33,7 +38,7 @@ const AdminStatsTab = () => { System Statistics - +
Metric diff --git a/app/src/components/settings/admin/AdminUsersTab.jsx b/app/src/components/settings/admin/AdminUsersTab.tsx similarity index 76% rename from app/src/components/settings/admin/AdminUsersTab.jsx rename to app/src/components/settings/admin/AdminUsersTab.tsx index f6d5b99..a7e7558 100644 --- a/app/src/components/settings/admin/AdminUsersTab.jsx +++ b/app/src/components/settings/admin/AdminUsersTab.tsx @@ -20,8 +20,14 @@ import { useUserAdmin } from '../../../hooks/useUserAdmin'; import CreateUserModal from '../../modals/user/CreateUserModal'; import EditUserModal from '../../modals/user/EditUserModal'; import DeleteUserModal from '../../modals/user/DeleteUserModal'; +import { User } from '../../../types/authApi'; +import { CreateUserRequest, UpdateUserRequest } from '../../../types/adminApi'; -const AdminUsersTab = ({ currentUser }) => { +interface AdminUsersTabProps { + currentUser: User; +} + +const AdminUsersTab: React.FC = ({ currentUser }) => { const { users, loading, @@ -31,19 +37,24 @@ const AdminUsersTab = ({ currentUser }) => { delete: deleteUser, } = useUserAdmin(); - const [createModalOpened, setCreateModalOpened] = useState(false); - const [editModalData, setEditModalData] = useState(null); - const [deleteModalData, setDeleteModalData] = useState(null); + const [createModalOpened, setCreateModalOpened] = useState(false); + const [editModalData, setEditModalData] = useState(null); + const [deleteModalData, setDeleteModalData] = useState(null); - const handleCreateUser = async (userData) => { + const handleCreateUser = async ( + userData: CreateUserRequest + ): Promise => { return await create(userData); }; - const handleEditUser = async (id, userData) => { + const handleEditUser = async ( + id: number, + userData: UpdateUserRequest + ): Promise => { return await update(id, userData); }; - const handleDeleteClick = (user) => { + const handleDeleteClick = (user: User): void => { if (user.id === currentUser.id) { notifications.show({ title: 'Error', @@ -55,20 +66,20 @@ const AdminUsersTab = ({ currentUser }) => { setDeleteModalData(user); }; - const handleDeleteConfirm = async () => { + const handleDeleteConfirm = async (): Promise => { if (!deleteModalData) return; - const result = await deleteUser(deleteModalData.id); - if (result.success) { + const success = await deleteUser(deleteModalData.id); + if (success) { setDeleteModalData(null); } }; - const rows = users.map((user) => ( + const renderUserRow = (user: User) => ( {user.email} {user.displayName} - {user.role} + {user.role} {new Date(user.createdAt).toLocaleDateString()} @@ -91,7 +102,7 @@ const AdminUsersTab = ({ currentUser }) => { - )); + ); return ( @@ -130,7 +141,7 @@ const AdminUsersTab = ({ currentUser }) => { Actions - {rows} + {users.map(renderUserRow)}
{ - const { data: workspaces, loading, error } = useAdminData('workspaces'); - - const rows = workspaces.map((workspace) => ( - - {workspace.userEmail} - {workspace.workspaceName} - - {new Date(workspace.workspaceCreatedAt).toLocaleDateString()} - - {workspace.totalFiles} - {formatBytes(workspace.totalSize)} - - )); - - return ( - - - - {error && ( - } - title="Error" - color="red" - mb="md" - > - {error} - - )} - - - - Workspace Management - - - - - - - Owner - Name - Created At - Total Files - Total Size - - - {rows} -
-
- ); -}; - -export default AdminWorkspacesTab; diff --git a/app/src/components/settings/admin/AdminWorkspacesTab.tsx b/app/src/components/settings/admin/AdminWorkspacesTab.tsx new file mode 100644 index 0000000..5b64db3 --- /dev/null +++ b/app/src/components/settings/admin/AdminWorkspacesTab.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Table, Group, Text, Box, LoadingOverlay, Alert } from '@mantine/core'; +import { IconAlertCircle } from '@tabler/icons-react'; +import { useAdminData } from '../../../hooks/useAdminData'; +import { formatBytes } from '../../../utils/formatBytes'; +import { FileCountStats, WorkspaceStats } from '../../../types/adminApi'; + +const AdminWorkspacesTab: React.FC = () => { + const { + data: workspaces, + loading, + error, + } = useAdminData<'workspaces'>('workspaces'); + + const renderWorkspaceRow = (workspace: WorkspaceStats) => { + const fileStats: FileCountStats = workspace.fileCountStats || { + totalFiles: 0, + totalSize: 0, + }; + + return ( + + {workspace.userEmail} + {workspace.workspaceName} + + {new Date(workspace.workspaceCreatedAt).toLocaleDateString()} + + {fileStats.totalFiles} + {formatBytes(fileStats.totalSize)} + + ); + }; + + return ( + + + + {error && ( + } + title="Error" + color="red" + mb="md" + > + {error} + + )} + + + + Workspace Management + + + + + + + Owner + Name + Created At + Total Files + Total Size + + + + {!loading && !error && workspaces.map(renderWorkspaceRow)} + +
+
+ ); +}; + +export default AdminWorkspacesTab; diff --git a/app/src/hooks/useAdminData.ts b/app/src/hooks/useAdminData.ts index 36bb66c..4b85e1a 100644 --- a/app/src/hooks/useAdminData.ts +++ b/app/src/hooks/useAdminData.ts @@ -1,9 +1,7 @@ import { useState, useEffect } from 'react'; import { notifications } from '@mantine/notifications'; import { getUsers, getWorkspaces, getSystemStats } from '@/api/admin'; -import { SystemStats } from '@/types/adminApi'; -import { Workspace } from '@/types/workspace'; -import { User } from '@/types/authApi'; +import { SystemStats, UserStats, WorkspaceStats } from '@/types/adminApi'; // Possible types of admin data type AdminDataType = 'stats' | 'workspaces' | 'users'; @@ -12,9 +10,9 @@ type AdminDataType = 'stats' | 'workspaces' | 'users'; type AdminData = T extends 'stats' ? SystemStats : T extends 'workspaces' - ? Workspace[] + ? WorkspaceStats[] : T extends 'users' - ? User[] + ? UserStats[] : never; // Define the return type of the hook @@ -34,9 +32,9 @@ export const useAdminData = ( if (type === 'stats') { return {} as SystemStats as AdminData; } else if (type === 'workspaces') { - return [] as Workspace[] as AdminData; + return [] as WorkspaceStats[] as AdminData; } else if (type === 'users') { - return [] as User[] as AdminData; + return [] as UserStats[] as AdminData; } else { // This case should never happen due to type constraints, // but TypeScript requires us to handle it From 9125cbdad311f18e267a17bfd385c98782fac9f3 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 18 May 2025 12:21:18 +0200 Subject: [PATCH 26/55] Migrate workspace settings to ts --- ...ordionControl.jsx => AccordionControl.tsx} | 6 +- ...nceSettings.jsx => AppearanceSettings.tsx} | 17 +++- ...oneSettings.jsx => DangerZoneSettings.tsx} | 6 +- ...{EditorSettings.jsx => EditorSettings.tsx} | 9 +- ...eneralSettings.jsx => GeneralSettings.tsx} | 15 +++- .../{GitSettings.jsx => GitSettings.tsx} | 17 +++- ...paceSettings.jsx => WorkspaceSettings.tsx} | 90 ++++++++++++------- app/src/types/workspace.ts | 6 ++ 8 files changed, 118 insertions(+), 48 deletions(-) rename app/src/components/settings/{AccordionControl.jsx => AccordionControl.tsx} (58%) rename app/src/components/settings/workspace/{AppearanceSettings.jsx => AppearanceSettings.tsx} (54%) rename app/src/components/settings/workspace/{DangerZoneSettings.jsx => DangerZoneSettings.tsx} (87%) rename app/src/components/settings/workspace/{EditorSettings.jsx => EditorSettings.tsx} (79%) rename app/src/components/settings/workspace/{GeneralSettings.jsx => GeneralSettings.tsx} (60%) rename app/src/components/settings/workspace/{GitSettings.jsx => GitSettings.tsx} (91%) rename app/src/components/settings/workspace/{WorkspaceSettings.jsx => WorkspaceSettings.tsx} (73%) diff --git a/app/src/components/settings/AccordionControl.jsx b/app/src/components/settings/AccordionControl.tsx similarity index 58% rename from app/src/components/settings/AccordionControl.jsx rename to app/src/components/settings/AccordionControl.tsx index 03cd02e..b366554 100644 --- a/app/src/components/settings/AccordionControl.jsx +++ b/app/src/components/settings/AccordionControl.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { Accordion, Title } from '@mantine/core'; -const AccordionControl = ({ children }) => ( +interface AccordionControlProps { + children: React.ReactNode; +} + +const AccordionControl: React.FC = ({ children }) => ( {children} diff --git a/app/src/components/settings/workspace/AppearanceSettings.jsx b/app/src/components/settings/workspace/AppearanceSettings.tsx similarity index 54% rename from app/src/components/settings/workspace/AppearanceSettings.jsx rename to app/src/components/settings/workspace/AppearanceSettings.tsx index 79e2055..a695c5b 100644 --- a/app/src/components/settings/workspace/AppearanceSettings.jsx +++ b/app/src/components/settings/workspace/AppearanceSettings.tsx @@ -1,12 +1,21 @@ import React from 'react'; -import { Text, Switch, Group, Box, Title } from '@mantine/core'; +import { Text, Switch, Group, Box } from '@mantine/core'; import { useWorkspace } from '../../../contexts/WorkspaceContext'; +import { Theme } from '@/types/theme'; -const AppearanceSettings = ({ themeSettings, onThemeChange }) => { +interface AppearanceSettingsProps { + themeSettings?: Theme; + onThemeChange: (newTheme: Theme) => void; +} + +const AppearanceSettings: React.FC = ({ + themeSettings, + onThemeChange, +}) => { const { colorScheme, updateColorScheme } = useWorkspace(); - const handleThemeChange = () => { - const newTheme = colorScheme === 'dark' ? 'light' : 'dark'; + const handleThemeChange = (): void => { + const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark; updateColorScheme(newTheme); onThemeChange(newTheme); }; diff --git a/app/src/components/settings/workspace/DangerZoneSettings.jsx b/app/src/components/settings/workspace/DangerZoneSettings.tsx similarity index 87% rename from app/src/components/settings/workspace/DangerZoneSettings.jsx rename to app/src/components/settings/workspace/DangerZoneSettings.tsx index dbc0e30..94a9f3d 100644 --- a/app/src/components/settings/workspace/DangerZoneSettings.jsx +++ b/app/src/components/settings/workspace/DangerZoneSettings.tsx @@ -4,13 +4,13 @@ import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal'; import { useWorkspace } from '../../../contexts/WorkspaceContext'; import { useModalContext } from '../../../contexts/ModalContext'; -const DangerZoneSettings = () => { +const DangerZoneSettings: React.FC = () => { const { currentWorkspace, workspaces, deleteCurrentWorkspace } = useWorkspace(); const { setSettingsModalVisible } = useModalContext(); - const [deleteModalOpened, setDeleteModalOpened] = useState(false); + const [deleteModalOpened, setDeleteModalOpened] = useState(false); - const handleDelete = async () => { + const handleDelete = async (): Promise => { await deleteCurrentWorkspace(); setDeleteModalOpened(false); setSettingsModalVisible(false); diff --git a/app/src/components/settings/workspace/EditorSettings.jsx b/app/src/components/settings/workspace/EditorSettings.tsx similarity index 79% rename from app/src/components/settings/workspace/EditorSettings.jsx rename to app/src/components/settings/workspace/EditorSettings.tsx index 13a76d2..62b58f9 100644 --- a/app/src/components/settings/workspace/EditorSettings.jsx +++ b/app/src/components/settings/workspace/EditorSettings.tsx @@ -1,7 +1,14 @@ import React from 'react'; import { Text, Switch, Tooltip, Group, Box } from '@mantine/core'; -const EditorSettings = ({ +interface EditorSettingsProps { + autoSave: boolean; + showHiddenFiles: boolean; + onAutoSaveChange: (value: boolean) => void; + onShowHiddenFilesChange: (value: boolean) => void; +} + +const EditorSettings: React.FC = ({ autoSave, showHiddenFiles, onAutoSaveChange, diff --git a/app/src/components/settings/workspace/GeneralSettings.jsx b/app/src/components/settings/workspace/GeneralSettings.tsx similarity index 60% rename from app/src/components/settings/workspace/GeneralSettings.jsx rename to app/src/components/settings/workspace/GeneralSettings.tsx index 0ada3e9..83b4a8a 100644 --- a/app/src/components/settings/workspace/GeneralSettings.jsx +++ b/app/src/components/settings/workspace/GeneralSettings.tsx @@ -1,7 +1,16 @@ import React from 'react'; -import { Title, Box, TextInput, Text, Grid } from '@mantine/core'; +import { Box, TextInput, Text, Grid } from '@mantine/core'; +import { Workspace } from '@/types/workspace'; -const GeneralSettings = ({ name, onInputChange }) => { +interface GeneralSettingsProps { + name: string; + onInputChange: (key: keyof Workspace, value: string) => void; +} + +const GeneralSettings: React.FC = ({ + name, + onInputChange, +}) => { return ( @@ -10,7 +19,7 @@ const GeneralSettings = ({ name, onInputChange }) => { onInputChange('name', event.currentTarget.value) } diff --git a/app/src/components/settings/workspace/GitSettings.jsx b/app/src/components/settings/workspace/GitSettings.tsx similarity index 91% rename from app/src/components/settings/workspace/GitSettings.jsx rename to app/src/components/settings/workspace/GitSettings.tsx index 1c5867c..214beb5 100644 --- a/app/src/components/settings/workspace/GitSettings.jsx +++ b/app/src/components/settings/workspace/GitSettings.tsx @@ -8,8 +8,21 @@ import { Group, Grid, } from '@mantine/core'; +import { Workspace } from '@/types/workspace'; -const GitSettings = ({ +interface GitSettingsProps { + gitEnabled: boolean; + gitUrl: string; + gitUser: string; + gitToken: string; + gitAutoCommit: boolean; + gitCommitMsgTemplate: string; + gitCommitName: string; + gitCommitEmail: string; + onInputChange: (key: keyof Workspace, value: any) => void; +} + +const GitSettings: React.FC = ({ gitEnabled, gitUrl, gitUser, @@ -21,7 +34,7 @@ const GitSettings = ({ onInputChange, }) => { return ( - + Enable Git Repository diff --git a/app/src/components/settings/workspace/WorkspaceSettings.jsx b/app/src/components/settings/workspace/WorkspaceSettings.tsx similarity index 73% rename from app/src/components/settings/workspace/WorkspaceSettings.jsx rename to app/src/components/settings/workspace/WorkspaceSettings.tsx index 34bc7b8..d5d00a6 100644 --- a/app/src/components/settings/workspace/WorkspaceSettings.jsx +++ b/app/src/components/settings/workspace/WorkspaceSettings.tsx @@ -17,23 +17,35 @@ import GeneralSettings from './GeneralSettings'; import { useModalContext } from '../../../contexts/ModalContext'; import DangerZoneSettings from './DangerZoneSettings'; import AccordionControl from '../AccordionControl'; +import { SettingsActionType, SettingsAction } from '../../../types/settings'; +import { Workspace } from '../../../types/workspace'; -const initialState = { +// State and reducer for workspace settings +interface WorkspaceSettingsState { + localSettings: Partial; + initialSettings: Partial; + hasUnsavedChanges: boolean; +} + +const initialState: WorkspaceSettingsState = { localSettings: {}, initialSettings: {}, hasUnsavedChanges: false, }; -function settingsReducer(state, action) { +function settingsReducer( + state: WorkspaceSettingsState, + action: SettingsAction> +): WorkspaceSettingsState { switch (action.type) { - case 'INIT_SETTINGS': + case SettingsActionType.INIT_SETTINGS: return { ...state, - localSettings: action.payload, - initialSettings: action.payload, + localSettings: action.payload || {}, + initialSettings: action.payload || {}, hasUnsavedChanges: false, }; - case 'UPDATE_LOCAL_SETTINGS': + case SettingsActionType.UPDATE_LOCAL_SETTINGS: const newLocalSettings = { ...state.localSettings, ...action.payload }; const hasChanges = JSON.stringify(newLocalSettings) !== @@ -43,7 +55,7 @@ function settingsReducer(state, action) { localSettings: newLocalSettings, hasUnsavedChanges: hasChanges, }; - case 'MARK_SAVED': + case SettingsActionType.MARK_SAVED: return { ...state, initialSettings: state.localSettings, @@ -54,16 +66,16 @@ function settingsReducer(state, action) { } } -const WorkspaceSettings = () => { +const WorkspaceSettings: React.FC = () => { const { currentWorkspace, updateSettings } = useWorkspace(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const [state, dispatch] = useReducer(settingsReducer, initialState); - const isInitialMount = useRef(true); + const isInitialMount = useRef(true); useEffect(() => { - if (isInitialMount.current) { + if (isInitialMount.current && currentWorkspace) { isInitialMount.current = false; - const settings = { + const settings: Partial = { name: currentWorkspace.name, theme: currentWorkspace.theme, autoSave: currentWorkspace.autoSave, @@ -77,15 +89,21 @@ const WorkspaceSettings = () => { gitCommitName: currentWorkspace.gitCommitName, gitCommitEmail: currentWorkspace.gitCommitEmail, }; - dispatch({ type: 'INIT_SETTINGS', payload: settings }); + dispatch({ type: SettingsActionType.INIT_SETTINGS, payload: settings }); } }, [currentWorkspace]); - const handleInputChange = useCallback((key, value) => { - dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); - }, []); + const handleInputChange = useCallback( + (key: keyof Workspace, value: any): void => { + dispatch({ + type: SettingsActionType.UPDATE_LOCAL_SETTINGS, + payload: { [key]: value } as Partial, + }); + }, + [] + ); - const handleSubmit = async () => { + const handleSubmit = async (): Promise => { try { if (!state.localSettings.name?.trim()) { notifications.show({ @@ -96,7 +114,7 @@ const WorkspaceSettings = () => { } await updateSettings(state.localSettings); - dispatch({ type: 'MARK_SAVED' }); + dispatch({ type: SettingsActionType.MARK_SAVED }); notifications.show({ message: 'Settings saved successfully', color: 'green', @@ -105,7 +123,9 @@ const WorkspaceSettings = () => { } catch (error) { console.error('Failed to save settings:', error); notifications.show({ - message: 'Failed to save settings: ' + error.message, + message: + 'Failed to save settings: ' + + (error instanceof Error ? error.message : String(error)), color: 'red', }); } @@ -123,7 +143,7 @@ const WorkspaceSettings = () => { centered size="lg" > - + {state.hasUnsavedChanges && ( Unsaved Changes @@ -133,7 +153,7 @@ const WorkspaceSettings = () => { ({ + styles={(theme: any) => ({ control: { paddingTop: theme.spacing.md, paddingBottom: theme.spacing.md, @@ -162,7 +182,7 @@ const WorkspaceSettings = () => { General @@ -173,7 +193,7 @@ const WorkspaceSettings = () => { + onThemeChange={(newTheme: string) => handleInputChange('theme', newTheme) } /> @@ -184,12 +204,12 @@ const WorkspaceSettings = () => { Editor + autoSave={state.localSettings.autoSave || false} + onAutoSaveChange={(value: boolean) => handleInputChange('autoSave', value) } - showHiddenFiles={state.localSettings.showHiddenFiles} - onShowHiddenFilesChange={(value) => + showHiddenFiles={state.localSettings.showHiddenFiles || false} + onShowHiddenFilesChange={(value: boolean) => handleInputChange('showHiddenFiles', value) } /> @@ -200,14 +220,16 @@ const WorkspaceSettings = () => { Git Integration diff --git a/app/src/types/workspace.ts b/app/src/types/workspace.ts index 15ec4f2..83f780a 100644 --- a/app/src/types/workspace.ts +++ b/app/src/types/workspace.ts @@ -3,23 +3,29 @@ import { Theme } from './theme'; export interface WorkspaceSettings { theme: Theme; autoSave: boolean; + showHiddenFiles: boolean; gitEnabled: boolean; gitUrl: string; gitUser: string; gitToken: string; gitAutoCommit: boolean; gitCommitMsgTemplate: string; + gitCommitName: string; + gitCommitEmail: string; } export const DEFAULT_WORKSPACE_SETTINGS: WorkspaceSettings = { theme: Theme.Light, autoSave: false, + showHiddenFiles: false, gitEnabled: false, gitUrl: '', gitUser: '', gitToken: '', gitAutoCommit: false, gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', }; export interface Workspace extends WorkspaceSettings { From a8a525531e1346f4bc58bb1d1d76e57ba855b6f1 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 18 May 2025 12:33:42 +0200 Subject: [PATCH 27/55] Migrate navigation to ts --- .../navigation/{UserMenu.jsx => UserMenu.tsx} | 22 +++++++------ ...paceSwitcher.jsx => WorkspaceSwitcher.tsx} | 31 +++++++++++-------- app/src/types/workspace.ts | 2 ++ 3 files changed, 32 insertions(+), 23 deletions(-) rename app/src/components/navigation/{UserMenu.jsx => UserMenu.tsx} (87%) rename app/src/components/navigation/{WorkspaceSwitcher.jsx => WorkspaceSwitcher.tsx} (86%) diff --git a/app/src/components/navigation/UserMenu.jsx b/app/src/components/navigation/UserMenu.tsx similarity index 87% rename from app/src/components/navigation/UserMenu.jsx rename to app/src/components/navigation/UserMenu.tsx index 0002eb0..1376792 100644 --- a/app/src/components/navigation/UserMenu.jsx +++ b/app/src/components/navigation/UserMenu.tsx @@ -18,13 +18,15 @@ import { useAuth } from '../../contexts/AuthContext'; import AccountSettings from '../settings/account/AccountSettings'; import AdminDashboard from '../settings/admin/AdminDashboard'; -const UserMenu = () => { - const [accountSettingsOpened, setAccountSettingsOpened] = useState(false); - const [adminDashboardOpened, setAdminDashboardOpened] = useState(false); - const [opened, setOpened] = useState(false); +const UserMenu: React.FC = () => { + const [accountSettingsOpened, setAccountSettingsOpened] = + useState(false); + const [adminDashboardOpened, setAdminDashboardOpened] = + useState(false); + const [opened, setOpened] = useState(false); const { user, logout } = useAuth(); - const handleLogout = () => { + const handleLogout = (): void => { logout(); }; @@ -57,7 +59,7 @@ const UserMenu = () => {
- {user.displayName || user.email} + {user?.displayName || user?.email}
@@ -72,7 +74,7 @@ const UserMenu = () => { }} px="sm" py="xs" - style={(theme) => ({ + style={(theme: any) => ({ borderRadius: theme.radius.sm, '&:hover': { backgroundColor: @@ -88,7 +90,7 @@ const UserMenu = () => { - {user.role === 'admin' && ( + {user?.role === 'admin' && ( { setAdminDashboardOpened(true); @@ -96,7 +98,7 @@ const UserMenu = () => { }} px="sm" py="xs" - style={(theme) => ({ + style={(theme: any) => ({ borderRadius: theme.radius.sm, '&:hover': { backgroundColor: @@ -118,7 +120,7 @@ const UserMenu = () => { px="sm" py="xs" color="red" - style={(theme) => ({ + style={(theme: any) => ({ borderRadius: theme.radius.sm, '&:hover': { backgroundColor: diff --git a/app/src/components/navigation/WorkspaceSwitcher.jsx b/app/src/components/navigation/WorkspaceSwitcher.tsx similarity index 86% rename from app/src/components/navigation/WorkspaceSwitcher.jsx rename to app/src/components/navigation/WorkspaceSwitcher.tsx index 6a0fd2a..1586294 100644 --- a/app/src/components/navigation/WorkspaceSwitcher.jsx +++ b/app/src/components/navigation/WorkspaceSwitcher.tsx @@ -17,19 +17,20 @@ import { import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useModalContext } from '../../contexts/ModalContext'; -import { listWorkspaces } from '../../api/git'; +import { listWorkspaces } from '../../api/workspace'; import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal'; +import { Workspace } from '../../types/workspace'; -const WorkspaceSwitcher = () => { +const WorkspaceSwitcher: React.FC = () => { const { currentWorkspace, switchWorkspace } = useWorkspace(); const { setSettingsModalVisible, setCreateWorkspaceModalVisible } = useModalContext(); - const [workspaces, setWorkspaces] = useState([]); - const [loading, setLoading] = useState(false); - const [popoverOpened, setPopoverOpened] = useState(false); + const [workspaces, setWorkspaces] = useState([]); + const [loading, setLoading] = useState(false); + const [popoverOpened, setPopoverOpened] = useState(false); const theme = useMantineTheme(); - const loadWorkspaces = async () => { + const loadWorkspaces = async (): Promise => { setLoading(true); try { const list = await listWorkspaces(); @@ -40,12 +41,14 @@ const WorkspaceSwitcher = () => { setLoading(false); }; - const handleCreateWorkspace = () => { + const handleCreateWorkspace = (): void => { setPopoverOpened(false); setCreateWorkspaceModalVisible(true); }; - const handleWorkspaceCreated = async (newWorkspace) => { + const handleWorkspaceCreated = async ( + newWorkspace: Workspace + ): Promise => { await loadWorkspaces(); switchWorkspace(newWorkspace.name); }; @@ -108,7 +111,7 @@ const WorkspaceSwitcher = () => { key={workspace.name} p="xs" withBorder - style={{ + style={(theme: any) => ({ backgroundColor: isSelected ? theme.colors.blue[ theme.colorScheme === 'dark' ? 8 : 1 @@ -119,7 +122,7 @@ const WorkspaceSwitcher = () => { theme.colorScheme === 'dark' ? 7 : 5 ] : undefined, - }} + })} > { c={ isSelected ? theme.colors.blue[ - theme.colorScheme === 'dark' ? 0 : 9 + (theme as any).colorScheme === 'dark' + ? 0 + : 9 ] : undefined } @@ -148,7 +153,7 @@ const WorkspaceSwitcher = () => { size="xs" c={ isSelected - ? theme.colorScheme === 'dark' + ? (theme as any).colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7] : 'dimmed' @@ -166,7 +171,7 @@ const WorkspaceSwitcher = () => { variant="subtle" size="lg" color={ - theme.colorScheme === 'dark' + (theme as any).colorScheme === 'dark' ? 'blue.2' : 'blue.7' } diff --git a/app/src/types/workspace.ts b/app/src/types/workspace.ts index 83f780a..4d8f359 100644 --- a/app/src/types/workspace.ts +++ b/app/src/types/workspace.ts @@ -30,10 +30,12 @@ export const DEFAULT_WORKSPACE_SETTINGS: WorkspaceSettings = { export interface Workspace extends WorkspaceSettings { name: string; + createdAt: number; } export const DEFAULT_WORKSPACE: Workspace = { name: '', + createdAt: Date.now(), ...DEFAULT_WORKSPACE_SETTINGS, }; From 834a7b1e7edd15d5ecd7ba36857538248297cea4 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 18 May 2025 13:08:03 +0200 Subject: [PATCH 28/55] Migrate layout --- .../layout/{Header.jsx => Header.tsx} | 2 +- .../layout/{Layout.jsx => Layout.tsx} | 2 +- .../{MainContent.jsx => MainContent.tsx} | 42 ++++++++++++------- .../layout/{Sidebar.jsx => Sidebar.tsx} | 19 +++++++-- app/src/hooks/useGitOperations.ts | 10 ++--- 5 files changed, 50 insertions(+), 25 deletions(-) rename app/src/components/layout/{Header.jsx => Header.tsx} (94%) rename app/src/components/layout/{Layout.jsx => Layout.tsx} (98%) rename app/src/components/layout/{MainContent.jsx => MainContent.tsx} (76%) rename app/src/components/layout/{Sidebar.jsx => Sidebar.tsx} (66%) diff --git a/app/src/components/layout/Header.jsx b/app/src/components/layout/Header.tsx similarity index 94% rename from app/src/components/layout/Header.jsx rename to app/src/components/layout/Header.tsx index 95fa001..8a73724 100644 --- a/app/src/components/layout/Header.jsx +++ b/app/src/components/layout/Header.tsx @@ -4,7 +4,7 @@ import UserMenu from '../navigation/UserMenu'; import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher'; import WorkspaceSettings from '../settings/workspace/WorkspaceSettings'; -const Header = () => { +const Header: React.FC = () => { return ( diff --git a/app/src/components/layout/Layout.jsx b/app/src/components/layout/Layout.tsx similarity index 98% rename from app/src/components/layout/Layout.jsx rename to app/src/components/layout/Layout.tsx index 1b5094f..1e76d13 100644 --- a/app/src/components/layout/Layout.jsx +++ b/app/src/components/layout/Layout.tsx @@ -7,7 +7,7 @@ import { useFileNavigation } from '../../hooks/useFileNavigation'; import { useFileList } from '../../hooks/useFileList'; import { useWorkspace } from '../../contexts/WorkspaceContext'; -const Layout = () => { +const Layout: React.FC = () => { const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); const { selectedFile, handleFileSelect } = useFileNavigation(); const { files, loadFileList } = useFileList(); diff --git a/app/src/components/layout/MainContent.jsx b/app/src/components/layout/MainContent.tsx similarity index 76% rename from app/src/components/layout/MainContent.jsx rename to app/src/components/layout/MainContent.tsx index fe6d760..a9366ae 100644 --- a/app/src/components/layout/MainContent.jsx +++ b/app/src/components/layout/MainContent.tsx @@ -10,11 +10,21 @@ import CommitMessageModal from '../modals/git/CommitMessageModal'; import { useFileContent } from '../../hooks/useFileContent'; import { useFileOperations } from '../../hooks/useFileOperations'; import { useGitOperations } from '../../hooks/useGitOperations'; -import { useWorkspace } from '../../contexts/WorkspaceContext'; -const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => { - const [activeTab, setActiveTab] = useState('source'); - const { settings } = useWorkspace(); +type ViewTab = 'source' | 'preview'; + +interface MainContentProps { + selectedFile: string | null; + handleFileSelect: (filePath: string | null) => Promise; + loadFileList: () => Promise; +} + +const MainContent: React.FC = ({ + selectedFile, + handleFileSelect, + loadFileList, +}) => { + const [activeTab, setActiveTab] = useState('source'); const { content, hasUnsavedChanges, @@ -22,15 +32,17 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => { handleContentChange, } = useFileContent(selectedFile); const { handleSave, handleCreate, handleDelete } = useFileOperations(); - const { handleCommitAndPush } = useGitOperations(settings.gitEnabled); + const { handleCommitAndPush } = useGitOperations(); - const handleTabChange = useCallback((value) => { - setActiveTab(value); + const handleTabChange = useCallback((value: string | null): void => { + if (value) { + setActiveTab(value as ViewTab); + } }, []); const handleSaveFile = useCallback( - async (filePath, content) => { - let success = await handleSave(filePath, content); + async (filePath: string, fileContent: string): Promise => { + let success = await handleSave(filePath, fileContent); if (success) { setHasUnsavedChanges(false); } @@ -40,22 +52,22 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => { ); const handleCreateFile = useCallback( - async (fileName) => { + async (fileName: string): Promise => { const success = await handleCreate(fileName); if (success) { - loadFileList(); - handleFileSelect(fileName); + await loadFileList(); + await handleFileSelect(fileName); } }, [handleCreate, handleFileSelect, loadFileList] ); const handleDeleteFile = useCallback( - async (filePath) => { + async (filePath: string): Promise => { const success = await handleDelete(filePath); if (success) { - loadFileList(); - handleFileSelect(null); + await loadFileList(); + await handleFileSelect(null); } }, [handleDelete, handleFileSelect, loadFileList] diff --git a/app/src/components/layout/Sidebar.jsx b/app/src/components/layout/Sidebar.tsx similarity index 66% rename from app/src/components/layout/Sidebar.jsx rename to app/src/components/layout/Sidebar.tsx index 4de51fa..cada4d4 100644 --- a/app/src/components/layout/Sidebar.jsx +++ b/app/src/components/layout/Sidebar.tsx @@ -4,10 +4,23 @@ import FileActions from '../files/FileActions'; import FileTree from '../files/FileTree'; import { useGitOperations } from '../../hooks/useGitOperations'; import { useWorkspace } from '../../contexts/WorkspaceContext'; +import { FileNode } from '@/types/fileApi'; -const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => { +interface SidebarProps { + selectedFile: string | null; + handleFileSelect: (filePath: string | null) => Promise; + files: FileNode[]; + loadFileList: () => Promise; +} + +const Sidebar: React.FC = ({ + selectedFile, + handleFileSelect, + files, + loadFileList, +}) => { const { settings } = useWorkspace(); - const { handlePull } = useGitOperations(settings.gitEnabled); + const { handlePull } = useGitOperations(); useEffect(() => { loadFileList(); @@ -28,7 +41,7 @@ const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => { ); diff --git a/app/src/hooks/useGitOperations.ts b/app/src/hooks/useGitOperations.ts index 426c3f7..d2abee0 100644 --- a/app/src/hooks/useGitOperations.ts +++ b/app/src/hooks/useGitOperations.ts @@ -5,7 +5,7 @@ import { useWorkspace } from '../contexts/WorkspaceContext'; interface UseGitOperationsResult { handlePull: () => Promise; - handleCommitAndPush: (message: string) => Promise; + handleCommitAndPush: (message: string) => Promise; } export const useGitOperations = (): UseGitOperationsResult => { @@ -34,8 +34,8 @@ export const useGitOperations = (): UseGitOperationsResult => { }, [currentWorkspace, settings.gitEnabled]); const handleCommitAndPush = useCallback( - async (message: string): Promise => { - if (!currentWorkspace || !settings.gitEnabled) return false; + async (message: string): Promise => { + if (!currentWorkspace || !settings.gitEnabled) return; try { const commitHash: CommitHash = await commitAndPush( @@ -47,7 +47,7 @@ export const useGitOperations = (): UseGitOperationsResult => { message: 'Successfully committed and pushed changes', color: 'green', }); - return true; + return; } catch (error) { console.error('Failed to commit and push changes:', error); notifications.show({ @@ -55,7 +55,7 @@ export const useGitOperations = (): UseGitOperationsResult => { message: 'Failed to commit and push changes', color: 'red', }); - return false; + return; } }, [currentWorkspace, settings.gitEnabled] From db75bdcc89832bad469ee8fe2be3c81ad3750cf9 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 18 May 2025 14:39:17 +0200 Subject: [PATCH 29/55] Migrate file components --- .../{FileActions.jsx => FileActions.tsx} | 16 +++- .../files/{FileTree.jsx => FileTree.tsx} | 79 +++++++++++++------ 2 files changed, 67 insertions(+), 28 deletions(-) rename app/src/components/files/{FileActions.jsx => FileActions.tsx} (82%) rename app/src/components/files/{FileTree.jsx => FileTree.tsx} (53%) diff --git a/app/src/components/files/FileActions.jsx b/app/src/components/files/FileActions.tsx similarity index 82% rename from app/src/components/files/FileActions.jsx rename to app/src/components/files/FileActions.tsx index d8b4f18..b846c9d 100644 --- a/app/src/components/files/FileActions.jsx +++ b/app/src/components/files/FileActions.tsx @@ -9,7 +9,15 @@ import { import { useModalContext } from '../../contexts/ModalContext'; import { useWorkspace } from '../../contexts/WorkspaceContext'; -const FileActions = ({ handlePullChanges, selectedFile }) => { +interface FileActionsProps { + handlePullChanges: () => Promise; + selectedFile: string | null; +} + +const FileActions: React.FC = ({ + handlePullChanges, + selectedFile, +}) => { const { settings } = useWorkspace(); const { setNewFileModalVisible, @@ -17,9 +25,9 @@ const FileActions = ({ handlePullChanges, selectedFile }) => { setCommitMessageModalVisible, } = useModalContext(); - const handleCreateFile = () => setNewFileModalVisible(true); - const handleDeleteFile = () => setDeleteFileModalVisible(true); - const handleCommitAndPush = () => setCommitMessageModalVisible(true); + const handleCreateFile = (): void => setNewFileModalVisible(true); + const handleDeleteFile = (): void => setDeleteFileModalVisible(true); + const handleCommitAndPush = (): void => setCommitMessageModalVisible(true); return ( diff --git a/app/src/components/files/FileTree.jsx b/app/src/components/files/FileTree.tsx similarity index 53% rename from app/src/components/files/FileTree.jsx rename to app/src/components/files/FileTree.tsx index 280ce31..ec5c4fe 100644 --- a/app/src/components/files/FileTree.jsx +++ b/app/src/components/files/FileTree.tsx @@ -1,21 +1,35 @@ import React, { useRef, useState, useLayoutEffect } from 'react'; -import { Tree } from 'react-arborist'; +import { Tree, NodeApi } from 'react-arborist'; import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react'; import { Tooltip } from '@mantine/core'; import useResizeObserver from '@react-hook/resize-observer'; +import { FileNode } from '../../types/fileApi'; -const useSize = (target) => { - const [size, setSize] = useState(); +interface Size { + width: number; + height: number; +} + +interface FileTreeProps { + files: FileNode[]; + handleFileSelect: (filePath: string | null) => Promise; + showHiddenFiles: boolean; +} + +const useSize = (target: React.RefObject): Size | undefined => { + const [size, setSize] = useState(); useLayoutEffect(() => { - setSize(target.current.getBoundingClientRect()); + if (target.current) { + setSize(target.current.getBoundingClientRect()); + } }, [target]); useResizeObserver(target, (entry) => setSize(entry.contentRect)); return size; }; -const FileIcon = ({ node }) => { +const FileIcon = ({ node }: { node: NodeApi }) => { if (node.isLeaf) { return ; } @@ -26,7 +40,21 @@ const FileIcon = ({ node }) => { ); }; -const Node = ({ node, style, dragHandle }) => { +// Define a Node component that matches what React-Arborist expects +function Node(props: any) { + const { node, style, dragHandle } = props; + + const handleClick = () => { + if (node.isInternal) { + node.toggle(); + } else { + const treeProps = node.tree.props as any; + if (typeof treeProps.onNodeClick === 'function') { + treeProps.onNodeClick(node); + } + } + }; + return (
{ whiteSpace: 'nowrap', overflow: 'hidden', }} - onClick={() => { - if (node.isInternal) { - node.toggle(); - } else { - node.tree.props.onNodeClick(node); - } - }} + onClick={handleClick} > {
); -}; +} -const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => { - const target = useRef(null); +const FileTree: React.FC = ({ + files, + handleFileSelect, + showHiddenFiles, +}) => { + const target = useRef(null); const size = useSize(target); - files = files.filter((file) => { + const filteredFiles = files.filter((file) => { if (file.name.startsWith('.') && !showHiddenFiles) { return false; } @@ -83,22 +109,27 @@ const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => { > {size && ( { + const fileNode = node.data as FileNode; if (!node.isInternal) { - handleFileSelect(node.data.path); - } - }} - onNodeClick={(node) => { - if (!node.isInternal) { - handleFileSelect(node.data.path); + handleFileSelect(fileNode.path); } }} + {...({ + // Use a spread with type assertion to add onNodeClick + onNodeClick: (node: NodeApi) => { + const fileNode = node.data as FileNode; + if (!node.isInternal) { + handleFileSelect(fileNode.path); + } + }, + } as any)} > {Node} From bfc5cc2d296c4f38e649975a60dc7b36492e13d3 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 18 May 2025 15:19:58 +0200 Subject: [PATCH 30/55] Migrate edito components --- .../{ContentView.jsx => ContentView.tsx} | 32 +++- .../editor/{Editor.jsx => Editor.tsx} | 25 +++- app/src/components/editor/MarkdownPreview.jsx | 111 -------------- app/src/components/editor/MarkdownPreview.tsx | 141 ++++++++++++++++++ app/src/utils/fileHelpers.ts | 7 + 5 files changed, 195 insertions(+), 121 deletions(-) rename app/src/components/editor/{ContentView.jsx => ContentView.tsx} (54%) rename app/src/components/editor/{Editor.jsx => Editor.tsx} (78%) delete mode 100644 app/src/components/editor/MarkdownPreview.jsx create mode 100644 app/src/components/editor/MarkdownPreview.tsx diff --git a/app/src/components/editor/ContentView.jsx b/app/src/components/editor/ContentView.tsx similarity index 54% rename from app/src/components/editor/ContentView.jsx rename to app/src/components/editor/ContentView.tsx index 5235339..4d43b14 100644 --- a/app/src/components/editor/ContentView.jsx +++ b/app/src/components/editor/ContentView.tsx @@ -2,10 +2,21 @@ import React from 'react'; import { Text, Center } from '@mantine/core'; import Editor from './Editor'; import MarkdownPreview from './MarkdownPreview'; -import { getFileUrl } from '../../api/git'; -import { isImageFile } from '../../utils/fileHelpers'; +import { getFileUrl, isImageFile } from '../../utils/fileHelpers'; +import { useWorkspace } from '@/contexts/WorkspaceContext'; -const ContentView = ({ +type ViewTab = 'source' | 'preview'; + +interface ContentViewProps { + activeTab: ViewTab; + selectedFile: string | null; + content: string; + handleContentChange: (content: string) => void; + handleSave: (filePath: string, content: string) => Promise; + handleFileSelect: (filePath: string | null) => Promise; +} + +const ContentView: React.FC = ({ activeTab, selectedFile, content, @@ -13,10 +24,21 @@ const ContentView = ({ handleSave, handleFileSelect, }) => { + const { currentWorkspace } = useWorkspace(); + if (!currentWorkspace) { + return ( +
+ + No workspace selected. + +
+ ); + } + if (!selectedFile) { return (
- + No file selected.
@@ -27,7 +49,7 @@ const ContentView = ({ return (
{selectedFile} { +interface EditorProps { + content: string; + handleContentChange: (content: string) => void; + handleSave: (filePath: string, content: string) => Promise; + selectedFile: string; +} + +const Editor: React.FC = ({ + content, + handleContentChange, + handleSave, + selectedFile, +}) => { const { colorScheme } = useWorkspace(); - const editorRef = useRef(); - const viewRef = useRef(); + const editorRef = useRef(null); + const viewRef = useRef(null); useEffect(() => { - const handleEditorSave = (view) => { + const handleEditorSave = (view: EditorView): boolean => { handleSave(selectedFile, view.state.doc.toString()); return true; }; @@ -36,6 +48,8 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { }, }); + if (!editorRef.current) return; + const state = EditorState.create({ doc: content, extensions: [ @@ -69,8 +83,9 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { return () => { view.destroy(); + viewRef.current = null; }; - }, [colorScheme, handleContentChange]); + }, [colorScheme, handleContentChange, handleSave, selectedFile]); useEffect(() => { if (viewRef.current && content !== viewRef.current.state.doc.toString()) { diff --git a/app/src/components/editor/MarkdownPreview.jsx b/app/src/components/editor/MarkdownPreview.jsx deleted file mode 100644 index 51b80ee..0000000 --- a/app/src/components/editor/MarkdownPreview.jsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { unified } from 'unified'; -import remarkParse from 'remark-parse'; -import remarkMath from 'remark-math'; -import remarkRehype from 'remark-rehype'; -import rehypeMathjax from 'rehype-mathjax'; -import rehypeReact from 'rehype-react'; -import rehypePrism from 'rehype-prism'; -import * as prod from 'react/jsx-runtime'; -import { notifications } from '@mantine/notifications'; -import { remarkWikiLinks } from '../../utils/remarkWikiLinks'; -import { useWorkspace } from '../../contexts/WorkspaceContext'; - -const MarkdownPreview = ({ content, handleFileSelect }) => { - const [processedContent, setProcessedContent] = useState(null); - const baseUrl = window.API_BASE_URL; - const { currentWorkspace } = useWorkspace(); - - const handleLinkClick = (e, href) => { - e.preventDefault(); - - if (href.startsWith(`${baseUrl}/internal/`)) { - // For existing files, extract the path and directly select it - const [filePath] = decodeURIComponent( - href.replace(`${baseUrl}/internal/`, '') - ).split('#'); - handleFileSelect(filePath); - } else if (href.startsWith(`${baseUrl}/notfound/`)) { - // For non-existent files, show a notification - const fileName = decodeURIComponent( - href.replace(`${baseUrl}/notfound/`, '') - ); - notifications.show({ - title: 'File Not Found', - message: `The file "${fileName}" does not exist.`, - color: 'red', - }); - } - }; - - const processor = useMemo( - () => - unified() - .use(remarkParse) - .use(remarkWikiLinks, currentWorkspace?.name) - .use(remarkMath) - .use(remarkRehype) - .use(rehypeMathjax) - .use(rehypePrism) - .use(rehypeReact, { - production: true, - jsx: prod.jsx, - jsxs: prod.jsxs, - Fragment: prod.Fragment, - components: { - img: ({ src, alt, ...props }) => ( - {alt} { - console.error('Failed to load image:', event.target.src); - event.target.alt = 'Failed to load image'; - }} - {...props} - /> - ), - a: ({ href, children, ...props }) => ( - handleLinkClick(e, href)} - {...props} - > - {children} - - ), - code: ({ children, className, ...props }) => { - const language = className - ? className.replace('language-', '') - : null; - return ( -
-                  {children}
-                
- ); - }, - }, - }), - [baseUrl, handleFileSelect, currentWorkspace?.name] - ); - - useEffect(() => { - const processContent = async () => { - if (!currentWorkspace) { - return; - } - - try { - const result = await processor.process(content); - setProcessedContent(result.result); - } catch (error) { - console.error('Error processing markdown:', error); - } - }; - - processContent(); - }, [content, processor, currentWorkspace]); - - return
{processedContent}
; -}; - -export default MarkdownPreview; diff --git a/app/src/components/editor/MarkdownPreview.tsx b/app/src/components/editor/MarkdownPreview.tsx new file mode 100644 index 0000000..ebbe6e6 --- /dev/null +++ b/app/src/components/editor/MarkdownPreview.tsx @@ -0,0 +1,141 @@ +import React, { useState, useEffect, useMemo, ReactNode } from 'react'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkMath from 'remark-math'; +import remarkRehype from 'remark-rehype'; +import rehypeMathjax from 'rehype-mathjax'; +import rehypeReact from 'rehype-react'; +import rehypePrism from 'rehype-prism'; +import * as prod from 'react/jsx-runtime'; +import { notifications } from '@mantine/notifications'; +import { remarkWikiLinks } from '../../utils/remarkWikiLinks'; +import { useWorkspace } from '../../contexts/WorkspaceContext'; + +interface MarkdownPreviewProps { + content: string; + handleFileSelect: (filePath: string | null) => Promise; +} + +interface MarkdownImageProps { + src: string; + alt?: string; + [key: string]: any; +} + +interface MarkdownLinkProps { + href: string; + children: ReactNode; + [key: string]: any; +} + +interface MarkdownCodeProps { + children: ReactNode; + className?: string; + [key: string]: any; +} + +const MarkdownPreview: React.FC = ({ + content, + handleFileSelect, +}) => { + const [processedContent, setProcessedContent] = useState( + null + ); + const baseUrl = window.API_BASE_URL; + const { currentWorkspace } = useWorkspace(); + + const handleLinkClick = ( + e: React.MouseEvent, + href: string + ): void => { + e.preventDefault(); + + if (href.startsWith(`${baseUrl}/internal/`)) { + // For existing files, extract the path and directly select it + const [filePath] = decodeURIComponent( + href.replace(`${baseUrl}/internal/`, '') + ).split('#'); + handleFileSelect(filePath); + } else if (href.startsWith(`${baseUrl}/notfound/`)) { + // For non-existent files, show a notification + const fileName = decodeURIComponent( + href.replace(`${baseUrl}/notfound/`, '') + ); + notifications.show({ + title: 'File Not Found', + message: `The file "${fileName}" does not exist.`, + color: 'red', + }); + } + }; + + const processor = useMemo(() => { + // Only create the processor if we have a workspace name + if (!currentWorkspace?.name) { + return unified(); + } + + return unified() + .use(remarkParse) + .use(remarkWikiLinks, currentWorkspace.name) // Now we know this is defined + .use(remarkMath) + .use(remarkRehype) + .use(rehypeMathjax) + .use(rehypePrism) + .use(rehypeReact as any, { + production: true, + jsx: prod.jsx, + jsxs: prod.jsxs, + Fragment: prod.Fragment, + components: { + img: ({ src, alt, ...props }: MarkdownImageProps) => ( + {alt { + console.error('Failed to load image:', event.currentTarget.src); + event.currentTarget.alt = 'Failed to load image'; + }} + {...props} + /> + ), + a: ({ href, children, ...props }: MarkdownLinkProps) => ( + handleLinkClick(e, href)} {...props}> + {children} + + ), + code: ({ children, className, ...props }: MarkdownCodeProps) => { + const language = className + ? className.replace('language-', '') + : null; + return ( +
+                {children}
+              
+ ); + }, + }, + }); + }, [baseUrl, handleFileSelect, currentWorkspace?.name]); + + useEffect(() => { + const processContent = async (): Promise => { + if (!currentWorkspace) { + return; + } + + try { + const result = await processor.process(content); + setProcessedContent(result.result as ReactNode); + } catch (error) { + console.error('Error processing markdown:', error); + } + }; + + processContent(); + }, [content, processor, currentWorkspace]); + + return
{processedContent}
; +}; + +export default MarkdownPreview; diff --git a/app/src/utils/fileHelpers.ts b/app/src/utils/fileHelpers.ts index 7ed9c0b..f589377 100644 --- a/app/src/utils/fileHelpers.ts +++ b/app/src/utils/fileHelpers.ts @@ -1,3 +1,4 @@ +import { API_BASE_URL } from '@/types/authApi'; import { IMAGE_EXTENSIONS } from '../types/file'; /** @@ -8,3 +9,9 @@ import { IMAGE_EXTENSIONS } from '../types/file'; export const isImageFile = (filePath: string): boolean => { return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext)); }; + +export const getFileUrl = (workspaceName: string, filePath: string) => { + return `${API_BASE_URL}/workspaces/${encodeURIComponent( + workspaceName + )}/files/${encodeURIComponent(filePath)}`; +}; From 3619cf4ed414a6b5a2315aa1289333b5d8d5c44a Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 18 May 2025 15:51:44 +0200 Subject: [PATCH 31/55] Update ts configuration --- app/package-lock.json | 17 ++++++-- app/package.json | 1 + app/src/api/git.ts | 1 + app/src/hooks/useGitOperations.ts | 1 + app/src/index.html | 2 +- app/src/types/git.ts | 2 +- app/tsconfig.json | 65 +++++++++++++++++++++---------- app/tsconfig.node.json | 27 ++++++++++++- 8 files changed, 88 insertions(+), 28 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 9c3fdba..f98a6a2 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -40,6 +40,7 @@ "@types/node": "^22.14.0", "@types/react": "^18.3.20", "@types/react-dom": "^18.3.6", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.4", "postcss": "^8.4.47", "postcss-preset-mantine": "^1.17.0", @@ -2077,6 +2078,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5432,9 +5443,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/app/package.json b/app/package.json index a75a83e..6ce1c27 100644 --- a/app/package.json +++ b/app/package.json @@ -56,6 +56,7 @@ "@types/node": "^22.14.0", "@types/react": "^18.3.20", "@types/react-dom": "^18.3.6", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.4", "postcss": "^8.4.47", "postcss-preset-mantine": "^1.17.0", diff --git a/app/src/api/git.ts b/app/src/api/git.ts index d76091d..eb8cbe0 100644 --- a/app/src/api/git.ts +++ b/app/src/api/git.ts @@ -1,5 +1,6 @@ import { API_BASE_URL } from '@/types/authApi'; import { apiCall } from './api'; +import { CommitHash } from '@/types/git'; /** * pullChanges fetches the latest changes from the remote repository diff --git a/app/src/hooks/useGitOperations.ts b/app/src/hooks/useGitOperations.ts index d2abee0..5930799 100644 --- a/app/src/hooks/useGitOperations.ts +++ b/app/src/hooks/useGitOperations.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { notifications } from '@mantine/notifications'; import { pullChanges, commitAndPush } from '../api/git'; import { useWorkspace } from '../contexts/WorkspaceContext'; +import { CommitHash } from '@/types/git'; interface UseGitOperationsResult { handlePull: () => Promise; diff --git a/app/src/index.html b/app/src/index.html index 863830b..ed2abbc 100644 --- a/app/src/index.html +++ b/app/src/index.html @@ -8,6 +8,6 @@
- + diff --git a/app/src/types/git.ts b/app/src/types/git.ts index cb768b9..58fc90e 100644 --- a/app/src/types/git.ts +++ b/app/src/types/git.ts @@ -1 +1 @@ -type CommitHash = string; +export type CommitHash = string; diff --git a/app/tsconfig.json b/app/tsconfig.json index 8f73c95..36b0596 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,36 +1,59 @@ { "compilerOptions": { + /* Language and Environment */ "target": "ES2020", - "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, "jsx": "react-jsx", + "useDefineForClassFields": true, - /* Linting */ - "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noFallthroughCasesInSwitch": true, - - /* Paths */ + /* Modules */ + "module": "ESNext", + "moduleResolution": "bundler", "baseUrl": ".", "paths": { "@/*": ["src/*"] }, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, - /* Type checking */ - "allowJs": true, + /* JavaScript Support */ + "allowJs": false, "checkJs": false, - "forceConsistentCasingInFileNames": true + + /* Emit */ + "noEmit": true, + "sourceMap": true, + "outDir": "./dist", + + /* Type Checking */ + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "alwaysStrict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + + /* Completeness */ + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + + /* Additional Checks */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx"], + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json index 42872c5..b3e05ae 100644 --- a/app/tsconfig.node.json +++ b/app/tsconfig.node.json @@ -1,10 +1,33 @@ { "compilerOptions": { + /* Basic Options */ "composite": true, - "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true + "target": "ES2020", + + /* Strict Type-Checking Options */ + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + + /* Additional Checks */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + /* Module Resolution Options */ + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true }, "include": ["vite.config.ts"] } From 60ab01b0c8a89c0103317587bb4c79f14b46b493 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 18 May 2025 16:36:20 +0200 Subject: [PATCH 32/55] Fix type-check issues --- app/src/components/editor/MarkdownPreview.tsx | 7 +++--- .../components/modals/user/EditUserModal.tsx | 8 +++---- .../navigation/WorkspaceSwitcher.tsx | 2 +- .../settings/account/AccountSettings.tsx | 8 +++---- .../settings/workspace/AppearanceSettings.tsx | 2 -- .../settings/workspace/DangerZoneSettings.tsx | 2 +- .../settings/workspace/WorkspaceSettings.tsx | 1 - app/src/contexts/WorkspaceContext.tsx | 7 ++---- app/src/hooks/useAdminData.ts | 9 ++++---- app/src/hooks/useGitOperations.ts | 11 +++++----- app/src/utils/remarkWikiLinks.ts | 22 ++++++++++++++----- 11 files changed, 41 insertions(+), 38 deletions(-) diff --git a/app/src/components/editor/MarkdownPreview.tsx b/app/src/components/editor/MarkdownPreview.tsx index ebbe6e6..9e8e2be 100644 --- a/app/src/components/editor/MarkdownPreview.tsx +++ b/app/src/components/editor/MarkdownPreview.tsx @@ -55,7 +55,9 @@ const MarkdownPreview: React.FC = ({ const [filePath] = decodeURIComponent( href.replace(`${baseUrl}/internal/`, '') ).split('#'); - handleFileSelect(filePath); + if (filePath) { + handleFileSelect(filePath); + } } else if (href.startsWith(`${baseUrl}/notfound/`)) { // For non-existent files, show a notification const fileName = decodeURIComponent( @@ -105,9 +107,6 @@ const MarkdownPreview: React.FC = ({ ), code: ({ children, className, ...props }: MarkdownCodeProps) => { - const language = className - ? className.replace('language-', '') - : null; return (
                 {children}
diff --git a/app/src/components/modals/user/EditUserModal.tsx b/app/src/components/modals/user/EditUserModal.tsx
index c3e8719..db2f129 100644
--- a/app/src/components/modals/user/EditUserModal.tsx
+++ b/app/src/components/modals/user/EditUserModal.tsx
@@ -30,7 +30,7 @@ const EditUserModal: React.FC = ({
   const [formData, setFormData] = useState({
     email: '',
     displayName: '',
-    role: undefined,
+    role: UserRole.Editor,
     password: '',
   });
 
@@ -58,7 +58,7 @@ const EditUserModal: React.FC = ({
       setFormData({
         email: '',
         displayName: '',
-        role: undefined,
+        role: UserRole.Editor,
         password: '',
       });
       onClose();
@@ -88,9 +88,9 @@ const EditUserModal: React.FC = ({