Merge pull request #50 from lordmathis/feat/typescript

Migrate frontend to typescript
This commit is contained in:
2025-05-25 16:11:34 +02:00
committed by GitHub
100 changed files with 7064 additions and 1883 deletions

41
.github/workflows/typescript.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: TypeScript Type Check
permissions:
contents: read
on:
push:
branches:
- "*"
pull_request:
branches:
- main
jobs:
type-check:
name: TypeScript Type Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "./app/package-lock.json"
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
run: npm run type-check
- name: Run ESLint
run: npm run lint
continue-on-error: true

View File

@@ -1,31 +0,0 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"react/prop-types": "off",
"no-unused-vars": "warn"
},
"settings": {
"react": {
"version": "detect"
}
}
}

107
app/eslint.config.mjs Normal file
View File

@@ -0,0 +1,107 @@
import { defineConfig, globalIgnores } from 'eslint/config';
import { fixupConfigRules, fixupPluginRules } from '@eslint/compat';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([
globalIgnores([
'**/node_modules',
'**/dist',
'**/build',
'**/coverage',
'**/public',
'**/*.js',
'**/vite.config.ts',
'**/eslint.config.mjs',
]),
{
extends: fixupConfigRules(
compat.extends(
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking'
)
),
plugins: {
react: fixupPluginRules(react),
'react-hooks': fixupPluginRules(reactHooks),
'@typescript-eslint': fixupPluginRules(typescriptEslint),
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
project: './tsconfig.json',
},
},
settings: {
react: {
version: 'detect',
},
},
rules: {
'no-console': [
'warn',
{
allow: ['warn', 'error', 'debug'],
},
],
'no-duplicate-imports': 'error',
'no-unused-vars': 'off',
'react/prop-types': 'off',
'react/react-in-jsx-scope': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{
prefer: 'type-imports',
},
],
'@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
},
},
]);

3357
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,11 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"type-check": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -50,14 +53,24 @@
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.67", "@eslint/compat": "^1.2.9",
"@types/react-dom": "^18.2.22", "@types/babel__core": "^7.20.5",
"@types/node": "^22.14.0",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.27.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4", "sass": "^1.80.4",
"vite": "^6.3.4", "typescript": "^5.8.2",
"vite": "^6.2.4",
"vite-plugin-compression2": "^1.3.0" "vite-plugin-compression2": "^1.3.0"
}, },
"browserslist": { "browserslist": {

View File

@@ -11,7 +11,9 @@ import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css'; import '@mantine/notifications/styles.css';
import './App.scss'; import './App.scss';
function AuthenticatedContent() { type AuthenticatedContentProps = object;
const AuthenticatedContent: React.FC<AuthenticatedContentProps> = () => {
const { user, loading, initialized } = useAuth(); const { user, loading, initialized } = useAuth();
if (!initialized) { if (!initialized) {
@@ -33,9 +35,11 @@ function AuthenticatedContent() {
</ModalProvider> </ModalProvider>
</WorkspaceProvider> </WorkspaceProvider>
); );
} };
function App() { type AppProps = object;
const App: React.FC<AppProps> = () => {
return ( return (
<> <>
<ColorSchemeScript defaultColorScheme="light" /> <ColorSchemeScript defaultColorScheme="light" />
@@ -49,6 +53,6 @@ function App() {
</MantineProvider> </MantineProvider>
</> </>
); );
} };
export default App; export default App;

135
app/src/api/admin.ts Normal file
View File

@@ -0,0 +1,135 @@
import {
API_BASE_URL,
type CreateUserRequest,
type UpdateUserRequest,
} from '@/types/api';
import { apiCall } from './api';
import {
isSystemStats,
isUser,
isWorkspace,
type SystemStats,
type User,
type Workspace,
} from '@/types/models';
const ADMIN_BASE_URL = `${API_BASE_URL}/admin`;
// User Management
/**
* Fetches all users from the API
* @returns {Promise<User[]>} 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<User[]> => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`);
const data: unknown = 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;
});
};
/**
* Creates a new user in the system
* @param {CreateUserRequest} userData The data for the new user
* @returns {Promise<User>} 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<User> => {
const response = await apiCall(`${ADMIN_BASE_URL}/users`, {
method: 'POST',
body: JSON.stringify(userData),
});
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user object received from API');
}
return data;
};
/**
* 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<User>} 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<User> => {
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData),
});
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user object received from API');
}
return data;
};
// Workspace Management
/**
* Fetches all workspaces from the API
* @returns {Promise<Workspace[]>} 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<Workspace[]> => {
const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`);
const data: unknown = 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;
});
};
// System Statistics
/**
* Fetches system-wide statistics from the API
* @returns {Promise<SystemStats>} 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<SystemStats> => {
const response = await apiCall(`${ADMIN_BASE_URL}/stats`);
const data: unknown = await response.json();
if (!isSystemStats(data)) {
throw new Error('Invalid system stats response received from API');
}
return data;
};

78
app/src/api/api.ts Normal file
View File

@@ -0,0 +1,78 @@
import { refreshToken } from './auth';
/**
* Gets the CSRF token from cookies
* @returns {string} The CSRF token or an empty string if not found
*/
const getCsrfToken = (): string => {
const cookies = document.cookie.split(';');
let csrfToken = '';
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'csrf_token' && value) {
csrfToken = decodeURIComponent(value);
break;
}
}
return csrfToken;
};
/**
* Makes an API call with proper cookie handling and error handling
*/
export const apiCall = async (
url: string,
options: RequestInit = {}
): Promise<Response> => {
console.debug(`Making API call to: ${url}`);
try {
// Set up headers with CSRF token for non-GET requests
const method = options.method || 'GET';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
// Add CSRF token for non-GET methods
if (method !== 'GET') {
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(url, {
...options,
// Include credentials to send/receive cookies
credentials: 'include',
headers,
});
console.debug(`Response status: ${response.status} for URL: ${url}`);
// 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;
}
};

75
app/src/api/auth.ts Normal file
View File

@@ -0,0 +1,75 @@
import { API_BASE_URL, isLoginResponse, type LoginRequest } from '@/types/api';
import { apiCall } from './api';
import { isUser, type User } from '@/types/models';
/**
* Logs in a user with email and password
* @param {string} email - The user's email
* @param {string} password - The user's password
* @returns {Promise<User>} 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<User> => {
const loginData: LoginRequest = { email, password };
const response = await apiCall(`${API_BASE_URL}/auth/login`, {
method: 'POST',
body: JSON.stringify(loginData),
});
const data: unknown = await response.json();
if (!isLoginResponse(data)) {
throw new Error('Invalid login response from API');
}
return data.user;
};
/**
* Logs out the current user
* @returns {Promise<void>} 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<void> => {
const response = await apiCall(`${API_BASE_URL}/auth/logout`, {
method: 'POST',
});
if (response.status !== 204) {
throw new Error('Failed to log out');
}
};
/**
* Refreshes the auth token
* @returns true if refresh was successful, false otherwise
*/
export const refreshToken = async (): Promise<boolean> => {
try {
await apiCall(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
});
return true;
} catch (_error) {
return false;
}
};
/**
* Gets the currently authenticated user
* @returns {Promise<User>} 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<User> => {
const response = await apiCall(`${API_BASE_URL}/auth/me`);
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data received from API');
}
return data;
};

169
app/src/api/file.ts Normal file
View File

@@ -0,0 +1,169 @@
import { isFileNode, type FileNode } from '@/types/models';
import { apiCall } from './api';
import {
API_BASE_URL,
isLookupResponse,
isSaveFileResponse,
type SaveFileResponse,
} from '@/types/api';
/**
* listFiles fetches the list of files in a workspace
* @param workspaceName - The name of the workspace
* @returns {Promise<FileNode[]>} 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<FileNode[]> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files`
);
const data: unknown = 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;
});
};
/**
* 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<string[]>} 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<string[]> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/lookup?filename=${encodeURIComponent(filename)}`
);
const data: unknown = await response.json();
if (!isLookupResponse(data)) {
throw new Error('Invalid lookup response received from API');
}
const lookupResponse = data;
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<string>} 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<string> => {
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<SaveFileResponse>} 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<SaveFileResponse> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/${encodeURIComponent(filePath)}`,
{
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
body: content,
}
);
const data: unknown = await response.json();
if (!isSaveFileResponse(data)) {
throw new Error('Invalid save file response received from API');
}
return data;
};
/**
* 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<string>} 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<string> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/files/last`
);
const data: unknown = await response.json();
if (
typeof data !== 'object' ||
data === null ||
!('lastOpenedFilePath' in data)
) {
throw new Error('Invalid last opened file response received from API');
}
return data.lastOpenedFilePath as string;
};
/**
* 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 }),
}
);
};

50
app/src/api/git.ts Normal file
View File

@@ -0,0 +1,50 @@
import { API_BASE_URL } from '@/types/api';
import { apiCall } from './api';
import type { CommitHash } from '@/types/models';
/**
* pullChanges fetches the latest changes from the remote repository
* @param workspaceName - The name of the workspace
* @returns {Promise<string>} A promise that resolves to a message indicating the result of the pull operation
*/
export const pullChanges = async (workspaceName: string): Promise<string> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}/git/pull`,
{
method: 'POST',
}
);
const data: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('message' in data)) {
throw new Error('Invalid pull response received from API');
}
return data.message as string;
};
/**
* pushChanges pushes the local changes to the remote repository
* @param workspaceName - The name of the workspace
* @returns {Promise<CommitHash>} A promise that resolves to the commit hash of the pushed changes
*/
export const commitAndPush = async (
workspaceName: string,
message: string
): Promise<CommitHash> => {
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: unknown = await response.json();
if (typeof data !== 'object' || data === null || !('commitHash' in data)) {
throw new Error('Invalid commit response received from API');
}
return data.commitHash as CommitHash;
};

41
app/src/api/user.ts Normal file
View File

@@ -0,0 +1,41 @@
import { API_BASE_URL, type UpdateProfileRequest } from '@/types/api';
import { isUser, type User } from '@/types/models';
import { apiCall } from './api';
/**
* 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<User> => {
const response = await apiCall(`${API_BASE_URL}/profile`, {
method: 'PUT',
body: JSON.stringify(updateRequest),
});
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data');
}
return data;
};
/**
* 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;
};

153
app/src/api/workspace.ts Normal file
View File

@@ -0,0 +1,153 @@
import { type Workspace, isWorkspace } from '@/types/models';
import { apiCall } from './api';
import { API_BASE_URL } from '@/types/api';
/**
* listWorkspaces fetches the list of workspaces
* @returns {Promise<Workspace[]>} 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<Workspace[]> => {
const response = await apiCall(`${API_BASE_URL}/workspaces`);
const data: unknown = 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;
});
};
/**
* createWorkspace creates a new workspace with the given name
* @param name - The name of the workspace to create
* @returns {Promise<Workspace>} 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<Workspace> => {
const response = await apiCall(`${API_BASE_URL}/workspaces`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
const data: unknown = await response.json();
if (!isWorkspace(data)) {
throw new Error('Invalid workspace object received from API');
}
return data;
};
/**
* getWorkspace fetches the workspace with the given name
* @param workspaceName - The name of the workspace to fetch
* @returns {Promise<Workspace>} 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<Workspace> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`
);
const data: unknown = await response.json();
if (!isWorkspace(data)) {
throw new Error('Invalid workspace object received from API');
}
return data;
};
/**
* 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<Workspace>} 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<Workspace> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(workspaceData),
}
);
const data: unknown = await response.json();
if (!isWorkspace(data)) {
throw new Error('Invalid workspace object received from API');
}
return data;
};
/**
* deleteWorkspace deletes the workspace with the given name
* @param workspaceName - The name of the workspace to delete
* @returns {Promise<string>} 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<string> => {
const response = await apiCall(
`${API_BASE_URL}/workspaces/${encodeURIComponent(workspaceName)}`,
{
method: 'DELETE',
}
);
const data: unknown = await response.json();
if (
typeof data !== 'object' ||
data === null ||
!('nextWorkspaceName' in data)
) {
throw new Error('Invalid delete workspace response received from API');
}
return data.nextWorkspaceName as string;
};
/**
* getLastWorkspaceName fetches the last workspace name
* @returns {Promise<string>} 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<string> => {
const response = await apiCall(`${API_BASE_URL}/workspaces/last`);
const data: unknown = await response.json();
if (
typeof data !== 'object' ||
data === null ||
!('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
* @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;
};

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, type FormEvent } from 'react';
import { import {
TextInput, TextInput,
PasswordInput, PasswordInput,
@@ -11,20 +11,22 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
const LoginPage = () => { const LoginPage: React.FC = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState<string>('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState<boolean>(false);
const { login } = useAuth(); const { login } = useAuth();
const handleSubmit = async (e) => { const handleSubmit = (e: FormEvent<HTMLElement>): void => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
try { login(email, password)
await login(email, password); .catch((error) => {
} finally { console.error('Login failed:', error);
setLoading(false); })
} .finally(() => {
setLoading(false);
});
}; };
return ( return (

View File

@@ -2,10 +2,21 @@ import React from 'react';
import { Text, Center } from '@mantine/core'; import { Text, Center } from '@mantine/core';
import Editor from './Editor'; import Editor from './Editor';
import MarkdownPreview from './MarkdownPreview'; import MarkdownPreview from './MarkdownPreview';
import { getFileUrl } from '../../services/api'; import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
import { 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<boolean>;
handleFileSelect: (filePath: string | null) => Promise<void>;
}
const ContentView: React.FC<ContentViewProps> = ({
activeTab, activeTab,
selectedFile, selectedFile,
content, content,
@@ -13,10 +24,21 @@ const ContentView = ({
handleSave, handleSave,
handleFileSelect, handleFileSelect,
}) => { }) => {
const { currentWorkspace } = useWorkspace();
if (!currentWorkspace) {
return (
<Center style={{ height: '100%' }}>
<Text size="xl" fw={500}>
No workspace selected.
</Text>
</Center>
);
}
if (!selectedFile) { if (!selectedFile) {
return ( return (
<Center style={{ height: '100%' }}> <Center style={{ height: '100%' }}>
<Text size="xl" weight={500}> <Text size="xl" fw={500}>
No file selected. No file selected.
</Text> </Text>
</Center> </Center>
@@ -27,7 +49,7 @@ const ContentView = ({
return ( return (
<Center className="image-preview"> <Center className="image-preview">
<img <img
src={getFileUrl(selectedFile)} src={getFileUrl(currentWorkspace.name, selectedFile)}
alt={selectedFile} alt={selectedFile}
style={{ style={{
maxWidth: '100%', maxWidth: '100%',

View File

@@ -5,16 +5,28 @@ import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands'; import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => { interface EditorProps {
content: string;
handleContentChange: (content: string) => void;
handleSave: (filePath: string, content: string) => Promise<boolean>;
selectedFile: string;
}
const Editor: React.FC<EditorProps> = ({
content,
handleContentChange,
handleSave,
selectedFile,
}) => {
const { colorScheme } = useWorkspace(); const { colorScheme } = useWorkspace();
const editorRef = useRef(); const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef(); const viewRef = useRef<EditorView | null>(null);
useEffect(() => { useEffect(() => {
const handleEditorSave = (view) => { const handleEditorSave = (view: EditorView): boolean => {
handleSave(selectedFile, view.state.doc.toString()); void handleSave(selectedFile, view.state.doc.toString());
return true; return true;
}; };
@@ -36,6 +48,8 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
}, },
}); });
if (!editorRef.current) return;
const state = EditorState.create({ const state = EditorState.create({
doc: content, doc: content,
extensions: [ extensions: [
@@ -69,8 +83,11 @@ const Editor = ({ content, handleContentChange, handleSave, selectedFile }) => {
return () => { return () => {
view.destroy(); view.destroy();
viewRef.current = null;
}; };
}, [colorScheme, handleContentChange]); // TODO: Refactor
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colorScheme, handleContentChange, handleSave, selectedFile]);
useEffect(() => { useEffect(() => {
if (viewRef.current && content !== viewRef.current.state.doc.toString()) { if (viewRef.current && content !== viewRef.current.state.doc.toString()) {

View File

@@ -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 }) => (
<img
src={src}
alt={alt}
onError={(event) => {
console.error('Failed to load image:', event.target.src);
event.target.alt = 'Failed to load image';
}}
{...props}
/>
),
a: ({ href, children, ...props }) => (
<a
href={href}
onClick={(e) => handleLinkClick(e, href)}
{...props}
>
{children}
</a>
),
code: ({ children, className, ...props }) => {
const language = className
? className.replace('language-', '')
: null;
return (
<pre className={className}>
<code {...props}>{children}</code>
</pre>
);
},
},
}),
[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 <div className="markdown-preview">{processedContent}</div>;
};
export default MarkdownPreview;

View File

@@ -0,0 +1,141 @@
import React, { useState, useEffect, useMemo, type ReactNode } from 'react';
import { unified, type Preset } from 'unified';
import remarkParse from 'remark-parse';
import remarkMath from 'remark-math';
import remarkRehype from 'remark-rehype';
import rehypeMathjax from 'rehype-mathjax';
import rehypeReact, { type Options } 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 '../../hooks/useWorkspace';
interface MarkdownPreviewProps {
content: string;
handleFileSelect: (filePath: string | null) => Promise<void>;
}
interface MarkdownImageProps {
src: string;
alt?: string;
[key: string]: unknown;
}
interface MarkdownLinkProps {
href: string;
children: ReactNode;
[key: string]: unknown;
}
interface MarkdownCodeProps {
children: ReactNode;
className?: string;
[key: string]: unknown;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
content,
handleFileSelect,
}) => {
const [processedContent, setProcessedContent] = useState<ReactNode | null>(
null
);
const baseUrl = window.API_BASE_URL;
const { currentWorkspace } = useWorkspace();
const processor = useMemo(() => {
const handleLinkClick = (
e: React.MouseEvent<HTMLAnchorElement>,
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('#');
if (filePath) {
void 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',
});
}
};
// Only create the processor if we have a workspace name
if (!currentWorkspace?.name) {
return unified();
}
return unified()
.use(remarkParse)
.use(remarkWikiLinks, currentWorkspace.name)
.use(remarkMath)
.use(remarkRehype)
.use(rehypeMathjax)
.use(rehypePrism as Preset)
.use(rehypeReact, {
jsx: prod.jsx,
jsxs: prod.jsxs,
Fragment: prod.Fragment,
development: false,
elementAttributeNameCase: 'react',
stylePropertyNameCase: 'dom',
components: {
img: ({ src, alt, ...props }: MarkdownImageProps) => (
<img
src={src}
alt={alt || ''}
onError={(event) => {
console.error('Failed to load image:', event.currentTarget.src);
event.currentTarget.alt = 'Failed to load image';
}}
{...props}
/>
),
a: ({ href, children, ...props }: MarkdownLinkProps) => (
<a href={href} onClick={(e) => handleLinkClick(e, href)} {...props}>
{children}
</a>
),
code: ({ children, className, ...props }: MarkdownCodeProps) => {
return (
<pre className={className}>
<code {...props}>{children}</code>
</pre>
);
},
},
} as Options);
}, [currentWorkspace?.name, baseUrl, handleFileSelect]);
useEffect(() => {
const processContent = async (): Promise<void> => {
if (!currentWorkspace) {
return;
}
try {
const result = await processor.process(content);
setProcessedContent(result.result as ReactNode);
} catch (error) {
console.error('Error processing markdown:', error);
}
};
void processContent();
}, [content, processor, currentWorkspace]);
return <div className="markdown-preview">{processedContent}</div>;
};
export default MarkdownPreview;

View File

@@ -7,9 +7,17 @@ import {
IconGitCommit, IconGitCommit,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useModalContext } from '../../contexts/ModalContext'; import { useModalContext } from '../../contexts/ModalContext';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
const FileActions = ({ handlePullChanges, selectedFile }) => { interface FileActionsProps {
handlePullChanges: () => Promise<boolean>;
selectedFile: string | null;
}
const FileActions: React.FC<FileActionsProps> = ({
handlePullChanges,
selectedFile,
}) => {
const { settings } = useWorkspace(); const { settings } = useWorkspace();
const { const {
setNewFileModalVisible, setNewFileModalVisible,
@@ -17,9 +25,9 @@ const FileActions = ({ handlePullChanges, selectedFile }) => {
setCommitMessageModalVisible, setCommitMessageModalVisible,
} = useModalContext(); } = useModalContext();
const handleCreateFile = () => setNewFileModalVisible(true); const handleCreateFile = (): void => setNewFileModalVisible(true);
const handleDeleteFile = () => setDeleteFileModalVisible(true); const handleDeleteFile = (): void => setDeleteFileModalVisible(true);
const handleCommitAndPush = () => setCommitMessageModalVisible(true); const handleCommitAndPush = (): void => setCommitMessageModalVisible(true);
return ( return (
<Group gap="xs"> <Group gap="xs">
@@ -53,7 +61,11 @@ const FileActions = ({ handlePullChanges, selectedFile }) => {
<ActionIcon <ActionIcon
variant="default" variant="default"
size="md" size="md"
onClick={handlePullChanges} onClick={() => {
handlePullChanges().catch((error) => {
console.error('Error pulling changes:', error);
});
}}
disabled={!settings.gitEnabled} disabled={!settings.gitEnabled}
> >
<IconGitPullRequest size={16} /> <IconGitPullRequest size={16} />

View File

@@ -1,21 +1,35 @@
import React, { useRef, useState, useLayoutEffect } from 'react'; import React, { useRef, useState, useLayoutEffect } from 'react';
import { Tree } from 'react-arborist'; import { Tree, type NodeApi } from 'react-arborist';
import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react'; import { IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react';
import { Tooltip } from '@mantine/core'; import { Tooltip } from '@mantine/core';
import useResizeObserver from '@react-hook/resize-observer'; import useResizeObserver from '@react-hook/resize-observer';
import type { FileNode } from '@/types/models';
const useSize = (target) => { interface Size {
const [size, setSize] = useState(); width: number;
height: number;
}
interface FileTreeProps {
files: FileNode[];
handleFileSelect: (filePath: string | null) => Promise<void>;
showHiddenFiles: boolean;
}
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
const [size, setSize] = useState<Size>();
useLayoutEffect(() => { useLayoutEffect(() => {
setSize(target.current.getBoundingClientRect()); if (target.current) {
setSize(target.current.getBoundingClientRect());
}
}, [target]); }, [target]);
useResizeObserver(target, (entry) => setSize(entry.contentRect)); useResizeObserver(target, (entry) => setSize(entry.contentRect));
return size; return size;
}; };
const FileIcon = ({ node }) => { const FileIcon = ({ node }: { node: NodeApi<FileNode> }) => {
if (node.isLeaf) { if (node.isLeaf) {
return <IconFile size={16} />; return <IconFile size={16} />;
} }
@@ -26,7 +40,28 @@ const FileIcon = ({ node }) => {
); );
}; };
const Node = ({ node, style, dragHandle }) => { // Define a Node component that matches what React-Arborist expects
function Node({
node,
style,
dragHandle,
onNodeClick,
...rest
}: {
node: NodeApi<FileNode>;
style: React.CSSProperties;
dragHandle?: React.Ref<HTMLDivElement>;
onNodeClick?: (node: NodeApi<FileNode>) => void;
// Accept any extra props from Arborist, but do not use an index signature
} & Record<string, unknown>) {
const handleClick = () => {
if (node.isInternal) {
node.toggle();
} else if (typeof onNodeClick === 'function') {
onNodeClick(node);
}
};
return ( return (
<Tooltip label={node.data.name} openDelay={500}> <Tooltip label={node.data.name} openDelay={500}>
<div <div
@@ -40,13 +75,8 @@ const Node = ({ node, style, dragHandle }) => {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
}} }}
onClick={() => { onClick={handleClick}
if (node.isInternal) { {...rest}
node.toggle();
} else {
node.tree.props.onNodeClick(node);
}
}}
> >
<FileIcon node={node} /> <FileIcon node={node} />
<span <span
@@ -63,19 +93,31 @@ const Node = ({ node, style, dragHandle }) => {
</div> </div>
</Tooltip> </Tooltip>
); );
}; }
const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => { const FileTree: React.FC<FileTreeProps> = ({
const target = useRef(null); files,
handleFileSelect,
showHiddenFiles,
}) => {
const target = useRef<HTMLDivElement>(null);
const size = useSize(target); const size = useSize(target);
files = files.filter((file) => { const filteredFiles = files.filter((file) => {
if (file.name.startsWith('.') && !showHiddenFiles) { if (file.name.startsWith('.') && !showHiddenFiles) {
return false; return false;
} }
return true; return true;
}); });
// Handler for node click
const onNodeClick = (node: NodeApi<FileNode>) => {
const fileNode = node.data;
if (!node.isInternal) {
void handleFileSelect(fileNode.path);
}
};
return ( return (
<div <div
ref={target} ref={target}
@@ -83,24 +125,20 @@ const FileTree = ({ files, handleFileSelect, showHiddenFiles }) => {
> >
{size && ( {size && (
<Tree <Tree
data={files} data={filteredFiles}
openByDefault={false} openByDefault={false}
width={size.width} width={size.width}
height={size.height} height={size.height}
indent={24} indent={24}
rowHeight={28} rowHeight={28}
onActivate={(node) => { onActivate={(node) => {
const fileNode = node.data;
if (!node.isInternal) { if (!node.isInternal) {
handleFileSelect(node.data.path); void handleFileSelect(fileNode.path);
}
}}
onNodeClick={(node) => {
if (!node.isInternal) {
handleFileSelect(node.data.path);
} }
}} }}
> >
{Node} {(props) => <Node {...props} onNodeClick={onNodeClick} />}
</Tree> </Tree>
)} )}
</div> </div>

View File

@@ -4,7 +4,7 @@ import UserMenu from '../navigation/UserMenu';
import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher'; import WorkspaceSwitcher from '../navigation/WorkspaceSwitcher';
import WorkspaceSettings from '../settings/workspace/WorkspaceSettings'; import WorkspaceSettings from '../settings/workspace/WorkspaceSettings';
const Header = () => { const Header: React.FC = () => {
return ( return (
<Group justify="space-between" h={60} px="md"> <Group justify="space-between" h={60} px="md">
<Text fw={700} size="lg"> <Text fw={700} size="lg">

View File

@@ -5,9 +5,9 @@ import Sidebar from './Sidebar';
import MainContent from './MainContent'; import MainContent from './MainContent';
import { useFileNavigation } from '../../hooks/useFileNavigation'; import { useFileNavigation } from '../../hooks/useFileNavigation';
import { useFileList } from '../../hooks/useFileList'; import { useFileList } from '../../hooks/useFileList';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
const Layout = () => { const Layout: React.FC = () => {
const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); const { currentWorkspace, loading: workspaceLoading } = useWorkspace();
const { selectedFile, handleFileSelect } = useFileNavigation(); const { selectedFile, handleFileSelect } = useFileNavigation();
const { files, loadFileList } = useFileList(); const { files, loadFileList } = useFileList();

View File

@@ -10,11 +10,21 @@ import CommitMessageModal from '../modals/git/CommitMessageModal';
import { useFileContent } from '../../hooks/useFileContent'; import { useFileContent } from '../../hooks/useFileContent';
import { useFileOperations } from '../../hooks/useFileOperations'; import { useFileOperations } from '../../hooks/useFileOperations';
import { useGitOperations } from '../../hooks/useGitOperations'; import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext';
const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => { type ViewTab = 'source' | 'preview';
const [activeTab, setActiveTab] = useState('source');
const { settings } = useWorkspace(); interface MainContentProps {
selectedFile: string | null;
handleFileSelect: (filePath: string | null) => Promise<void>;
loadFileList: () => Promise<void>;
}
const MainContent: React.FC<MainContentProps> = ({
selectedFile,
handleFileSelect,
loadFileList,
}) => {
const [activeTab, setActiveTab] = useState<ViewTab>('source');
const { const {
content, content,
hasUnsavedChanges, hasUnsavedChanges,
@@ -22,15 +32,17 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
handleContentChange, handleContentChange,
} = useFileContent(selectedFile); } = useFileContent(selectedFile);
const { handleSave, handleCreate, handleDelete } = useFileOperations(); const { handleSave, handleCreate, handleDelete } = useFileOperations();
const { handleCommitAndPush } = useGitOperations(settings.gitEnabled); const { handleCommitAndPush } = useGitOperations();
const handleTabChange = useCallback((value) => { const handleTabChange = useCallback((value: string | null): void => {
setActiveTab(value); if (value) {
setActiveTab(value as ViewTab);
}
}, []); }, []);
const handleSaveFile = useCallback( const handleSaveFile = useCallback(
async (filePath, content) => { async (filePath: string, fileContent: string): Promise<boolean> => {
let success = await handleSave(filePath, content); const success = await handleSave(filePath, fileContent);
if (success) { if (success) {
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
} }
@@ -40,22 +52,22 @@ const MainContent = ({ selectedFile, handleFileSelect, loadFileList }) => {
); );
const handleCreateFile = useCallback( const handleCreateFile = useCallback(
async (fileName) => { async (fileName: string): Promise<void> => {
const success = await handleCreate(fileName); const success = await handleCreate(fileName);
if (success) { if (success) {
loadFileList(); await loadFileList();
handleFileSelect(fileName); await handleFileSelect(fileName);
} }
}, },
[handleCreate, handleFileSelect, loadFileList] [handleCreate, handleFileSelect, loadFileList]
); );
const handleDeleteFile = useCallback( const handleDeleteFile = useCallback(
async (filePath) => { async (filePath: string): Promise<void> => {
const success = await handleDelete(filePath); const success = await handleDelete(filePath);
if (success) { if (success) {
loadFileList(); await loadFileList();
handleFileSelect(null); await handleFileSelect(null);
} }
}, },
[handleDelete, handleFileSelect, loadFileList] [handleDelete, handleFileSelect, loadFileList]

View File

@@ -3,14 +3,27 @@ import { Box } from '@mantine/core';
import FileActions from '../files/FileActions'; import FileActions from '../files/FileActions';
import FileTree from '../files/FileTree'; import FileTree from '../files/FileTree';
import { useGitOperations } from '../../hooks/useGitOperations'; import { useGitOperations } from '../../hooks/useGitOperations';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
import type { FileNode } from '@/types/models';
const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => { interface SidebarProps {
selectedFile: string | null;
handleFileSelect: (filePath: string | null) => Promise<void>;
files: FileNode[];
loadFileList: () => Promise<void>;
}
const Sidebar: React.FC<SidebarProps> = ({
selectedFile,
handleFileSelect,
files,
loadFileList,
}) => {
const { settings } = useWorkspace(); const { settings } = useWorkspace();
const { handlePull } = useGitOperations(settings.gitEnabled); const { handlePull } = useGitOperations();
useEffect(() => { useEffect(() => {
loadFileList(); void loadFileList();
}, [loadFileList]); }, [loadFileList]);
return ( return (
@@ -28,7 +41,7 @@ const Sidebar = ({ selectedFile, handleFileSelect, files, loadFileList }) => {
<FileTree <FileTree
files={files} files={files}
handleFileSelect={handleFileSelect} handleFileSelect={handleFileSelect}
showHiddenFiles={settings.showHiddenFiles} showHiddenFiles={settings.showHiddenFiles || false}
/> />
</Box> </Box>
); );

View File

@@ -8,8 +8,18 @@ import {
Button, Button,
} from '@mantine/core'; } from '@mantine/core';
const DeleteAccountModal = ({ opened, onClose, onConfirm }) => { interface DeleteAccountModalProps {
const [password, setPassword] = useState(''); opened: boolean;
onClose: () => void;
onConfirm: (password: string) => Promise<void>;
}
const DeleteAccountModal: React.FC<DeleteAccountModalProps> = ({
opened,
onClose,
onConfirm,
}) => {
const [password, setPassword] = useState<string>('');
return ( return (
<Modal <Modal
@@ -40,7 +50,7 @@ const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
onConfirm(password); void onConfirm(password);
setPassword(''); setPassword('');
}} }}
> >

View File

@@ -8,8 +8,20 @@ import {
PasswordInput, PasswordInput,
} from '@mantine/core'; } from '@mantine/core';
const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => { interface EmailPasswordModalProps {
const [password, setPassword] = useState(''); opened: boolean;
onClose: () => void;
onConfirm: (password: string) => Promise<void>;
email: string;
}
const EmailPasswordModal: React.FC<EmailPasswordModalProps> = ({
opened,
onClose,
onConfirm,
email,
}) => {
const [password, setPassword] = useState<string>('');
return ( return (
<Modal <Modal
@@ -36,7 +48,7 @@ const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => {
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
onConfirm(password); void onConfirm(password);
setPassword(''); setPassword('');
}} }}
> >

View File

@@ -2,11 +2,15 @@ import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext'; import { useModalContext } from '../../../contexts/ModalContext';
const CreateFileModal = ({ onCreateFile }) => { interface CreateFileModalProps {
const [fileName, setFileName] = useState(''); onCreateFile: (fileName: string) => Promise<void>;
}
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
const [fileName, setFileName] = useState<string>('');
const { newFileModalVisible, setNewFileModalVisible } = useModalContext(); const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
const handleSubmit = async () => { const handleSubmit = async (): Promise<void> => {
if (fileName) { if (fileName) {
await onCreateFile(fileName); await onCreateFile(fileName);
setFileName(''); setFileName('');
@@ -38,7 +42,7 @@ const CreateFileModal = ({ onCreateFile }) => {
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit}>Create</Button> <Button onClick={() => void handleSubmit()}>Create</Button>
</Group> </Group>
</Box> </Box>
</Modal> </Modal>

View File

@@ -2,11 +2,21 @@ import React from 'react';
import { Modal, Text, Button, Group } from '@mantine/core'; import { Modal, Text, Button, Group } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext'; import { useModalContext } from '../../../contexts/ModalContext';
const DeleteFileModal = ({ onDeleteFile, selectedFile }) => { interface DeleteFileModalProps {
onDeleteFile: (fileName: string) => Promise<void>;
selectedFile: string | null;
}
const DeleteFileModal: React.FC<DeleteFileModalProps> = ({
onDeleteFile,
selectedFile,
}) => {
const { deleteFileModalVisible, setDeleteFileModalVisible } = const { deleteFileModalVisible, setDeleteFileModalVisible } =
useModalContext(); useModalContext();
const handleConfirm = async () => { const handleConfirm = async (): Promise<void> => {
if (!selectedFile) return;
await onDeleteFile(selectedFile); await onDeleteFile(selectedFile);
setDeleteFileModalVisible(false); setDeleteFileModalVisible(false);
}; };
@@ -18,7 +28,7 @@ const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
title="Delete File" title="Delete File"
centered centered
> >
<Text>Are you sure you want to delete "{selectedFile}"?</Text> <Text>Are you sure you want to delete &quot;{selectedFile}&quot;?</Text>
<Group justify="flex-end" mt="xl"> <Group justify="flex-end" mt="xl">
<Button <Button
variant="default" variant="default"
@@ -26,7 +36,7 @@ const DeleteFileModal = ({ onDeleteFile, selectedFile }) => {
> >
Cancel Cancel
</Button> </Button>
<Button color="red" onClick={handleConfirm}> <Button color="red" onClick={() => void handleConfirm()}>
Delete Delete
</Button> </Button>
</Group> </Group>

View File

@@ -2,12 +2,18 @@ import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext'; import { useModalContext } from '../../../contexts/ModalContext';
const CommitMessageModal = ({ onCommitAndPush }) => { interface CommitMessageModalProps {
onCommitAndPush: (message: string) => Promise<void>;
}
const CommitMessageModal: React.FC<CommitMessageModalProps> = ({
onCommitAndPush,
}) => {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const { commitMessageModalVisible, setCommitMessageModalVisible } = const { commitMessageModalVisible, setCommitMessageModalVisible } =
useModalContext(); useModalContext();
const handleSubmit = async () => { const handleSubmit = async (): Promise<void> => {
if (message) { if (message) {
await onCommitAndPush(message); await onCommitAndPush(message);
setMessage(''); setMessage('');
@@ -39,7 +45,7 @@ const CommitMessageModal = ({ onCommitAndPush }) => {
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit}>Commit</Button> <Button onClick={() => void handleSubmit()}>Commit</Button>
</Group> </Group>
</Box> </Box>
</Modal> </Modal>

View File

@@ -8,20 +8,41 @@ import {
Button, Button,
Group, Group,
} from '@mantine/core'; } from '@mantine/core';
import type { CreateUserRequest } from '@/types/api';
import { UserRole } from '@/types/models';
const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => { interface CreateUserModalProps {
const [email, setEmail] = useState(''); opened: boolean;
const [password, setPassword] = useState(''); onClose: () => void;
const [displayName, setDisplayName] = useState(''); onCreateUser: (userData: CreateUserRequest) => Promise<boolean>;
const [role, setRole] = useState('viewer'); loading: boolean;
}
const handleSubmit = async () => { const CreateUserModal: React.FC<CreateUserModalProps> = ({
const result = await onCreateUser({ email, password, displayName, role }); opened,
if (result.success) { onClose,
onCreateUser,
loading,
}) => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [displayName, setDisplayName] = useState<string>('');
const [role, setRole] = useState<UserRole>(UserRole.Viewer);
const handleSubmit = async (): Promise<void> => {
const userData: CreateUserRequest = {
email,
password,
displayName,
role,
};
const success = await onCreateUser(userData);
if (success) {
setEmail(''); setEmail('');
setPassword(''); setPassword('');
setDisplayName(''); setDisplayName('');
setRole('viewer'); setRole(UserRole.Viewer);
onClose(); onClose();
} }
}; };
@@ -53,18 +74,18 @@ const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
label="Role" label="Role"
required required
value={role} value={role}
onChange={setRole} onChange={(value) => value && setRole(value as UserRole)}
data={[ data={[
{ value: 'admin', label: 'Admin' }, { value: UserRole.Admin, label: 'Admin' },
{ value: 'editor', label: 'Editor' }, { value: UserRole.Editor, label: 'Editor' },
{ value: 'viewer', label: 'Viewer' }, { value: UserRole.Viewer, label: 'Viewer' },
]} ]}
/> />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}> <Button variant="default" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit} loading={loading}> <Button onClick={() => void handleSubmit} loading={loading}>
Create User Create User
</Button> </Button>
</Group> </Group>

View File

@@ -1,29 +0,0 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => (
<Modal
opened={opened}
onClose={onClose}
title="Delete User"
centered
size="sm"
>
<Stack>
<Text>
Are you sure you want to delete user "{user?.email}"? This action cannot
be undone and all associated data will be permanently deleted.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={onConfirm} loading={loading}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
);
export default DeleteUserModal;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import type { User } from '@/types/models';
interface DeleteUserModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
user: User | null;
loading: boolean;
}
const DeleteUserModal: React.FC<DeleteUserModalProps> = ({
opened,
onClose,
onConfirm,
user,
loading,
}) => (
<Modal
opened={opened}
onClose={onClose}
title="Delete User"
centered
size="sm"
>
<Stack>
<Text>
Are you sure you want to delete user &quot;{user?.email}&quot;? This
action cannot be undone and all associated data will be permanently
deleted.
</Text>
<Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button color="red" onClick={() => void onConfirm()} loading={loading}>
Delete User
</Button>
</Group>
</Stack>
</Modal>
);
export default DeleteUserModal;

View File

@@ -9,12 +9,28 @@ import {
PasswordInput, PasswordInput,
Text, Text,
} from '@mantine/core'; } from '@mantine/core';
import type { UpdateUserRequest } from '@/types/api';
import { type User, UserRole } from '@/types/models';
const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => { interface EditUserModalProps {
const [formData, setFormData] = useState({ opened: boolean;
onClose: () => void;
onEditUser: (userId: number, userData: UpdateUserRequest) => Promise<boolean>;
loading: boolean;
user: User | null;
}
const EditUserModal: React.FC<EditUserModalProps> = ({
opened,
onClose,
onEditUser,
loading,
user,
}) => {
const [formData, setFormData] = useState<UpdateUserRequest>({
email: '', email: '',
displayName: '', displayName: '',
role: '', role: UserRole.Editor,
password: '', password: '',
}); });
@@ -29,18 +45,20 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
} }
}, [user]); }, [user]);
const handleSubmit = async () => { const handleSubmit = async (): Promise<void> => {
if (!user) return;
const updateData = { const updateData = {
...formData, ...formData,
...(formData.password ? { password: formData.password } : {}), ...(formData.password ? { password: formData.password } : {}),
}; };
const result = await onEditUser(user.id, updateData); const success = await onEditUser(user.id, updateData);
if (result.success) { if (success) {
setFormData({ setFormData({
email: '', email: '',
displayName: '', displayName: '',
role: '', role: UserRole.Editor,
password: '', password: '',
}); });
onClose(); onClose();
@@ -70,12 +88,14 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
<Select <Select
label="Role" label="Role"
required required
value={formData.role} value={formData.role ? formData.role.toString() : null}
onChange={(value) => setFormData({ ...formData, role: value })} onChange={(value) =>
setFormData({ ...formData, role: value as UserRole })
}
data={[ data={[
{ value: 'admin', label: 'Admin' }, { value: UserRole.Admin, label: 'Admin' },
{ value: 'editor', label: 'Editor' }, { value: UserRole.Editor, label: 'Editor' },
{ value: 'viewer', label: 'Viewer' }, { value: UserRole.Viewer, label: 'Viewer' },
]} ]}
/> />
<PasswordInput <PasswordInput
@@ -93,7 +113,7 @@ const EditUserModal = ({ opened, onClose, onEditUser, loading, user }) => {
<Button variant="default" onClick={onClose}> <Button variant="default" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit} loading={loading}> <Button onClick={() => void handleSubmit} loading={loading}>
Save Changes Save Changes
</Button> </Button>
</Group> </Group>

View File

@@ -1,16 +1,23 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; import { Modal, TextInput, Button, Group, Box } from '@mantine/core';
import { useModalContext } from '../../../contexts/ModalContext'; import { useModalContext } from '../../../contexts/ModalContext';
import { createWorkspace } from '../../../services/api';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import type { Workspace } from '@/types/models';
import { createWorkspace } from '@/api/workspace';
const CreateWorkspaceModal = ({ onWorkspaceCreated }) => { interface CreateWorkspaceModalProps {
const [name, setName] = useState(''); onWorkspaceCreated?: (workspace: Workspace) => Promise<void>;
const [loading, setLoading] = useState(false); }
const CreateWorkspaceModal: React.FC<CreateWorkspaceModalProps> = ({
onWorkspaceCreated,
}) => {
const [name, setName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } = const { createWorkspaceModalVisible, setCreateWorkspaceModalVisible } =
useModalContext(); useModalContext();
const handleSubmit = async () => { const handleSubmit = async (): Promise<void> => {
if (!name.trim()) { if (!name.trim()) {
notifications.show({ notifications.show({
title: 'Error', title: 'Error',
@@ -31,9 +38,9 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
setName(''); setName('');
setCreateWorkspaceModalVisible(false); setCreateWorkspaceModalVisible(false);
if (onWorkspaceCreated) { if (onWorkspaceCreated) {
onWorkspaceCreated(workspace); await onWorkspaceCreated(workspace);
} }
} catch (error) { } catch (_error) {
notifications.show({ notifications.show({
title: 'Error', title: 'Error',
message: 'Failed to create workspace', message: 'Failed to create workspace',
@@ -70,7 +77,7 @@ const CreateWorkspaceModal = ({ onWorkspaceCreated }) => {
> >
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit} loading={loading}> <Button onClick={() => void handleSubmit} loading={loading}>
Create Create
</Button> </Button>
</Group> </Group>

View File

@@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core'; import { Modal, Text, Button, Group, Stack } from '@mantine/core';
const DeleteWorkspaceModal = ({ interface DeleteUserModalProps {
opened: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
workspaceName: string | undefined;
}
const DeleteWorkspaceModal: React.FC<DeleteUserModalProps> = ({
opened, opened,
onClose, onClose,
onConfirm, onConfirm,
@@ -16,15 +23,15 @@ const DeleteWorkspaceModal = ({
> >
<Stack> <Stack>
<Text> <Text>
Are you sure you want to delete workspace "{workspaceName}"? This action Are you sure you want to delete workspace &quot;{workspaceName}&quot;?
cannot be undone and all files in this workspace will be permanently This action cannot be undone and all files in this workspace will be
deleted. permanently deleted.
</Text> </Text>
<Group justify="flex-end" mt="xl"> <Group justify="flex-end" mt="xl">
<Button variant="default" onClick={onClose}> <Button variant="default" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button color="red" onClick={onConfirm}> <Button color="red" onClick={() => void onConfirm}>
Delete Workspace Delete Workspace
</Button> </Button>
</Group> </Group>

View File

@@ -17,15 +17,19 @@ import {
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import AccountSettings from '../settings/account/AccountSettings'; import AccountSettings from '../settings/account/AccountSettings';
import AdminDashboard from '../settings/admin/AdminDashboard'; import AdminDashboard from '../settings/admin/AdminDashboard';
import { UserRole } from '@/types/models';
import { getHoverStyle } from '@/utils/themeStyles';
const UserMenu = () => { const UserMenu: React.FC = () => {
const [accountSettingsOpened, setAccountSettingsOpened] = useState(false); const [accountSettingsOpened, setAccountSettingsOpened] =
const [adminDashboardOpened, setAdminDashboardOpened] = useState(false); useState<boolean>(false);
const [opened, setOpened] = useState(false); const [adminDashboardOpened, setAdminDashboardOpened] =
useState<boolean>(false);
const [opened, setOpened] = useState<boolean>(false);
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const handleLogout = () => { const handleLogout = async (): Promise<void> => {
logout(); await logout();
}; };
return ( return (
@@ -57,7 +61,7 @@ const UserMenu = () => {
</Avatar> </Avatar>
<div> <div>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{user.displayName || user.email} {user?.displayName || user?.email}
</Text> </Text>
</div> </div>
</Group> </Group>
@@ -72,15 +76,7 @@ const UserMenu = () => {
}} }}
px="sm" px="sm"
py="xs" py="xs"
style={(theme) => ({ style={(theme) => getHoverStyle(theme)}
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
> >
<Group> <Group>
<IconSettings size={16} /> <IconSettings size={16} />
@@ -88,7 +84,7 @@ const UserMenu = () => {
</Group> </Group>
</UnstyledButton> </UnstyledButton>
{user.role === 'admin' && ( {user?.role === UserRole.Admin && (
<UnstyledButton <UnstyledButton
onClick={() => { onClick={() => {
setAdminDashboardOpened(true); setAdminDashboardOpened(true);
@@ -96,15 +92,7 @@ const UserMenu = () => {
}} }}
px="sm" px="sm"
py="xs" py="xs"
style={(theme) => ({ style={(theme) => getHoverStyle(theme)}
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
> >
<Group> <Group>
<IconUsers size={16} /> <IconUsers size={16} />
@@ -114,19 +102,14 @@ const UserMenu = () => {
)} )}
<UnstyledButton <UnstyledButton
onClick={handleLogout} onClick={() => {
void handleLogout();
setOpened(false);
}}
px="sm" px="sm"
py="xs" py="xs"
color="red" color="red"
style={(theme) => ({ style={(theme) => getHoverStyle(theme)}
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
})}
> >
<Group> <Group>
<IconLogout size={16} color="red" /> <IconLogout size={16} color="red" />

View File

@@ -15,21 +15,26 @@ import {
useMantineTheme, useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react'; import { IconFolders, IconSettings, IconFolderPlus } from '@tabler/icons-react';
import { useWorkspace } from '../../contexts/WorkspaceContext'; import { useWorkspace } from '../../hooks/useWorkspace';
import { useModalContext } from '../../contexts/ModalContext'; import { useModalContext } from '../../contexts/ModalContext';
import { listWorkspaces } from '../../services/api'; import { listWorkspaces } from '../../api/workspace';
import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal'; import CreateWorkspaceModal from '../modals/workspace/CreateWorkspaceModal';
import type { Workspace } from '@/types/models';
import {
getConditionalColor,
getWorkspacePaperStyle,
} from '@/utils/themeStyles';
const WorkspaceSwitcher = () => { const WorkspaceSwitcher: React.FC = () => {
const { currentWorkspace, switchWorkspace } = useWorkspace(); const { currentWorkspace, switchWorkspace } = useWorkspace();
const { setSettingsModalVisible, setCreateWorkspaceModalVisible } = const { setSettingsModalVisible, setCreateWorkspaceModalVisible } =
useModalContext(); useModalContext();
const [workspaces, setWorkspaces] = useState([]); const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState<boolean>(false);
const [popoverOpened, setPopoverOpened] = useState(false); const [popoverOpened, setPopoverOpened] = useState<boolean>(false);
const theme = useMantineTheme(); const theme = useMantineTheme();
const loadWorkspaces = async () => { const loadWorkspaces = async (): Promise<void> => {
setLoading(true); setLoading(true);
try { try {
const list = await listWorkspaces(); const list = await listWorkspaces();
@@ -40,14 +45,16 @@ const WorkspaceSwitcher = () => {
setLoading(false); setLoading(false);
}; };
const handleCreateWorkspace = () => { const handleCreateWorkspace = (): void => {
setPopoverOpened(false); setPopoverOpened(false);
setCreateWorkspaceModalVisible(true); setCreateWorkspaceModalVisible(true);
}; };
const handleWorkspaceCreated = async (newWorkspace) => { const handleWorkspaceCreated = async (
newWorkspace: Workspace
): Promise<void> => {
await loadWorkspaces(); await loadWorkspaces();
switchWorkspace(newWorkspace.name); await switchWorkspace(newWorkspace.name);
}; };
return ( return (
@@ -64,7 +71,7 @@ const WorkspaceSwitcher = () => {
onClick={() => { onClick={() => {
setPopoverOpened((o) => !o); setPopoverOpened((o) => !o);
if (!popoverOpened) { if (!popoverOpened) {
loadWorkspaces(); void loadWorkspaces();
} }
}} }}
> >
@@ -108,24 +115,15 @@ const WorkspaceSwitcher = () => {
key={workspace.name} key={workspace.name}
p="xs" p="xs"
withBorder withBorder
style={{ style={(theme) =>
backgroundColor: isSelected getWorkspacePaperStyle(theme, isSelected)
? theme.colors.blue[ }
theme.colorScheme === 'dark' ? 8 : 1
]
: undefined,
borderColor: isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 7 : 5
]
: undefined,
}}
> >
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<UnstyledButton <UnstyledButton
style={{ flex: 1 }} style={{ flex: 1 }}
onClick={() => { onClick={() => {
switchWorkspace(workspace.name); void switchWorkspace(workspace.name);
setPopoverOpened(false); setPopoverOpened(false);
}} }}
> >
@@ -134,25 +132,13 @@ const WorkspaceSwitcher = () => {
size="sm" size="sm"
fw={500} fw={500}
truncate truncate
c={ c={isSelected ? 'blue' : 'inherit'}
isSelected
? theme.colors.blue[
theme.colorScheme === 'dark' ? 0 : 9
]
: undefined
}
> >
{workspace.name} {workspace.name}
</Text> </Text>
<Text <Text
size="xs" size="xs"
c={ c={getConditionalColor(theme, isSelected)}
isSelected
? theme.colorScheme === 'dark'
? theme.colors.blue[2]
: theme.colors.blue[7]
: 'dimmed'
}
> >
{new Date( {new Date(
workspace.createdAt workspace.createdAt
@@ -165,11 +151,7 @@ const WorkspaceSwitcher = () => {
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="lg" size="lg"
color={ color={getConditionalColor(theme, true)}
theme.colorScheme === 'dark'
? 'blue.2'
: 'blue.7'
}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSettingsModalVisible(true); setSettingsModalVisible(true);

View File

@@ -1,7 +1,11 @@
import React from 'react'; import React from 'react';
import { Accordion, Title } from '@mantine/core'; import { Accordion, Title } from '@mantine/core';
const AccordionControl = ({ children }) => ( interface AccordionControlProps {
children: React.ReactNode;
}
const AccordionControl: React.FC<AccordionControlProps> = ({ children }) => (
<Accordion.Control> <Accordion.Control>
<Title order={4}>{children}</Title> <Title order={4}>{children}</Title>
</Accordion.Control> </Accordion.Control>

View File

@@ -16,24 +16,39 @@ import SecuritySettings from './SecuritySettings';
import ProfileSettings from './ProfileSettings'; import ProfileSettings from './ProfileSettings';
import DangerZoneSettings from './DangerZoneSettings'; import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl'; import AccordionControl from '../AccordionControl';
import {
type UserProfileSettings,
type ProfileSettingsState,
type SettingsAction,
SettingsActionType,
} from '@/types/models';
import { getAccordionStyles } from '@/utils/themeStyles';
interface AccountSettingsProps {
opened: boolean;
onClose: () => void;
}
// Reducer for managing settings state // Reducer for managing settings state
const initialState = { const initialState: ProfileSettingsState = {
localSettings: {}, localSettings: {},
initialSettings: {}, initialSettings: {},
hasUnsavedChanges: false, hasUnsavedChanges: false,
}; };
function settingsReducer(state, action) { function settingsReducer(
state: ProfileSettingsState,
action: SettingsAction<UserProfileSettings>
): ProfileSettingsState {
switch (action.type) { switch (action.type) {
case 'INIT_SETTINGS': case SettingsActionType.INIT_SETTINGS:
return { return {
...state, ...state,
localSettings: action.payload, localSettings: action.payload || {},
initialSettings: action.payload, initialSettings: action.payload || {},
hasUnsavedChanges: false, hasUnsavedChanges: false,
}; };
case 'UPDATE_LOCAL_SETTINGS': case SettingsActionType.UPDATE_LOCAL_SETTINGS: {
const newLocalSettings = { ...state.localSettings, ...action.payload }; const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges = const hasChanges =
JSON.stringify(newLocalSettings) !== JSON.stringify(newLocalSettings) !==
@@ -43,7 +58,8 @@ function settingsReducer(state, action) {
localSettings: newLocalSettings, localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges, hasUnsavedChanges: hasChanges,
}; };
case 'MARK_SAVED': }
case SettingsActionType.MARK_SAVED:
return { return {
...state, ...state,
initialSettings: state.localSettings, initialSettings: state.localSettings,
@@ -54,39 +70,51 @@ function settingsReducer(state, action) {
} }
} }
const AccountSettings = ({ opened, onClose }) => { const AccountSettings: React.FC<AccountSettingsProps> = ({
opened,
onClose,
}) => {
const { user, refreshUser } = useAuth(); const { user, refreshUser } = useAuth();
const { loading, updateProfile } = useProfileSettings(); const { loading, updateProfile } = useProfileSettings();
const [state, dispatch] = useReducer(settingsReducer, initialState); const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true); const isInitialMount = useRef<boolean>(true);
const [emailModalOpened, setEmailModalOpened] = useState(false); const [emailModalOpened, setEmailModalOpened] = useState<boolean>(false);
// Initialize settings on mount // Initialize settings on mount
useEffect(() => { useEffect(() => {
if (isInitialMount.current && user) { if (isInitialMount.current && user) {
isInitialMount.current = false; isInitialMount.current = false;
const settings = { const settings: UserProfileSettings = {
displayName: user.displayName, displayName: user.displayName || '',
email: user.email, email: user.email,
currentPassword: '', currentPassword: '',
newPassword: '', newPassword: '',
}; };
dispatch({ type: 'INIT_SETTINGS', payload: settings }); dispatch({
type: SettingsActionType.INIT_SETTINGS,
payload: settings,
});
} }
}, [user]); }, [user]);
const handleInputChange = (key, value) => { const handleInputChange = (
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); key: keyof UserProfileSettings,
value: string
): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { [key]: value } as UserProfileSettings,
});
}; };
const handleSubmit = async () => { const handleSubmit = async (): Promise<void> => {
const updates = {}; const updates: UserProfileSettings = {};
const needsPasswordConfirmation = const needsPasswordConfirmation =
state.localSettings.email !== state.initialSettings.email; state.localSettings.email !== state.initialSettings.email;
// Add display name if changed // Add display name if changed
if (state.localSettings.displayName !== state.initialSettings.displayName) { if (state.localSettings.displayName !== state.initialSettings.displayName) {
updates.displayName = state.localSettings.displayName; updates.displayName = state.localSettings.displayName || '';
} }
// Handle password change // Handle password change
@@ -106,17 +134,17 @@ const AccountSettings = ({ opened, onClose }) => {
// If we're only changing display name or have password already provided, proceed directly // If we're only changing display name or have password already provided, proceed directly
if (!needsPasswordConfirmation || state.localSettings.currentPassword) { if (!needsPasswordConfirmation || state.localSettings.currentPassword) {
if (needsPasswordConfirmation) { if (needsPasswordConfirmation) {
updates.email = state.localSettings.email; updates.email = state.localSettings.email || '';
// If we don't have a password change, we still need to include the current password for email change // If we don't have a password change, we still need to include the current password for email change
if (!updates.currentPassword) { if (!updates.currentPassword) {
updates.currentPassword = state.localSettings.currentPassword; updates.currentPassword = state.localSettings.currentPassword || '';
} }
} }
const result = await updateProfile(updates); const updatedUser = await updateProfile(updates);
if (result.success) { if (updatedUser) {
await refreshUser(); await refreshUser();
dispatch({ type: 'MARK_SAVED' }); dispatch({ type: SettingsActionType.MARK_SAVED });
onClose(); onClose();
} }
} else { } else {
@@ -125,17 +153,20 @@ const AccountSettings = ({ opened, onClose }) => {
} }
}; };
const handleEmailConfirm = async (password) => { const handleEmailConfirm = async (password: string): Promise<void> => {
const updates = { const updates: UserProfileSettings = {
...state.localSettings, ...state.localSettings,
currentPassword: password, currentPassword: password,
}; };
// Remove any undefined/empty values // Remove any undefined/empty values
Object.keys(updates).forEach((key) => { Object.keys(updates).forEach((key) => {
if (updates[key] === undefined || updates[key] === '') { const typedKey = key as keyof UserProfileSettings;
delete updates[key]; if (updates[typedKey] === undefined || updates[typedKey] === '') {
delete updates[typedKey];
} }
}); });
// Remove keys that haven't changed // Remove keys that haven't changed
if (updates.displayName === state.initialSettings.displayName) { if (updates.displayName === state.initialSettings.displayName) {
delete updates.displayName; delete updates.displayName;
@@ -144,10 +175,10 @@ const AccountSettings = ({ opened, onClose }) => {
delete updates.email; delete updates.email;
} }
const result = await updateProfile(updates); const updatedUser = await updateProfile(updates);
if (result.success) { if (updatedUser) {
await refreshUser(); await refreshUser();
dispatch({ type: 'MARK_SAVED' }); dispatch({ type: SettingsActionType.MARK_SAVED });
setEmailModalOpened(false); setEmailModalOpened(false);
onClose(); onClose();
} }
@@ -162,7 +193,7 @@ const AccountSettings = ({ opened, onClose }) => {
centered centered
size="lg" size="lg"
> >
<Stack spacing="xl"> <Stack gap="xl">
{state.hasUnsavedChanges && ( {state.hasUnsavedChanges && (
<Badge color="yellow" variant="light"> <Badge color="yellow" variant="light">
Unsaved Changes Unsaved Changes
@@ -172,25 +203,7 @@ const AccountSettings = ({ opened, onClose }) => {
<Accordion <Accordion
defaultValue={['profile', 'security', 'danger']} defaultValue={['profile', 'security', 'danger']}
multiple multiple
styles={(theme) => ({ styles={(theme) => getAccordionStyles(theme)}
control: {
paddingTop: theme.spacing.md,
paddingBottom: theme.spacing.md,
},
item: {
borderBottom: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
'&[data-active]': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
},
},
})}
> >
<Accordion.Item value="profile"> <Accordion.Item value="profile">
<AccordionControl>Profile</AccordionControl> <AccordionControl>Profile</AccordionControl>
@@ -225,7 +238,7 @@ const AccountSettings = ({ opened, onClose }) => {
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={() => void handleSubmit}
loading={loading} loading={loading}
disabled={!state.hasUnsavedChanges} disabled={!state.hasUnsavedChanges}
> >
@@ -239,7 +252,7 @@ const AccountSettings = ({ opened, onClose }) => {
opened={emailModalOpened} opened={emailModalOpened}
onClose={() => setEmailModalOpened(false)} onClose={() => setEmailModalOpened(false)}
onConfirm={handleEmailConfirm} onConfirm={handleEmailConfirm}
email={state.localSettings.email} email={state.localSettings.email || ''}
/> />
</> </>
); );

View File

@@ -4,16 +4,16 @@ import DeleteAccountModal from '../../modals/account/DeleteAccountModal';
import { useAuth } from '../../../contexts/AuthContext'; import { useAuth } from '../../../contexts/AuthContext';
import { useProfileSettings } from '../../../hooks/useProfileSettings'; import { useProfileSettings } from '../../../hooks/useProfileSettings';
const DangerZoneSettings = () => { const DangerZoneSettings: React.FC = () => {
const { logout } = useAuth(); const { logout } = useAuth();
const { deleteAccount } = useProfileSettings(); const { deleteAccount } = useProfileSettings();
const [deleteModalOpened, setDeleteModalOpened] = useState(false); const [deleteModalOpened, setDeleteModalOpened] = useState<boolean>(false);
const handleDelete = async (password) => { const handleDelete = async (password: string): Promise<void> => {
const result = await deleteAccount(password); const success = await deleteAccount(password);
if (result.success) { if (success) {
setDeleteModalOpened(false); setDeleteModalOpened(false);
logout(); await logout();
} }
}; };

View File

@@ -1,9 +1,18 @@
import React from 'react'; import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core'; import { Box, Stack, TextInput } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models';
const ProfileSettings = ({ settings, onInputChange }) => ( interface ProfileSettingsProps {
settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void;
}
const ProfileSettingsComponent: React.FC<ProfileSettingsProps> = ({
settings,
onInputChange,
}) => (
<Box> <Box>
<Stack spacing="md"> <Stack gap="md">
<TextInput <TextInput
label="Display Name" label="Display Name"
value={settings.displayName || ''} value={settings.displayName || ''}
@@ -20,4 +29,4 @@ const ProfileSettings = ({ settings, onInputChange }) => (
</Box> </Box>
); );
export default ProfileSettings; export default ProfileSettingsComponent;

View File

@@ -1,11 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Box, PasswordInput, Stack, Text } from '@mantine/core'; import { Box, PasswordInput, Stack, Text } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models';
const SecuritySettings = ({ settings, onInputChange }) => { interface SecuritySettingsProps {
settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void;
}
type PasswordField = 'currentPassword' | 'newPassword' | 'confirmNewPassword';
const SecuritySettings: React.FC<SecuritySettingsProps> = ({
settings,
onInputChange,
}) => {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const handlePasswordChange = (field, value) => { const handlePasswordChange = (field: PasswordField, value: string) => {
if (field === 'confirmNewPassword') { if (field === 'confirmNewPassword') {
setConfirmPassword(value); setConfirmPassword(value);
// Check if passwords match when either password field changes // Check if passwords match when either password field changes
@@ -27,7 +38,7 @@ const SecuritySettings = ({ settings, onInputChange }) => {
return ( return (
<Box> <Box>
<Stack spacing="md"> <Stack gap="md">
<PasswordInput <PasswordInput
label="Current Password" label="Current Password"
value={settings.currentPassword || ''} value={settings.currentPassword || ''}
@@ -55,7 +66,7 @@ const SecuritySettings = ({ settings, onInputChange }) => {
/> />
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Password must be at least 8 characters long. Leave password fields Password must be at least 8 characters long. Leave password fields
empty if you don't want to change it. empty if you don&apos;t want to change it.
</Text> </Text>
</Stack> </Stack>
</Box> </Box>

View File

@@ -6,13 +6,23 @@ import AdminUsersTab from './AdminUsersTab';
import AdminWorkspacesTab from './AdminWorkspacesTab'; import AdminWorkspacesTab from './AdminWorkspacesTab';
import AdminStatsTab from './AdminStatsTab'; import AdminStatsTab from './AdminStatsTab';
const AdminDashboard = ({ opened, onClose }) => { interface AdminDashboardProps {
opened: boolean;
onClose: () => void;
}
type AdminTabValue = 'users' | 'workspaces' | 'stats';
const AdminDashboard: React.FC<AdminDashboardProps> = ({ opened, onClose }) => {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState('users'); const [activeTab, setActiveTab] = useState<AdminTabValue>('users');
return ( return (
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard"> <Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
<Tabs value={activeTab} onChange={setActiveTab}> <Tabs
value={activeTab}
onChange={(value) => setActiveTab(value as AdminTabValue)}
>
<Tabs.List> <Tabs.List>
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}> <Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
Users Users
@@ -26,7 +36,7 @@ const AdminDashboard = ({ opened, onClose }) => {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="users" pt="md"> <Tabs.Panel value="users" pt="md">
<AdminUsersTab currentUser={currentUser} /> {currentUser && <AdminUsersTab currentUser={currentUser} />}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="workspaces" pt="md"> <Tabs.Panel value="workspaces" pt="md">

View File

@@ -4,8 +4,13 @@ import { IconAlertCircle } from '@tabler/icons-react';
import { useAdminData } from '../../../hooks/useAdminData'; import { useAdminData } from '../../../hooks/useAdminData';
import { formatBytes } from '../../../utils/formatBytes'; import { formatBytes } from '../../../utils/formatBytes';
const AdminStatsTab = () => { interface StatsRow {
const { data: stats, loading, error } = useAdminData('stats'); label: string;
value: string | number;
}
const AdminStatsTab: React.FC = () => {
const { data: stats, loading, error } = useAdminData<'stats'>('stats');
if (loading) { if (loading) {
return <LoadingOverlay visible={true} />; return <LoadingOverlay visible={true} />;
@@ -19,7 +24,7 @@ const AdminStatsTab = () => {
); );
} }
const statsRows = [ const statsRows: StatsRow[] = [
{ label: 'Total Users', value: stats.totalUsers }, { label: 'Total Users', value: stats.totalUsers },
{ label: 'Active Users', value: stats.activeUsers }, { label: 'Active Users', value: stats.activeUsers },
{ label: 'Total Workspaces', value: stats.totalWorkspaces }, { label: 'Total Workspaces', value: stats.totalWorkspaces },
@@ -33,7 +38,7 @@ const AdminStatsTab = () => {
System Statistics System Statistics
</Text> </Text>
<Table striped highlightOnHover withBorder> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Metric</Table.Th> <Table.Th>Metric</Table.Th>

View File

@@ -20,8 +20,14 @@ import { useUserAdmin } from '../../../hooks/useUserAdmin';
import CreateUserModal from '../../modals/user/CreateUserModal'; import CreateUserModal from '../../modals/user/CreateUserModal';
import EditUserModal from '../../modals/user/EditUserModal'; import EditUserModal from '../../modals/user/EditUserModal';
import DeleteUserModal from '../../modals/user/DeleteUserModal'; import DeleteUserModal from '../../modals/user/DeleteUserModal';
import type { User } from '@/types/models';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
const AdminUsersTab = ({ currentUser }) => { interface AdminUsersTabProps {
currentUser: User;
}
const AdminUsersTab: React.FC<AdminUsersTabProps> = ({ currentUser }) => {
const { const {
users, users,
loading, loading,
@@ -31,19 +37,24 @@ const AdminUsersTab = ({ currentUser }) => {
delete: deleteUser, delete: deleteUser,
} = useUserAdmin(); } = useUserAdmin();
const [createModalOpened, setCreateModalOpened] = useState(false); const [createModalOpened, setCreateModalOpened] = useState<boolean>(false);
const [editModalData, setEditModalData] = useState(null); const [editModalData, setEditModalData] = useState<User | null>(null);
const [deleteModalData, setDeleteModalData] = useState(null); const [deleteModalData, setDeleteModalData] = useState<User | null>(null);
const handleCreateUser = async (userData) => { const handleCreateUser = async (
userData: CreateUserRequest
): Promise<boolean> => {
return await create(userData); return await create(userData);
}; };
const handleEditUser = async (id, userData) => { const handleEditUser = async (
id: number,
userData: UpdateUserRequest
): Promise<boolean> => {
return await update(id, userData); return await update(id, userData);
}; };
const handleDeleteClick = (user) => { const handleDeleteClick = (user: User): void => {
if (user.id === currentUser.id) { if (user.id === currentUser.id) {
notifications.show({ notifications.show({
title: 'Error', title: 'Error',
@@ -55,20 +66,20 @@ const AdminUsersTab = ({ currentUser }) => {
setDeleteModalData(user); setDeleteModalData(user);
}; };
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async (): Promise<void> => {
if (!deleteModalData) return; if (!deleteModalData) return;
const result = await deleteUser(deleteModalData.id); const success = await deleteUser(deleteModalData.id);
if (result.success) { if (success) {
setDeleteModalData(null); setDeleteModalData(null);
} }
}; };
const rows = users.map((user) => ( const renderUserRow = (user: User) => (
<Table.Tr key={user.id}> <Table.Tr key={user.id}>
<Table.Td>{user.email}</Table.Td> <Table.Td>{user.email}</Table.Td>
<Table.Td>{user.displayName}</Table.Td> <Table.Td>{user.displayName}</Table.Td>
<Table.Td> <Table.Td>
<Text transform="capitalize">{user.role}</Text> <Text style={{ textTransform: 'capitalize' }}>{user.role}</Text>
</Table.Td> </Table.Td>
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td> <Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td> <Table.Td>
@@ -91,7 +102,7 @@ const AdminUsersTab = ({ currentUser }) => {
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
)); );
return ( return (
<Box pos="relative"> <Box pos="relative">
@@ -130,7 +141,7 @@ const AdminUsersTab = ({ currentUser }) => {
<Table.Th style={{ width: 100 }}>Actions</Table.Th> <Table.Th style={{ width: 100 }}>Actions</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody>{rows}</Table.Tbody> <Table.Tbody>{users.map(renderUserRow)}</Table.Tbody>
</Table> </Table>
<CreateUserModal <CreateUserModal

View File

@@ -1,67 +0,0 @@
import React from 'react';
import {
Table,
Group,
Text,
ActionIcon,
Box,
LoadingOverlay,
Alert,
} from '@mantine/core';
import { IconTrash, IconEdit, IconAlertCircle } from '@tabler/icons-react';
import { useAdminData } from '../../../hooks/useAdminData';
import { formatBytes } from '../../../utils/formatBytes';
const AdminWorkspacesTab = () => {
const { data: workspaces, loading, error } = useAdminData('workspaces');
const rows = workspaces.map((workspace) => (
<Table.Tr key={workspace.id}>
<Table.Td>{workspace.userEmail}</Table.Td>
<Table.Td>{workspace.workspaceName}</Table.Td>
<Table.Td>
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{workspace.totalFiles}</Table.Td>
<Table.Td>{formatBytes(workspace.totalSize)}</Table.Td>
</Table.Tr>
));
return (
<Box pos="relative">
<LoadingOverlay visible={loading} />
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Error"
color="red"
mb="md"
>
{error}
</Alert>
)}
<Group justify="space-between" mb="md">
<Text size="xl" fw={700}>
Workspace Management
</Text>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Owner</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Total Files</Table.Th>
<Table.Th>Total Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Box>
);
};
export default AdminWorkspacesTab;

View File

@@ -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 type { FileCountStats, WorkspaceStats } from '@/types/models';
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 (
<Table.Tr key={workspace.workspaceID}>
<Table.Td>{workspace.userEmail}</Table.Td>
<Table.Td>{workspace.workspaceName}</Table.Td>
<Table.Td>
{new Date(workspace.workspaceCreatedAt).toLocaleDateString()}
</Table.Td>
<Table.Td>{fileStats.totalFiles}</Table.Td>
<Table.Td>{formatBytes(fileStats.totalSize)}</Table.Td>
</Table.Tr>
);
};
return (
<Box pos="relative">
<LoadingOverlay visible={loading} />
{error && (
<Alert
icon={<IconAlertCircle size={16} />}
title="Error"
color="red"
mb="md"
>
{error}
</Alert>
)}
<Group justify="space-between" mb="md">
<Text size="xl" fw={700}>
Workspace Management
</Text>
</Group>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Owner</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Created At</Table.Th>
<Table.Th>Total Files</Table.Th>
<Table.Th>Total Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{!loading && !error && workspaces.map(renderWorkspaceRow)}
</Table.Tbody>
</Table>
</Box>
);
};
export default AdminWorkspacesTab;

View File

@@ -1,24 +0,0 @@
import React from 'react';
import { Text, Switch, Group, Box, Title } from '@mantine/core';
import { useWorkspace } from '../../../contexts/WorkspaceContext';
const AppearanceSettings = ({ themeSettings, onThemeChange }) => {
const { colorScheme, updateColorScheme } = useWorkspace();
const handleThemeChange = () => {
const newTheme = colorScheme === 'dark' ? 'light' : 'dark';
updateColorScheme(newTheme);
onThemeChange(newTheme);
};
return (
<Box mb="md">
<Group justify="space-between" align="center">
<Text size="sm">Dark Mode</Text>
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
</Group>
</Box>
);
};
export default AppearanceSettings;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Text, Switch, Group, Box } from '@mantine/core';
import { useTheme } from '../../../contexts/ThemeContext';
import { Theme } from '@/types/models';
interface AppearanceSettingsProps {
onThemeChange: (newTheme: Theme) => void;
}
const AppearanceSettings: React.FC<AppearanceSettingsProps> = ({
onThemeChange,
}) => {
const { colorScheme, updateColorScheme } = useTheme();
const handleThemeChange = (): void => {
const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark;
updateColorScheme(newTheme);
onThemeChange(newTheme);
};
return (
<Box mb="md">
<Group justify="space-between" align="center">
<Text size="sm">Dark Mode</Text>
<Switch checked={colorScheme === 'dark'} onChange={handleThemeChange} />
</Group>
</Box>
);
};
export default AppearanceSettings;

View File

@@ -1,16 +1,16 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Box, Button, Title } from '@mantine/core'; import { Box, Button } from '@mantine/core';
import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal'; import DeleteWorkspaceModal from '../../modals/workspace/DeleteWorkspaceModal';
import { useWorkspace } from '../../../contexts/WorkspaceContext'; import { useWorkspace } from '../../../hooks/useWorkspace';
import { useModalContext } from '../../../contexts/ModalContext'; import { useModalContext } from '../../../contexts/ModalContext';
const DangerZoneSettings = () => { const DangerZoneSettings: React.FC = () => {
const { currentWorkspace, workspaces, deleteCurrentWorkspace } = const { currentWorkspace, workspaces, deleteCurrentWorkspace } =
useWorkspace(); useWorkspace();
const { setSettingsModalVisible } = useModalContext(); const { setSettingsModalVisible } = useModalContext();
const [deleteModalOpened, setDeleteModalOpened] = useState(false); const [deleteModalOpened, setDeleteModalOpened] = useState<boolean>(false);
const handleDelete = async () => { const handleDelete = async (): Promise<void> => {
await deleteCurrentWorkspace(); await deleteCurrentWorkspace();
setDeleteModalOpened(false); setDeleteModalOpened(false);
setSettingsModalVisible(false); setSettingsModalVisible(false);

View File

@@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import { Text, Switch, Tooltip, Group, Box } from '@mantine/core'; 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<EditorSettingsProps> = ({
autoSave, autoSave,
showHiddenFiles, showHiddenFiles,
onAutoSaveChange, onAutoSaveChange,

View File

@@ -1,7 +1,16 @@
import React from 'react'; import React from 'react';
import { Title, Box, TextInput, Text, Grid } from '@mantine/core'; import { Box, TextInput, Text, Grid } from '@mantine/core';
import type { Workspace } from '@/types/models';
const GeneralSettings = ({ name, onInputChange }) => { interface GeneralSettingsProps {
name: string;
onInputChange: (key: keyof Workspace, value: string) => void;
}
const GeneralSettings: React.FC<GeneralSettingsProps> = ({
name,
onInputChange,
}) => {
return ( return (
<Box mb="md"> <Box mb="md">
<Grid gutter="md" align="center"> <Grid gutter="md" align="center">
@@ -10,7 +19,7 @@ const GeneralSettings = ({ name, onInputChange }) => {
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<TextInput <TextInput
value={name || ''} value={name}
onChange={(event) => onChange={(event) =>
onInputChange('name', event.currentTarget.value) onInputChange('name', event.currentTarget.value)
} }

View File

@@ -8,8 +8,21 @@ import {
Group, Group,
Grid, Grid,
} from '@mantine/core'; } from '@mantine/core';
import type { Workspace } from '@/types/models';
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: string | boolean) => void;
}
const GitSettings: React.FC<GitSettingsProps> = ({
gitEnabled, gitEnabled,
gitUrl, gitUrl,
gitUser, gitUser,
@@ -21,7 +34,7 @@ const GitSettings = ({
onInputChange, onInputChange,
}) => { }) => {
return ( return (
<Stack spacing="md"> <Stack gap="md">
<Grid gutter="md" align="center"> <Grid gutter="md" align="center">
<Grid.Col span={6}> <Grid.Col span={6}>
<Text size="sm">Enable Git Repository</Text> <Text size="sm">Enable Git Repository</Text>

View File

@@ -9,7 +9,7 @@ import {
Accordion, Accordion,
} from '@mantine/core'; } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useWorkspace } from '../../../contexts/WorkspaceContext'; import { useWorkspace } from '../../../hooks/useWorkspace';
import AppearanceSettings from './AppearanceSettings'; import AppearanceSettings from './AppearanceSettings';
import EditorSettings from './EditorSettings'; import EditorSettings from './EditorSettings';
import GitSettings from './GitSettings'; import GitSettings from './GitSettings';
@@ -17,23 +17,39 @@ import GeneralSettings from './GeneralSettings';
import { useModalContext } from '../../../contexts/ModalContext'; import { useModalContext } from '../../../contexts/ModalContext';
import DangerZoneSettings from './DangerZoneSettings'; import DangerZoneSettings from './DangerZoneSettings';
import AccordionControl from '../AccordionControl'; import AccordionControl from '../AccordionControl';
import {
type Theme,
type Workspace,
type SettingsAction,
SettingsActionType,
} from '@/types/models';
import { getAccordionStyles } from '@/utils/themeStyles';
// State and reducer for workspace settings
interface WorkspaceSettingsState {
localSettings: Partial<Workspace>;
initialSettings: Partial<Workspace>;
hasUnsavedChanges: boolean;
}
const initialState = { const initialState: WorkspaceSettingsState = {
localSettings: {}, localSettings: {},
initialSettings: {}, initialSettings: {},
hasUnsavedChanges: false, hasUnsavedChanges: false,
}; };
function settingsReducer(state, action) { function settingsReducer(
state: WorkspaceSettingsState,
action: SettingsAction<Partial<Workspace>>
): WorkspaceSettingsState {
switch (action.type) { switch (action.type) {
case 'INIT_SETTINGS': case SettingsActionType.INIT_SETTINGS:
return { return {
...state, ...state,
localSettings: action.payload, localSettings: action.payload || {},
initialSettings: action.payload, initialSettings: action.payload || {},
hasUnsavedChanges: false, hasUnsavedChanges: false,
}; };
case 'UPDATE_LOCAL_SETTINGS': case SettingsActionType.UPDATE_LOCAL_SETTINGS: {
const newLocalSettings = { ...state.localSettings, ...action.payload }; const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges = const hasChanges =
JSON.stringify(newLocalSettings) !== JSON.stringify(newLocalSettings) !==
@@ -43,7 +59,8 @@ function settingsReducer(state, action) {
localSettings: newLocalSettings, localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges, hasUnsavedChanges: hasChanges,
}; };
case 'MARK_SAVED': }
case SettingsActionType.MARK_SAVED:
return { return {
...state, ...state,
initialSettings: state.localSettings, initialSettings: state.localSettings,
@@ -54,16 +71,16 @@ function settingsReducer(state, action) {
} }
} }
const WorkspaceSettings = () => { const WorkspaceSettings: React.FC = () => {
const { currentWorkspace, updateSettings } = useWorkspace(); const { currentWorkspace, updateSettings } = useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState); const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true); const isInitialMount = useRef<boolean>(true);
useEffect(() => { useEffect(() => {
if (isInitialMount.current) { if (isInitialMount.current && currentWorkspace) {
isInitialMount.current = false; isInitialMount.current = false;
const settings = { const settings: Partial<Workspace> = {
name: currentWorkspace.name, name: currentWorkspace.name,
theme: currentWorkspace.theme, theme: currentWorkspace.theme,
autoSave: currentWorkspace.autoSave, autoSave: currentWorkspace.autoSave,
@@ -77,15 +94,21 @@ const WorkspaceSettings = () => {
gitCommitName: currentWorkspace.gitCommitName, gitCommitName: currentWorkspace.gitCommitName,
gitCommitEmail: currentWorkspace.gitCommitEmail, gitCommitEmail: currentWorkspace.gitCommitEmail,
}; };
dispatch({ type: 'INIT_SETTINGS', payload: settings }); dispatch({ type: SettingsActionType.INIT_SETTINGS, payload: settings });
} }
}, [currentWorkspace]); }, [currentWorkspace]);
const handleInputChange = useCallback((key, value) => { const handleInputChange = useCallback(
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } }); <K extends keyof Workspace>(key: K, value: Workspace[K]): void => {
}, []); dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { [key]: value } as Partial<Workspace>,
});
},
[]
);
const handleSubmit = async () => { const handleSubmit = async (): Promise<void> => {
try { try {
if (!state.localSettings.name?.trim()) { if (!state.localSettings.name?.trim()) {
notifications.show({ notifications.show({
@@ -96,7 +119,7 @@ const WorkspaceSettings = () => {
} }
await updateSettings(state.localSettings); await updateSettings(state.localSettings);
dispatch({ type: 'MARK_SAVED' }); dispatch({ type: SettingsActionType.MARK_SAVED });
notifications.show({ notifications.show({
message: 'Settings saved successfully', message: 'Settings saved successfully',
color: 'green', color: 'green',
@@ -105,7 +128,9 @@ const WorkspaceSettings = () => {
} catch (error) { } catch (error) {
console.error('Failed to save settings:', error); console.error('Failed to save settings:', error);
notifications.show({ notifications.show({
message: 'Failed to save settings: ' + error.message, message:
'Failed to save settings: ' +
(error instanceof Error ? error.message : String(error)),
color: 'red', color: 'red',
}); });
} }
@@ -123,7 +148,7 @@ const WorkspaceSettings = () => {
centered centered
size="lg" size="lg"
> >
<Stack spacing="xl"> <Stack gap="xl">
{state.hasUnsavedChanges && ( {state.hasUnsavedChanges && (
<Badge color="yellow" variant="light"> <Badge color="yellow" variant="light">
Unsaved Changes Unsaved Changes
@@ -134,23 +159,7 @@ const WorkspaceSettings = () => {
defaultValue={['general', 'appearance', 'editor', 'git', 'danger']} defaultValue={['general', 'appearance', 'editor', 'git', 'danger']}
multiple multiple
styles={(theme) => ({ styles={(theme) => ({
control: { ...getAccordionStyles(theme),
paddingTop: theme.spacing.md,
paddingBottom: theme.spacing.md,
},
item: {
borderBottom: `1px solid ${
theme.colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
'&[data-active]': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
},
},
chevron: { chevron: {
'&[data-rotate]': { '&[data-rotate]': {
transform: 'rotate(180deg)', transform: 'rotate(180deg)',
@@ -162,7 +171,7 @@ const WorkspaceSettings = () => {
<AccordionControl>General</AccordionControl> <AccordionControl>General</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<GeneralSettings <GeneralSettings
name={state.localSettings.name} name={state.localSettings.name || ''}
onInputChange={handleInputChange} onInputChange={handleInputChange}
/> />
</Accordion.Panel> </Accordion.Panel>
@@ -172,9 +181,8 @@ const WorkspaceSettings = () => {
<AccordionControl>Appearance</AccordionControl> <AccordionControl>Appearance</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<AppearanceSettings <AppearanceSettings
themeSettings={state.localSettings.theme} onThemeChange={(newTheme: string) =>
onThemeChange={(newTheme) => handleInputChange('theme', newTheme as Theme)
handleInputChange('theme', newTheme)
} }
/> />
</Accordion.Panel> </Accordion.Panel>
@@ -184,12 +192,12 @@ const WorkspaceSettings = () => {
<AccordionControl>Editor</AccordionControl> <AccordionControl>Editor</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<EditorSettings <EditorSettings
autoSave={state.localSettings.autoSave} autoSave={state.localSettings.autoSave || false}
onAutoSaveChange={(value) => onAutoSaveChange={(value: boolean) =>
handleInputChange('autoSave', value) handleInputChange('autoSave', value)
} }
showHiddenFiles={state.localSettings.showHiddenFiles} showHiddenFiles={state.localSettings.showHiddenFiles || false}
onShowHiddenFilesChange={(value) => onShowHiddenFilesChange={(value: boolean) =>
handleInputChange('showHiddenFiles', value) handleInputChange('showHiddenFiles', value)
} }
/> />
@@ -200,14 +208,16 @@ const WorkspaceSettings = () => {
<AccordionControl>Git Integration</AccordionControl> <AccordionControl>Git Integration</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<GitSettings <GitSettings
gitEnabled={state.localSettings.gitEnabled} gitEnabled={state.localSettings.gitEnabled || false}
gitUrl={state.localSettings.gitUrl} gitUrl={state.localSettings.gitUrl || ''}
gitUser={state.localSettings.gitUser} gitUser={state.localSettings.gitUser || ''}
gitToken={state.localSettings.gitToken} gitToken={state.localSettings.gitToken || ''}
gitAutoCommit={state.localSettings.gitAutoCommit} gitAutoCommit={state.localSettings.gitAutoCommit || false}
gitCommitMsgTemplate={state.localSettings.gitCommitMsgTemplate} gitCommitMsgTemplate={
gitCommitName={state.localSettings.gitCommitName} state.localSettings.gitCommitMsgTemplate || ''
gitCommitEmail={state.localSettings.gitCommitEmail} }
gitCommitName={state.localSettings.gitCommitName || ''}
gitCommitEmail={state.localSettings.gitCommitEmail || ''}
onInputChange={handleInputChange} onInputChange={handleInputChange}
/> />
</Accordion.Panel> </Accordion.Panel>
@@ -225,7 +235,7 @@ const WorkspaceSettings = () => {
<Button variant="default" onClick={handleClose}> <Button variant="default" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSubmit}>Save Changes</Button> <Button onClick={() => void handleSubmit}>Save Changes</Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -1,108 +0,0 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
} from 'react';
import { notifications } from '@mantine/notifications';
import * as authApi from '../services/authApi';
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -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 type { User } from '@/types/models';
interface AuthContextType {
user: User | null;
loading: boolean;
initialized: boolean;
login: (email: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
refreshToken: () => Promise<boolean>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthProviderProps {
children: React.ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [initialized, setInitialized] = useState<boolean>(false);
// Load user data on mount
useEffect(() => {
const initializeAuth = async (): Promise<void> => {
try {
const userData = await getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to initialize auth:', error);
} finally {
setLoading(false);
setInitialized(true);
}
};
void initializeAuth();
}, []);
const login = useCallback(
async (email: string, password: string): Promise<boolean> => {
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<void> => {
try {
await apiLogout();
} catch (error) {
console.error('Logout failed:', error);
} finally {
setUser(null);
}
}, []);
const refreshToken = useCallback(async (): Promise<boolean> => {
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<void> => {
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -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 (
<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
);
};
export const useModalContext = () => useContext(ModalContext);

View File

@@ -0,0 +1,67 @@
import React, {
type ReactNode,
createContext,
useContext,
useState,
} from 'react';
interface ModalContextType {
newFileModalVisible: boolean;
setNewFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
deleteFileModalVisible: boolean;
setDeleteFileModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
commitMessageModalVisible: boolean;
setCommitMessageModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
settingsModalVisible: boolean;
setSettingsModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
switchWorkspaceModalVisible: boolean;
setSwitchWorkspaceModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
createWorkspaceModalVisible: boolean;
setCreateWorkspaceModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
// Create the context with a default undefined value
const ModalContext = createContext<ModalContextType | null>(null);
interface ModalProviderProps {
children: ReactNode;
}
export const ModalProvider: React.FC<ModalProviderProps> = ({ 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 (
<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
);
};
export const useModalContext = (): ModalContextType => {
const context = useContext(ModalContext);
if (context === null) {
throw new Error('useModalContext must be used within a ModalProvider');
}
return context;
};

View File

@@ -0,0 +1,46 @@
import React, {
createContext,
useContext,
useCallback,
type ReactNode,
} from 'react';
import { useMantineColorScheme, type MantineColorScheme } from '@mantine/core';
interface ThemeContextType {
colorScheme: MantineColorScheme;
updateColorScheme: (newTheme: MantineColorScheme) => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { colorScheme, setColorScheme } = useMantineColorScheme();
const updateColorScheme = useCallback(
(newTheme: MantineColorScheme): void => {
setColorScheme(newTheme);
},
[setColorScheme]
);
const value: ThemeContextType = {
colorScheme,
updateColorScheme,
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@@ -1,211 +0,0 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { useMantineColorScheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
fetchLastWorkspaceName,
getWorkspace,
updateWorkspace,
updateLastWorkspaceName,
deleteWorkspace,
listWorkspaces,
} from '../services/api';
import { DEFAULT_WORKSPACE_SETTINGS } from '../utils/constants';
const WorkspaceContext = createContext();
export const WorkspaceProvider = ({ children }) => {
const [currentWorkspace, setCurrentWorkspace] = useState(null);
const [workspaces, setWorkspaces] = useState([]);
const [loading, setLoading] = useState(true);
const { colorScheme, setColorScheme } = useMantineColorScheme();
const loadWorkspaces = useCallback(async () => {
try {
const workspaceList = await listWorkspaces();
setWorkspaces(workspaceList);
return workspaceList;
} catch (error) {
console.error('Failed to load workspaces:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspaces list',
color: 'red',
});
return [];
}
}, []);
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 loadFirstAvailableWorkspace = useCallback(async () => {
try {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace',
color: 'red',
});
}
}, []);
useEffect(() => {
const initializeWorkspace = async () => {
try {
const { lastWorkspaceName } = await fetchLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to initialize workspace:', error);
await loadFirstAvailableWorkspace();
} finally {
setLoading(false);
}
};
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 deleteCurrentWorkspace = useCallback(async () => {
if (!currentWorkspace) return;
try {
const allWorkspaces = await loadWorkspaces();
if (allWorkspaces.length <= 1) {
notifications.show({
title: 'Error',
message:
'Cannot delete the last workspace. At least one workspace must exist.',
color: 'red',
});
return;
}
// Delete workspace and get the next workspace ID
const response = await deleteWorkspace(currentWorkspace.name);
// Load the new workspace data
await loadWorkspaceData(response.nextWorkspaceName);
notifications.show({
title: 'Success',
message: 'Workspace deleted successfully',
color: 'green',
});
await loadWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete workspace',
color: 'red',
});
}
}, [currentWorkspace]);
const updateSettings = useCallback(
async (newSettings) => {
if (!currentWorkspace) return;
try {
const updatedWorkspace = {
...currentWorkspace,
...newSettings,
};
const response = await updateWorkspace(
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);
setColorScheme(response.theme);
await loadWorkspaces();
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[currentWorkspace, setColorScheme]
);
const updateColorScheme = useCallback(
(newTheme) => {
setColorScheme(newTheme);
},
[setColorScheme]
);
const value = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
updateSettings,
loading,
colorScheme,
updateColorScheme,
switchWorkspace,
deleteCurrentWorkspace,
};
return (
<WorkspaceContext.Provider value={value}>
{children}
</WorkspaceContext.Provider>
);
};
export const useWorkspace = () => {
const context = useContext(WorkspaceContext);
if (context === undefined) {
throw new Error('useWorkspace must be used within a WorkspaceProvider');
}
return context;
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { ThemeProvider } from './ThemeContext';
import { WorkspaceDataProvider } from './WorkspaceDataContext';
import { useWorkspace as useWorkspaceHook } from '../hooks/useWorkspace';
// Re-export the useWorkspace hook directly for backward compatibility
export const useWorkspace = useWorkspaceHook;
interface WorkspaceProviderProps {
children: React.ReactNode;
}
// Create a backward-compatible WorkspaceProvider that composes our new providers
export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
children,
}) => {
return (
<ThemeProvider>
<WorkspaceDataProvider>{children}</WorkspaceDataProvider>
</ThemeProvider>
);
};

View File

@@ -0,0 +1,146 @@
import React, {
type ReactNode,
createContext,
useContext,
useState,
useEffect,
useCallback,
} from 'react';
import { notifications } from '@mantine/notifications';
import { DEFAULT_WORKSPACE_SETTINGS, type Workspace } from '@/types/models';
import {
getWorkspace,
listWorkspaces,
getLastWorkspaceName,
updateLastWorkspaceName,
} from '@/api/workspace';
import { useTheme } from './ThemeContext';
interface WorkspaceDataContextType {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
loading: boolean;
loadWorkspaces: () => Promise<Workspace[]>;
loadWorkspaceData: (workspaceName: string) => Promise<void>;
setCurrentWorkspace: (workspace: Workspace | null) => void;
}
const WorkspaceDataContext = createContext<WorkspaceDataContextType | null>(
null
);
interface WorkspaceDataProviderProps {
children: ReactNode;
}
export const WorkspaceDataProvider: React.FC<WorkspaceDataProviderProps> = ({
children,
}) => {
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(
null
);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const { updateColorScheme } = useTheme();
const loadWorkspaces = useCallback(async (): Promise<Workspace[]> => {
try {
const workspaceList = await listWorkspaces();
setWorkspaces(workspaceList);
return workspaceList;
} catch (error) {
console.error('Failed to load workspaces:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspaces list',
color: 'red',
});
return [];
}
}, []);
const loadWorkspaceData = useCallback(
async (workspaceName: string): Promise<void> => {
try {
const workspace = await getWorkspace(workspaceName);
setCurrentWorkspace(workspace);
updateColorScheme(workspace.theme);
} catch (error) {
console.error('Failed to load workspace data:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace data',
color: 'red',
});
}
},
[updateColorScheme]
);
const loadFirstAvailableWorkspace = useCallback(async (): Promise<void> => {
try {
const allWorkspaces = await listWorkspaces();
if (allWorkspaces.length > 0) {
const firstWorkspace = allWorkspaces[0];
if (!firstWorkspace) throw new Error('No workspaces available');
await updateLastWorkspaceName(firstWorkspace.name);
await loadWorkspaceData(firstWorkspace.name);
}
} catch (error) {
console.error('Failed to load first available workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to load workspace',
color: 'red',
});
}
}, [loadWorkspaceData]);
useEffect(() => {
const initializeWorkspace = async (): Promise<void> => {
try {
const lastWorkspaceName = await getLastWorkspaceName();
if (lastWorkspaceName) {
await loadWorkspaceData(lastWorkspaceName);
} else {
await loadFirstAvailableWorkspace();
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to initialize workspace:', error);
await loadFirstAvailableWorkspace();
} finally {
setLoading(false);
}
};
void initializeWorkspace();
}, [loadFirstAvailableWorkspace, loadWorkspaceData, loadWorkspaces]);
const value: WorkspaceDataContextType = {
currentWorkspace,
workspaces,
settings: currentWorkspace || DEFAULT_WORKSPACE_SETTINGS,
loading,
loadWorkspaces,
loadWorkspaceData,
setCurrentWorkspace,
};
return (
<WorkspaceDataContext.Provider value={value}>
{children}
</WorkspaceDataContext.Provider>
);
};
export const useWorkspaceData = (): WorkspaceDataContextType => {
const context = useContext(WorkspaceDataContext);
if (!context) {
throw new Error(
'useWorkspaceData must be used within a WorkspaceDataProvider'
);
}
return context;
};

View File

@@ -1,48 +0,0 @@
import { useState, useEffect } from 'react';
import { notifications } from '@mantine/notifications';
import { getUsers, getWorkspaces, getSystemStats } from '../services/adminApi';
// 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 };
};

View File

@@ -0,0 +1,88 @@
import { useState, useEffect, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { getUsers, getWorkspaces, getSystemStats } from '@/api/admin';
import type { SystemStats, User, WorkspaceStats } from '@/types/models';
// 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 AdminDataType> = T extends 'stats'
? SystemStats
: T extends 'workspaces'
? WorkspaceStats[]
: T extends 'users'
? User[]
: never;
// Define the return type of the hook
interface AdminDataResult<T extends AdminDataType> {
data: AdminData<T>;
loading: boolean;
error: string | null;
reload: () => Promise<void>;
}
// Hook for admin data fetching (stats and workspaces)
export const useAdminData = <T extends AdminDataType>(
type: T
): AdminDataResult<T> => {
// Initialize with the appropriate empty type
const getInitialData = (): AdminData<T> => {
if (type === 'stats') {
return {} as SystemStats as AdminData<T>;
} else if (type === 'workspaces') {
return [] as WorkspaceStats[] as AdminData<T>;
} else if (type === 'users') {
return [] as User[] as AdminData<T>;
} else {
return [] as unknown as AdminData<T>;
}
};
const [data, setData] = useState<AdminData<T>>(getInitialData());
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const loadData = useCallback(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<T>);
} catch (err) {
const message =
err instanceof Error
? (err as { response?: { data?: { error?: string } } })?.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);
}
}, [type]);
useEffect(() => {
void loadData();
}, [loadData]);
return { data, loading, error, reload: loadData };
};

View File

@@ -1,25 +1,38 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { fetchFileContent } from '../services/api';
import { isImageFile } from '../utils/fileHelpers'; import { isImageFile } from '../utils/fileHelpers';
import { DEFAULT_FILE } from '../utils/constants'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { getFileContent } from '@/api/file';
import { DEFAULT_FILE } from '@/types/models';
export const useFileContent = (selectedFile) => { interface UseFileContentResult {
const { currentWorkspace } = useWorkspace(); content: string;
const [content, setContent] = useState(DEFAULT_FILE.content); setContent: React.Dispatch<React.SetStateAction<string>>;
const [originalContent, setOriginalContent] = useState(DEFAULT_FILE.content); hasUnsavedChanges: boolean;
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); setHasUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
loadFileContent: (filePath: string) => Promise<void>;
handleContentChange: (newContent: string) => void;
}
export const useFileContent = (
selectedFile: string | null
): UseFileContentResult => {
const { currentWorkspace } = useWorkspaceData();
const [content, setContent] = useState<string>(DEFAULT_FILE.content);
const [originalContent, setOriginalContent] = useState<string>(
DEFAULT_FILE.content
);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
const loadFileContent = useCallback( const loadFileContent = useCallback(
async (filePath) => { async (filePath: string) => {
if (!currentWorkspace) return; if (!currentWorkspace) return;
try { try {
let newContent; let newContent: string;
if (filePath === DEFAULT_FILE.path) { if (filePath === DEFAULT_FILE.path) {
newContent = DEFAULT_FILE.content; newContent = DEFAULT_FILE.content;
} else if (!isImageFile(filePath)) { } else if (!isImageFile(filePath)) {
newContent = await fetchFileContent(currentWorkspace.name, filePath); newContent = await getFileContent(currentWorkspace.name, filePath);
} else { } else {
newContent = ''; // Set empty content for image files newContent = ''; // Set empty content for image files
} }
@@ -38,12 +51,12 @@ export const useFileContent = (selectedFile) => {
useEffect(() => { useEffect(() => {
if (selectedFile && currentWorkspace) { if (selectedFile && currentWorkspace) {
loadFileContent(selectedFile); void loadFileContent(selectedFile);
} }
}, [selectedFile, currentWorkspace, loadFileContent]); }, [selectedFile, currentWorkspace, loadFileContent]);
const handleContentChange = useCallback( const handleContentChange = useCallback(
(newContent) => { (newContent: string) => {
setContent(newContent); setContent(newContent);
setHasUnsavedChanges(newContent !== originalContent); setHasUnsavedChanges(newContent !== originalContent);
}, },

View File

@@ -1,16 +1,22 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { fetchFileList } from '../services/api'; import { listFiles } from '../api/file';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import type { FileNode } from '@/types/models';
export const useFileList = () => { interface UseFileListResult {
const [files, setFiles] = useState([]); files: FileNode[];
const { currentWorkspace, loading: workspaceLoading } = useWorkspace(); loadFileList: () => Promise<void>;
}
const loadFileList = useCallback(async () => { export const useFileList = (): UseFileListResult => {
const [files, setFiles] = useState<FileNode[]>([]);
const { currentWorkspace, loading: workspaceLoading } = useWorkspaceData();
const loadFileList = useCallback(async (): Promise<void> => {
if (!currentWorkspace || workspaceLoading) return; if (!currentWorkspace || workspaceLoading) return;
try { try {
const fileList = await fetchFileList(currentWorkspace.name); const fileList = await listFiles(currentWorkspace.name);
if (Array.isArray(fileList)) { if (Array.isArray(fileList)) {
setFiles(fileList); setFiles(fileList);
} else { } else {

View File

@@ -1,16 +1,22 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { DEFAULT_FILE } from '../utils/constants'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useWorkspace } from '../contexts/WorkspaceContext';
import { useLastOpenedFile } from './useLastOpenedFile'; import { useLastOpenedFile } from './useLastOpenedFile';
import { DEFAULT_FILE } from '@/types/models';
export const useFileNavigation = () => { interface UseFileNavigationResult {
const [selectedFile, setSelectedFile] = useState(DEFAULT_FILE.path); selectedFile: string;
const [isNewFile, setIsNewFile] = useState(true); isNewFile: boolean;
const { currentWorkspace } = useWorkspace(); handleFileSelect: (filePath: string | null) => Promise<void>;
}
export const useFileNavigation = (): UseFileNavigationResult => {
const [selectedFile, setSelectedFile] = useState<string>(DEFAULT_FILE.path);
const [isNewFile, setIsNewFile] = useState<boolean>(true);
const { currentWorkspace } = useWorkspaceData();
const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile(); const { loadLastOpenedFile, saveLastOpenedFile } = useLastOpenedFile();
const handleFileSelect = useCallback( const handleFileSelect = useCallback(
async (filePath) => { async (filePath: string | null): Promise<void> => {
const newPath = filePath || DEFAULT_FILE.path; const newPath = filePath || DEFAULT_FILE.path;
setSelectedFile(newPath); setSelectedFile(newPath);
setIsNewFile(!filePath); setIsNewFile(!filePath);
@@ -24,20 +30,20 @@ export const useFileNavigation = () => {
// Load last opened file when workspace changes // Load last opened file when workspace changes
useEffect(() => { useEffect(() => {
const initializeFile = async () => { const initializeFile = async (): Promise<void> => {
setSelectedFile(DEFAULT_FILE.path); setSelectedFile(DEFAULT_FILE.path);
setIsNewFile(true); setIsNewFile(true);
const lastFile = await loadLastOpenedFile(); const lastFile = await loadLastOpenedFile();
if (lastFile) { if (lastFile) {
handleFileSelect(lastFile); await handleFileSelect(lastFile);
} else { } else {
handleFileSelect(null); await handleFileSelect(null);
} }
}; };
if (currentWorkspace) { if (currentWorkspace) {
initializeFile(); void initializeFile();
} }
}, [currentWorkspace, loadLastOpenedFile, handleFileSelect]); }, [currentWorkspace, loadLastOpenedFile, handleFileSelect]);

View File

@@ -1,15 +1,22 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { saveFileContent, deleteFile } from '../services/api'; import { saveFile, deleteFile } from '../api/file';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useGitOperations } from './useGitOperations'; import { useGitOperations } from './useGitOperations';
import { FileAction } from '@/types/models';
export const useFileOperations = () => { interface UseFileOperationsResult {
const { currentWorkspace, settings } = useWorkspace(); handleSave: (filePath: string, content: string) => Promise<boolean>;
handleDelete: (filePath: string) => Promise<boolean>;
handleCreate: (fileName: string, initialContent?: string) => Promise<boolean>;
}
export const useFileOperations = (): UseFileOperationsResult => {
const { currentWorkspace, settings } = useWorkspaceData();
const { handleCommitAndPush } = useGitOperations(); const { handleCommitAndPush } = useGitOperations();
const autoCommit = useCallback( const autoCommit = useCallback(
async (filePath, action) => { async (filePath: string, action: FileAction): Promise<void> => {
if (settings.gitAutoCommit && settings.gitEnabled) { if (settings.gitAutoCommit && settings.gitEnabled) {
let commitMessage = settings.gitCommitMsgTemplate let commitMessage = settings.gitCommitMsgTemplate
.replace('${filename}', filePath) .replace('${filename}', filePath)
@@ -21,21 +28,21 @@ export const useFileOperations = () => {
await handleCommitAndPush(commitMessage); await handleCommitAndPush(commitMessage);
} }
}, },
[settings] [settings, handleCommitAndPush]
); );
const handleSave = useCallback( const handleSave = useCallback(
async (filePath, content) => { async (filePath: string, content: string): Promise<boolean> => {
if (!currentWorkspace) return false; if (!currentWorkspace) return false;
try { try {
await saveFileContent(currentWorkspace.name, filePath, content); await saveFile(currentWorkspace.name, filePath, content);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'File saved successfully', message: 'File saved successfully',
color: 'green', color: 'green',
}); });
autoCommit(filePath, 'update'); await autoCommit(filePath, FileAction.Update);
return true; return true;
} catch (error) { } catch (error) {
console.error('Error saving file:', error); console.error('Error saving file:', error);
@@ -51,7 +58,7 @@ export const useFileOperations = () => {
); );
const handleDelete = useCallback( const handleDelete = useCallback(
async (filePath) => { async (filePath: string): Promise<boolean> => {
if (!currentWorkspace) return false; if (!currentWorkspace) return false;
try { try {
@@ -61,7 +68,7 @@ export const useFileOperations = () => {
message: 'File deleted successfully', message: 'File deleted successfully',
color: 'green', color: 'green',
}); });
autoCommit(filePath, 'delete'); await autoCommit(filePath, FileAction.Delete);
return true; return true;
} catch (error) { } catch (error) {
console.error('Error deleting file:', error); console.error('Error deleting file:', error);
@@ -77,17 +84,17 @@ export const useFileOperations = () => {
); );
const handleCreate = useCallback( const handleCreate = useCallback(
async (fileName, initialContent = '') => { async (fileName: string, initialContent: string = ''): Promise<boolean> => {
if (!currentWorkspace) return false; if (!currentWorkspace) return false;
try { try {
await saveFileContent(currentWorkspace.name, fileName, initialContent); await saveFile(currentWorkspace.name, fileName, initialContent);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'File created successfully', message: 'File created successfully',
color: 'green', color: 'green',
}); });
autoCommit(fileName, 'create'); await autoCommit(fileName, FileAction.Create);
return true; return true;
} catch (error) { } catch (error) {
console.error('Error creating new file:', error); console.error('Error creating new file:', error);

View File

@@ -1,12 +1,18 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { pullChanges, commitAndPush } from '../services/api'; import { pullChanges, commitAndPush } from '../api/git';
import { useWorkspace } from '../contexts/WorkspaceContext'; import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import type { CommitHash } from '@/types/models';
export const useGitOperations = () => { interface UseGitOperationsResult {
const { currentWorkspace, settings } = useWorkspace(); handlePull: () => Promise<boolean>;
handleCommitAndPush: (message: string) => Promise<void>;
}
const handlePull = useCallback(async () => { export const useGitOperations = (): UseGitOperationsResult => {
const { currentWorkspace, settings } = useWorkspaceData();
const handlePull = useCallback(async (): Promise<boolean> => {
if (!currentWorkspace || !settings.gitEnabled) return false; if (!currentWorkspace || !settings.gitEnabled) return false;
try { try {
@@ -29,17 +35,19 @@ export const useGitOperations = () => {
}, [currentWorkspace, settings.gitEnabled]); }, [currentWorkspace, settings.gitEnabled]);
const handleCommitAndPush = useCallback( const handleCommitAndPush = useCallback(
async (message) => { async (message: string): Promise<void> => {
if (!currentWorkspace || !settings.gitEnabled) return false; if (!currentWorkspace || !settings.gitEnabled) return;
const commitHash: CommitHash = await commitAndPush(
currentWorkspace.name,
message
);
try { try {
await commitAndPush(currentWorkspace.name, message);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'Successfully committed and pushed changes', message: 'Successfully committed and pushed changes ' + commitHash,
color: 'green', color: 'green',
}); });
return true; return;
} catch (error) { } catch (error) {
console.error('Failed to commit and push changes:', error); console.error('Failed to commit and push changes:', error);
notifications.show({ notifications.show({
@@ -47,7 +55,7 @@ export const useGitOperations = () => {
message: 'Failed to commit and push changes', message: 'Failed to commit and push changes',
color: 'red', color: 'red',
}); });
return false; return;
} }
}, },
[currentWorkspace, settings.gitEnabled] [currentWorkspace, settings.gitEnabled]

View File

@@ -1,37 +0,0 @@
import { useCallback } from 'react';
import { getLastOpenedFile, updateLastOpenedFile } from '../services/api';
import { useWorkspace } from '../contexts/WorkspaceContext';
export const useLastOpenedFile = () => {
const { currentWorkspace } = useWorkspace();
const loadLastOpenedFile = useCallback(async () => {
if (!currentWorkspace) return null;
try {
const response = await getLastOpenedFile(currentWorkspace.name);
return response.lastOpenedFilePath || null;
} catch (error) {
console.error('Failed to load last opened file:', error);
return null;
}
}, [currentWorkspace]);
const saveLastOpenedFile = useCallback(
async (filePath) => {
if (!currentWorkspace) return;
try {
await updateLastOpenedFile(currentWorkspace.name, filePath);
} catch (error) {
console.error('Failed to save last opened file:', error);
}
},
[currentWorkspace]
);
return {
loadLastOpenedFile,
saveLastOpenedFile,
};
};

View File

@@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { getLastOpenedFile, updateLastOpenedFile } from '../api/file';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
interface UseLastOpenedFileResult {
loadLastOpenedFile: () => Promise<string | null>;
saveLastOpenedFile: (filePath: string) => Promise<void>;
}
export const useLastOpenedFile = (): UseLastOpenedFileResult => {
const { currentWorkspace } = useWorkspaceData();
const loadLastOpenedFile = useCallback(async (): Promise<string | null> => {
if (!currentWorkspace) return null;
try {
const response: string = await getLastOpenedFile(currentWorkspace.name);
return response || null;
} catch (error) {
console.error('Failed to load last opened file:', error);
return null;
}
}, [currentWorkspace]);
const saveLastOpenedFile = useCallback(
async (filePath: string): Promise<void> => {
if (!currentWorkspace) return;
try {
await updateLastOpenedFile(currentWorkspace.name, filePath);
} catch (error) {
console.error('Failed to save last opened file:', error);
}
},
[currentWorkspace]
);
return {
loadLastOpenedFile,
saveLastOpenedFile,
};
};

View File

@@ -1,71 +0,0 @@
import { useState, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { updateProfile, deleteProfile } from '../services/api';
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,
};
}

View File

@@ -0,0 +1,88 @@
import { useState, useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { updateProfile, deleteUser } from '../api/user';
import type { UpdateProfileRequest } from '@/types/api';
import type { User } from '@/types/models';
interface UseProfileSettingsResult {
loading: boolean;
updateProfile: (updates: UpdateProfileRequest) => Promise<User | null>;
deleteAccount: (password: string) => Promise<boolean>;
}
export function useProfileSettings(): UseProfileSettingsResult {
const [loading, setLoading] = useState<boolean>(false);
const handleProfileUpdate = useCallback(
async (updates: UpdateProfileRequest): Promise<User | null> => {
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<boolean> => {
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,
};
}

View File

@@ -1,11 +1,28 @@
import { useAdminData } from './useAdminData'; import { useAdminData } from './useAdminData';
import { createUser, updateUser, deleteUser } from '../services/adminApi'; import {
createUser,
updateUser,
deleteUser as adminDeleteUser,
} from '../api/admin';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import type { User } from '@/types/models';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
export const useUserAdmin = () => { interface UseUserAdminResult {
users: User[];
loading: boolean;
error: string | null;
create: (userData: CreateUserRequest) => Promise<boolean>;
update: (userId: number, userData: UpdateUserRequest) => Promise<boolean>;
delete: (userId: number) => Promise<boolean>;
}
export const useUserAdmin = (): UseUserAdminResult => {
const { data: users, loading, error, reload } = useAdminData('users'); const { data: users, loading, error, reload } = useAdminData('users');
const handleCreate = async (userData) => { const handleCreate = async (
userData: CreateUserRequest
): Promise<boolean> => {
try { try {
await createUser(userData); await createUser(userData);
notifications.show({ notifications.show({
@@ -13,20 +30,23 @@ export const useUserAdmin = () => {
message: 'User created successfully', message: 'User created successfully',
color: 'green', color: 'green',
}); });
reload(); await reload();
return { success: true }; return true;
} catch (err) { } catch (err) {
const message = err.response?.data?.error || err.message; const message = err instanceof Error ? err.message : String(err);
notifications.show({ notifications.show({
title: 'Error', title: 'Error',
message: `Failed to create user: ${message}`, message: `Failed to create user: ${message}`,
color: 'red', color: 'red',
}); });
return { success: false, error: message }; return false;
} }
}; };
const handleUpdate = async (userId, userData) => { const handleUpdate = async (
userId: number,
userData: UpdateUserRequest
): Promise<boolean> => {
try { try {
await updateUser(userId, userData); await updateUser(userId, userData);
notifications.show({ notifications.show({
@@ -34,37 +54,37 @@ export const useUserAdmin = () => {
message: 'User updated successfully', message: 'User updated successfully',
color: 'green', color: 'green',
}); });
reload(); await reload();
return { success: true }; return true;
} catch (err) { } catch (err) {
const message = err.response?.data?.error || err.message; const message = err instanceof Error ? err.message : String(err);
notifications.show({ notifications.show({
title: 'Error', title: 'Error',
message: `Failed to update user: ${message}`, message: `Failed to update user: ${message}`,
color: 'red', color: 'red',
}); });
return { success: false, error: message }; return false;
} }
}; };
const handleDelete = async (userId) => { const handleDelete = async (userId: number): Promise<boolean> => {
try { try {
await deleteUser(userId); await adminDeleteUser(userId);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
message: 'User deleted successfully', message: 'User deleted successfully',
color: 'green', color: 'green',
}); });
reload(); await reload();
return { success: true }; return true;
} catch (err) { } catch (err) {
const message = err.response?.data?.error || err.message; const message = err instanceof Error ? err.message : String(err);
notifications.show({ notifications.show({
title: 'Error', title: 'Error',
message: `Failed to delete user: ${message}`, message: `Failed to delete user: ${message}`,
color: 'red', color: 'red',
}); });
return { success: false, error: message }; return false;
} }
}; };

View File

@@ -0,0 +1,37 @@
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import { useTheme } from '../contexts/ThemeContext';
import { useWorkspaceOperations } from './useWorkspaceOperations';
import type { Workspace, DEFAULT_WORKSPACE_SETTINGS } from '@/types/models';
import type { MantineColorScheme } from '@mantine/core';
interface UseWorkspaceResult {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS;
updateSettings: (newSettings: Partial<Workspace>) => Promise<void>;
loading: boolean;
colorScheme: MantineColorScheme;
updateColorScheme: (newTheme: MantineColorScheme) => void;
switchWorkspace: (workspaceName: string) => Promise<void>;
deleteCurrentWorkspace: () => Promise<void>;
}
export const useWorkspace = (): UseWorkspaceResult => {
const { currentWorkspace, workspaces, settings, loading } =
useWorkspaceData();
const { colorScheme, updateColorScheme } = useTheme();
const { switchWorkspace, deleteCurrentWorkspace, updateSettings } =
useWorkspaceOperations();
return {
currentWorkspace,
workspaces,
settings,
updateSettings,
loading,
colorScheme,
updateColorScheme,
switchWorkspace,
deleteCurrentWorkspace,
};
};

View File

@@ -0,0 +1,117 @@
import { useCallback } from 'react';
import { notifications } from '@mantine/notifications';
import { useWorkspaceData } from '../contexts/WorkspaceDataContext';
import {
updateLastWorkspaceName,
updateWorkspace,
deleteWorkspace,
} from '@/api/workspace';
import { useTheme } from '../contexts/ThemeContext';
import type { Workspace } from '@/types/models';
interface UseWorkspaceOperationsResult {
switchWorkspace: (workspaceName: string) => Promise<void>;
deleteCurrentWorkspace: () => Promise<void>;
updateSettings: (newSettings: Partial<Workspace>) => Promise<void>;
}
export const useWorkspaceOperations = (): UseWorkspaceOperationsResult => {
const {
currentWorkspace,
loadWorkspaceData,
loadWorkspaces,
setCurrentWorkspace,
} = useWorkspaceData();
const { updateColorScheme } = useTheme();
const switchWorkspace = useCallback(
async (workspaceName: string): Promise<void> => {
try {
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',
});
}
},
[loadWorkspaceData, loadWorkspaces]
);
const deleteCurrentWorkspace = useCallback(async (): Promise<void> => {
if (!currentWorkspace) return;
try {
const allWorkspaces = await loadWorkspaces();
if (allWorkspaces.length <= 1) {
notifications.show({
title: 'Error',
message:
'Cannot delete the last workspace. At least one workspace must exist.',
color: 'red',
});
return;
}
// Delete workspace and get the next workspace ID
const nextWorkspaceName: string = await deleteWorkspace(
currentWorkspace.name
);
// Load the new workspace data
await loadWorkspaceData(nextWorkspaceName);
notifications.show({
title: 'Success',
message: 'Workspace deleted successfully',
color: 'green',
});
await loadWorkspaces();
} catch (error) {
console.error('Failed to delete workspace:', error);
notifications.show({
title: 'Error',
message: 'Failed to delete workspace',
color: 'red',
});
}
}, [currentWorkspace, loadWorkspaceData, loadWorkspaces]);
const updateSettings = useCallback(
async (newSettings: Partial<Workspace>): Promise<void> => {
if (!currentWorkspace) return;
try {
const updatedWorkspace = {
...currentWorkspace,
...newSettings,
};
const response = await updateWorkspace(
currentWorkspace.name,
updatedWorkspace
);
setCurrentWorkspace(response);
if (newSettings.theme) {
updateColorScheme(response.theme);
}
await loadWorkspaces();
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
[currentWorkspace, loadWorkspaces, updateColorScheme, setCurrentWorkspace]
);
return {
switchWorkspace,
deleteCurrentWorkspace,
updateSettings,
};
};

View File

@@ -8,6 +8,6 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./index.jsx"></script> <script type="module" src="./index.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,9 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; 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( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <App />

View File

@@ -1,49 +0,0 @@
import { apiCall } from './authApi';
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();
};

View File

@@ -1,172 +0,0 @@
import { API_BASE_URL } from '../utils/constants';
import { apiCall } from './authApi';
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();
};
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();
};
// 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`,
{
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}`;
};
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();
};
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();
};

View File

@@ -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();
};

116
app/src/types/api.ts Normal file
View File

@@ -0,0 +1,116 @@
import { isUser, type User, type UserRole } from './models';
declare global {
interface Window {
API_BASE_URL: string;
}
}
export const API_BASE_URL = window.API_BASE_URL;
/**
* Error response from the API
*/
export interface ErrorResponse {
message: string;
}
/**
* API call options extending the standard RequestInit
*/
export interface ApiCallOptions extends RequestInit {
headers?: HeadersInit;
}
/**
* Login request parameters
*/
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
user: User;
sessionId?: string;
expiresAt?: string; // ISO 8601 string representation of the date
}
export function isLoginResponse(obj: unknown): obj is LoginResponse {
return (
typeof obj === 'object' &&
obj !== null &&
'user' in obj &&
isUser(obj.user) &&
(!('sessionId' in obj) ||
typeof (obj as LoginResponse).sessionId === 'string') &&
(!('expiresAt' in obj) ||
typeof (obj as LoginResponse).expiresAt === 'string')
);
}
// 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;
}
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 UpdateLastOpenedFileRequest {
filePath: string;
}
// 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;
}

287
app/src/types/models.ts Normal file
View File

@@ -0,0 +1,287 @@
/**
* 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'
);
}
/**
* 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' &&
Object.values(UserRole).includes(value as UserRole)
);
}
export enum Theme {
Light = 'light',
Dark = 'dark',
}
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 {
id?: number;
userId?: number;
name: string;
createdAt: number | string;
lastOpenedFilePath?: string;
}
export const DEFAULT_WORKSPACE: Workspace = {
name: '',
createdAt: Date.now(),
lastOpenedFilePath: '',
...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' &&
'createdAt' in obj &&
(typeof (obj as Workspace).createdAt === 'number' ||
typeof (obj as Workspace).createdAt === 'string') &&
'theme' in obj &&
typeof (obj as Workspace).theme === 'string' &&
'autoSave' in obj &&
typeof (obj as Workspace).autoSave === 'boolean' &&
'showHiddenFiles' in obj &&
typeof (obj as Workspace).showHiddenFiles === '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' &&
'gitCommitName' in obj &&
typeof (obj as Workspace).gitCommitName === 'string' &&
'gitCommitEmail' in obj &&
typeof (obj as Workspace).gitCommitEmail === 'string'
);
}
export enum FileAction {
Create = 'create',
Update = 'update',
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!',
};
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) ||
(obj as FileNode).children === undefined ||
(obj as FileNode).children === null ||
Array.isArray((obj as FileNode).children))
);
}
// 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'
);
}
export type CommitHash = string;
export enum InlineContainerType {
Paragraph = 'paragraph',
ListItem = 'listItem',
TableCell = 'tableCell',
Blockquote = 'blockquote',
Heading = 'heading',
Emphasis = 'emphasis',
Strong = 'strong',
Delete = 'delete',
}
export const MARKDOWN_REGEX = {
WIKILINK: /(!?)\[\[(.*?)\]\]/g,
} as const;
export enum ModalType {
NewFile = 'newFile',
DeleteFile = 'deleteFile',
CommitMessage = 'commitMessage',
}
export enum SettingsActionType {
INIT_SETTINGS = 'INIT_SETTINGS',
UPDATE_LOCAL_SETTINGS = 'UPDATE_LOCAL_SETTINGS',
MARK_SAVED = 'MARK_SAVED',
}
export interface UserProfileSettings {
displayName?: string;
email?: string;
currentPassword?: string;
newPassword?: string;
}
export interface ProfileSettingsState {
localSettings: UserProfileSettings;
initialSettings: UserProfileSettings;
hasUnsavedChanges: boolean;
}
export interface SettingsAction<T> {
type: SettingsActionType;
payload?: T;
}

View File

@@ -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',
]);

View File

@@ -1,5 +0,0 @@
import { IMAGE_EXTENSIONS } from './constants';
export const isImageFile = (filePath) => {
return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext));
};

View File

@@ -0,0 +1,17 @@
import { API_BASE_URL } from '@/types/api';
import { IMAGE_EXTENSIONS } from '@/types/models';
/**
* 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));
};
export const getFileUrl = (workspaceName: string, filePath: string) => {
return `${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName
)}/files/${encodeURIComponent(filePath)}`;
};

View File

@@ -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]}`;
};

View File

@@ -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]}`;
};

View File

@@ -1,177 +0,0 @@
import { visit } from 'unist-util-visit';
import { lookupFileByName, getFileUrl } from '../services/api';
import { INLINE_CONTAINER_TYPES, MARKDOWN_REGEX } from './constants';
function createNotFoundLink(fileName, displayText, baseUrl) {
return {
type: 'link',
url: `${baseUrl}/notfound/${encodeURIComponent(fileName)}`,
children: [{ type: 'text', value: displayText }],
data: {
hProperties: { style: { color: 'red', textDecoration: 'underline' } },
},
};
}
function createFileLink(filePath, displayText, heading, baseUrl) {
const url = heading
? `${baseUrl}/internal/${encodeURIComponent(filePath)}#${encodeURIComponent(
heading
)}`
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
return {
type: 'link',
url,
children: [{ type: 'text', value: displayText }],
};
}
function createImageNode(workspaceName, filePath, displayText) {
return {
type: 'image',
url: getFileUrl(workspaceName, filePath),
alt: displayText,
title: displayText,
};
}
function addMarkdownExtension(fileName) {
if (fileName.includes('.')) {
return fileName;
}
return `${fileName}.md`;
}
export function remarkWikiLinks(workspaceName) {
return async function transformer(tree) {
if (!workspaceName) {
console.warn('No workspace ID provided to remarkWikiLinks plugin');
return;
}
const baseUrl = window.API_BASE_URL;
const replacements = new Map();
// Find all wiki links
visit(tree, 'text', function (node, index, parent) {
const regex = MARKDOWN_REGEX.WIKILINK;
let match;
const matches = [];
while ((match = regex.exec(node.value)) !== null) {
const [fullMatch, isImage, innerContent] = match;
let fileName, displayText, heading;
const pipeIndex = innerContent.indexOf('|');
const hashIndex = innerContent.indexOf('#');
if (pipeIndex !== -1) {
displayText = innerContent.slice(pipeIndex + 1).trim();
fileName = innerContent.slice(0, pipeIndex).trim();
} else {
displayText = innerContent;
fileName = innerContent;
}
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
heading = fileName.slice(hashIndex + 1).trim();
fileName = fileName.slice(0, hashIndex).trim();
}
matches.push({
fullMatch,
isImage,
fileName,
displayText,
heading,
index: match.index,
});
}
if (matches.length > 0) {
replacements.set(node, { matches, parent, index });
}
});
// Process all matches
for (const [node, { matches, parent }] of replacements) {
const newNodes = [];
let lastIndex = 0;
for (const match of matches) {
// Add text before the match
if (match.index > lastIndex) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex, match.index),
});
}
try {
const lookupFileName = match.isImage
? match.fileName
: addMarkdownExtension(match.fileName);
const paths = await lookupFileByName(workspaceName, lookupFileName);
if (paths && paths.length > 0) {
const filePath = paths[0];
if (match.isImage) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
);
} else {
newNodes.push(
createFileLink(
filePath,
match.displayText,
match.heading,
baseUrl
)
);
}
} else {
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
} catch (error) {
console.debug('File lookup failed:', match.fileName, error);
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
lastIndex = match.index + match.fullMatch.length;
}
// Add any remaining text
if (lastIndex < node.value.length) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex),
});
}
// 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);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, ...newNodes);
}
} else {
// For other types of parents, wrap the nodes in a paragraph
const paragraph = {
type: 'paragraph',
children: newNodes,
};
const nodeIndex = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, paragraph);
}
}
}
};
}

View File

@@ -0,0 +1,303 @@
import { visit } from 'unist-util-visit';
import type { Node, Parent } from 'unist';
import type { Text } from 'mdast';
import { lookupFileByName } from '@/api/file';
import { getFileUrl } from './fileHelpers';
import { InlineContainerType, MARKDOWN_REGEX } from '@/types/models';
/**
* Represents a wiki link match from the regex
*/
interface WikiLinkMatch {
fullMatch: string;
isImage: boolean; // Changed from string to boolean
fileName: string;
displayText: string;
heading?: string | undefined;
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: [createTextNode(displayText)],
data: {
hProperties: { style: { color: 'red', textDecoration: 'underline' } },
},
};
}
/**
* 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
)}`
: `${baseUrl}/internal/${encodeURIComponent(filePath)}`;
return {
type: 'link',
url,
children: [createTextNode(displayText)],
};
}
/**
* Creates an image node
*/
function createImageNode(
workspaceName: string,
filePath: string,
displayText: string
): ImageNode {
return {
type: 'image',
url: getFileUrl(workspaceName, filePath),
alt: displayText,
title: displayText,
};
}
/**
* 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`;
}
/**
* Determines if a node type can contain inline content
*/
function canContainInline(type: string): boolean {
return Object.values(InlineContainerType).includes(
type as InlineContainerType
);
}
/**
* Plugin for processing wiki-style links in markdown
*/
export function remarkWikiLinks(workspaceName: string) {
return async function transformer(tree: Node): Promise<void> {
if (!workspaceName) {
console.warn('No workspace ID provided to remarkWikiLinks plugin');
return;
}
const baseUrl: string = window.API_BASE_URL;
const replacements = new Map<Text, ReplacementInfo>();
// Find all wiki links
visit(tree, 'text', function (node: Text, index: number, parent: Parent) {
const regex = MARKDOWN_REGEX.WIKILINK;
let match: RegExpExecArray | null;
const matches: WikiLinkMatch[] = [];
while ((match = regex.exec(node.value)) !== null) {
// Provide default values during destructuring to handle potential undefined values
const [fullMatch = '', isImageMark = '', innerContent = ''] = match;
// Skip if we somehow got a match without the expected content
if (!innerContent) {
console.warn('Matched wiki link without inner content:', fullMatch);
continue;
}
let fileName: string;
let displayText: string;
let heading: string | undefined;
// Convert isImageMark string to boolean
const isImage: boolean = isImageMark === '!';
const pipeIndex: number = innerContent.indexOf('|');
const hashIndex: number = innerContent.indexOf('#');
if (pipeIndex !== -1) {
displayText = innerContent.slice(pipeIndex + 1).trim();
fileName = innerContent.slice(0, pipeIndex).trim();
} else {
displayText = innerContent;
fileName = innerContent;
}
if (hashIndex !== -1 && (pipeIndex === -1 || hashIndex < pipeIndex)) {
heading = fileName.slice(hashIndex + 1).trim();
fileName = fileName.slice(0, hashIndex).trim();
}
matches.push({
fullMatch,
isImage,
fileName,
displayText,
heading,
index: match.index,
});
}
if (matches.length > 0) {
replacements.set(node, { matches, parent, index });
}
});
// Process all matches
for (const [node, { matches, parent }] of replacements) {
const newNodes: (LinkNode | ImageNode | TextNode)[] = [];
let lastIndex: number = 0;
for (const match of matches) {
// Add text before the match
if (match.index > lastIndex) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex, match.index),
});
}
try {
const lookupFileName: string = match.isImage
? match.fileName
: addMarkdownExtension(match.fileName);
const paths: string[] = await lookupFileByName(
workspaceName,
lookupFileName
);
if (paths && paths.length > 0 && paths[0]) {
const filePath: string = paths[0];
if (match.isImage) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
);
} else {
newNodes.push(
createFileLink(
filePath,
match.displayText,
match.heading,
baseUrl
)
);
}
} else {
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
} catch (error) {
console.debug('File lookup failed:', match.fileName, error);
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
lastIndex = match.index + match.fullMatch.length;
}
// Add any remaining text
if (lastIndex < node.value.length) {
newNodes.push({
type: 'text',
value: node.value.slice(lastIndex),
});
}
// 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 {
// Wrap in paragraph for other types
const paragraph: Parent = {
type: 'paragraph',
children: newNodes,
};
const nodeIndex: number = parent.children.indexOf(node);
if (nodeIndex !== -1) {
parent.children.splice(nodeIndex, 1, paragraph);
}
}
}
};
}

View File

@@ -0,0 +1,80 @@
import type { MantineTheme } from '@mantine/core';
// For type safety - this property exists on the MantineTheme but may not be in types
interface ThemeWithColorScheme extends MantineTheme {
colorScheme?: 'dark' | 'light';
}
// Type-safe hover style function for unstyledButton and similar components
export const getHoverStyle = (theme: MantineTheme) => ({
borderRadius: theme.radius.sm,
'&:hover': {
backgroundColor:
(theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[0],
},
});
// Type-safe color function for text or components that need conditional colors
export const getConditionalColor = (
theme: MantineTheme,
isSelected = false
) => {
if (isSelected) {
return (theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.blue[2]
: theme.colors.blue[7];
}
return 'dimmed';
};
// Helper for accordion styling
export const getAccordionStyles = (theme: MantineTheme) => ({
control: {
paddingTop: theme.spacing.md,
paddingBottom: theme.spacing.md,
},
item: {
borderBottom: `1px solid ${
(theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.dark[4]
: theme.colors.gray[3]
}`,
'&[data-active]': {
backgroundColor:
(theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.dark[7]
: theme.colors.gray[0],
},
},
});
// Helper for workspace paper styling
export const getWorkspacePaperStyle = (
theme: MantineTheme,
isSelected: boolean
) => ({
backgroundColor: isSelected
? theme.colors.blue[
(theme as ThemeWithColorScheme).colorScheme === 'dark' ? 8 : 1
]
: undefined,
borderColor: isSelected
? theme.colors.blue[
(theme as ThemeWithColorScheme).colorScheme === 'dark' ? 7 : 5
]
: undefined,
});
// Helper for text color based on theme and selection
export const getTextColor = (
theme: MantineTheme,
isSelected: boolean
): string | null => {
if (!isSelected) return null;
return (theme as ThemeWithColorScheme).colorScheme === 'dark'
? theme.colors.blue[0]
: theme.colors.blue[9];
};

59
app/tsconfig.json Normal file
View File

@@ -0,0 +1,59 @@
{
"compilerOptions": {
/* Language and Environment */
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"useDefineForClassFields": true,
/* Modules */
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
/* JavaScript Support */
"allowJs": false,
"checkJs": false,
/* 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"],
"exclude": ["node_modules", "dist"],
"references": [{ "path": "./tsconfig.node.json" }]
}

33
app/tsconfig.node.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
/* Basic Options */
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"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"]
}

View File

@@ -9,7 +9,7 @@ import { compression } from 'vite-plugin-compression2';
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
react({ react({
include: ['**/*.jsx', '**/*.js'], include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
}), }),
compression(), compression(),
], ],
@@ -124,7 +124,7 @@ export default defineConfig(({ mode }) => ({
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
extensions: ['.js', '.jsx', '.json'], extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
}, },
// Add performance optimization options // Add performance optimization options

View File

@@ -35,6 +35,7 @@ type Config struct {
func DefaultConfig() *Config { func DefaultConfig() *Config {
return &Config{ return &Config{
DBURL: "sqlite://lemma.db", DBURL: "sqlite://lemma.db",
DBType: db.DBTypeSQLite,
WorkDir: "./data", WorkDir: "./data",
StaticPath: "../app/dist", StaticPath: "../app/dist",
Port: "8080", Port: "8080",

View File

@@ -3,6 +3,7 @@ package context
import ( import (
"lemma/internal/db" "lemma/internal/db"
"net/http" "net/http"
"net/url"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -42,12 +43,25 @@ func WithWorkspaceContextMiddleware(db db.WorkspaceReader) func(http.Handler) ht
} }
workspaceName := chi.URLParam(r, "workspaceName") workspaceName := chi.URLParam(r, "workspaceName")
workspace, err := db.GetWorkspaceByName(ctx.UserID, workspaceName) // URL-decode the workspace name
decodedWorkspaceName, err := url.PathUnescape(workspaceName)
if err != nil {
log.Error("failed to decode workspace name",
"error", err,
"userID", ctx.UserID,
"workspace", workspaceName,
"path", r.URL.Path)
http.Error(w, "Invalid workspace name", http.StatusBadRequest)
return
}
workspace, err := db.GetWorkspaceByName(ctx.UserID, decodedWorkspaceName)
if err != nil { if err != nil {
log.Error("failed to get workspace", log.Error("failed to get workspace",
"error", err, "error", err,
"userID", ctx.UserID, "userID", ctx.UserID,
"workspace", workspaceName, "workspace", decodedWorkspaceName,
"encodedWorkspace", workspaceName,
"path", r.URL.Path) "path", r.URL.Path)
http.Error(w, "Failed to get workspace", http.StatusNotFound) http.Error(w, "Failed to get workspace", http.StatusNotFound)
return return

View File

@@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS system_settings (
); );
-- Create indexes for performance -- Create indexes for performance
CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token); CREATE INDEX IF NOT EXISTS idx_sessions_refresh_token ON sessions(refresh_token);
CREATE INDEX idx_workspaces_user_id ON workspaces(user_id); CREATE INDEX IF NOT EXISTS idx_workspaces_user_id ON workspaces(user_id);

View File

@@ -54,7 +54,7 @@ CREATE TABLE IF NOT EXISTS system_settings (
); );
-- Create indexes for performance -- Create indexes for performance
CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token); CREATE INDEX IF NOT EXISTS idx_sessions_refresh_token ON sessions(refresh_token);
CREATE INDEX idx_workspaces_user_id ON workspaces(user_id); CREATE INDEX IF NOT EXISTS idx_workspaces_user_id ON workspaces(user_id);

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"time" "time"
@@ -110,7 +111,18 @@ func (h *Handler) LookupFileByName() http.HandlerFunc {
return return
} }
filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename) // URL-decode the filename
decodedFilename, err := url.PathUnescape(filename)
if err != nil {
log.Error("failed to decode filename",
"filename", filename,
"error", err.Error(),
)
respondError(w, "Invalid filename", http.StatusBadRequest)
return
}
filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, decodedFilename)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
log.Error("failed to lookup file", log.Error("failed to lookup file",
@@ -159,11 +171,22 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
) )
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
content, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, filePath) // URL-decode the file path
decodedPath, err := url.PathUnescape(filePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
content, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, decodedPath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted", log.Error("invalid file path attempted",
"filePath", filePath, "filePath", decodedPath,
"error", err.Error(), "error", err.Error(),
) )
respondError(w, "Invalid file path", http.StatusBadRequest) respondError(w, "Invalid file path", http.StatusBadRequest)
@@ -172,7 +195,7 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
if os.IsNotExist(err) { if os.IsNotExist(err) {
log.Debug("file not found", log.Debug("file not found",
"filePath", filePath, "filePath", decodedPath,
) )
respondError(w, "File not found", http.StatusNotFound) respondError(w, "File not found", http.StatusNotFound)
return return
@@ -228,21 +251,32 @@ func (h *Handler) SaveFile() http.HandlerFunc {
) )
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
// URL-decode the file path
decodedPath, err := url.PathUnescape(filePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
content, err := io.ReadAll(r.Body) content, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
log.Error("failed to read request body", log.Error("failed to read request body",
"filePath", filePath, "filePath", decodedPath,
"error", err.Error(), "error", err.Error(),
) )
respondError(w, "Failed to read request body", http.StatusBadRequest) respondError(w, "Failed to read request body", http.StatusBadRequest)
return return
} }
err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, decodedPath, content)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted", log.Error("invalid file path attempted",
"filePath", filePath, "filePath", decodedPath,
"error", err.Error(), "error", err.Error(),
) )
respondError(w, "Invalid file path", http.StatusBadRequest) respondError(w, "Invalid file path", http.StatusBadRequest)
@@ -295,11 +329,22 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
) )
filePath := chi.URLParam(r, "*") filePath := chi.URLParam(r, "*")
err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath) // URL-decode the file path
decodedPath, err := url.PathUnescape(filePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", filePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
err = h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, decodedPath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted", log.Error("invalid file path attempted",
"filePath", filePath, "filePath", decodedPath,
"error", err.Error(), "error", err.Error(),
) )
respondError(w, "Invalid file path", http.StatusBadRequest) respondError(w, "Invalid file path", http.StatusBadRequest)
@@ -308,7 +353,7 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
if os.IsNotExist(err) { if os.IsNotExist(err) {
log.Debug("file not found", log.Debug("file not found",
"filePath", filePath, "filePath", decodedPath,
) )
respondError(w, "File not found", http.StatusNotFound) respondError(w, "File not found", http.StatusNotFound)
return return
@@ -413,7 +458,19 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
// Validate the file path in the workspace // Validate the file path in the workspace
if requestBody.FilePath != "" { if requestBody.FilePath != "" {
_, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath) // URL-decode the file path
decodedPath, err := url.PathUnescape(requestBody.FilePath)
if err != nil {
log.Error("failed to decode file path",
"filePath", requestBody.FilePath,
"error", err.Error(),
)
respondError(w, "Invalid file path", http.StatusBadRequest)
return
}
requestBody.FilePath = decodedPath
_, err = h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath)
if err != nil { if err != nil {
if storage.IsPathValidationError(err) { if storage.IsPathValidationError(err) {
log.Error("invalid file path attempted", log.Error("invalid file path attempted",

View File

@@ -173,19 +173,19 @@ func (h *Handler) GetWorkspace() http.HandlerFunc {
} }
} }
func gitSettingsChanged(new, old *models.Workspace) bool { func gitSettingsChanged(newWorkspace, old *models.Workspace) bool {
// Check if Git was enabled/disabled // Check if Git was enabled/disabled
if new.GitEnabled != old.GitEnabled { if newWorkspace.GitEnabled != old.GitEnabled {
return true return true
} }
// If Git is enabled, check if any settings changed // If Git is enabled, check if any settings changed
if new.GitEnabled { if newWorkspace.GitEnabled {
return new.GitURL != old.GitURL || return newWorkspace.GitURL != old.GitURL ||
new.GitUser != old.GitUser || newWorkspace.GitUser != old.GitUser ||
new.GitToken != old.GitToken || newWorkspace.GitToken != old.GitToken ||
new.GitCommitName != old.GitCommitName || newWorkspace.GitCommitName != old.GitCommitName ||
new.GitCommitEmail != old.GitCommitEmail newWorkspace.GitCommitEmail != old.GitCommitEmail
} }
return false return false