mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 15:44:21 +00:00
Merge pull request #50 from lordmathis/feat/typescript
Migrate frontend to typescript
This commit is contained in:
41
.github/workflows/typescript.yml
vendored
Normal file
41
.github/workflows/typescript.yml
vendored
Normal 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
|
||||||
@@ -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
107
app/eslint.config.mjs
Normal 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
3357
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||||
|
|||||||
@@ -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
135
app/src/api/admin.ts
Normal 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
78
app/src/api/api.ts
Normal 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
75
app/src/api/auth.ts
Normal 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
169
app/src/api/file.ts
Normal 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
50
app/src/api/git.ts
Normal 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
41
app/src/api/user.ts
Normal 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
153
app/src/api/workspace.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
@@ -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%',
|
||||||
@@ -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()) {
|
||||||
@@ -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;
|
|
||||||
141
app/src/components/editor/MarkdownPreview.tsx
Normal file
141
app/src/components/editor/MarkdownPreview.tsx
Normal 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;
|
||||||
@@ -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} />
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
@@ -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();
|
||||||
@@ -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]
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -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('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -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>
|
||||||
@@ -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 "{selectedFile}"?</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
|
||||||
45
app/src/components/modals/user/DeleteUserModal.tsx
Normal file
45
app/src/components/modals/user/DeleteUserModal.tsx
Normal 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 "{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={() => void onConfirm()} loading={loading}>
|
||||||
|
Delete User
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DeleteUserModal;
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 "{workspaceName}"?
|
||||||
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>
|
||||||
@@ -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" />
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
@@ -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 || ''}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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't want to change it.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -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">
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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;
|
|
||||||
73
app/src/components/settings/admin/AdminWorkspacesTab.tsx
Normal file
73
app/src/components/settings/admin/AdminWorkspacesTab.tsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
31
app/src/components/settings/workspace/AppearanceSettings.tsx
Normal file
31
app/src/components/settings/workspace/AppearanceSettings.tsx
Normal 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;
|
||||||
@@ -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);
|
||||||
@@ -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,
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
131
app/src/contexts/AuthContext.tsx
Normal file
131
app/src/contexts/AuthContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
67
app/src/contexts/ModalContext.tsx
Normal file
67
app/src/contexts/ModalContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
46
app/src/contexts/ThemeContext.tsx
Normal file
46
app/src/contexts/ThemeContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
22
app/src/contexts/WorkspaceContext.tsx
Normal file
22
app/src/contexts/WorkspaceContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
146
app/src/contexts/WorkspaceDataContext.tsx
Normal file
146
app/src/contexts/WorkspaceDataContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
88
app/src/hooks/useAdminData.ts
Normal file
88
app/src/hooks/useAdminData.ts
Normal 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 };
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
@@ -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 {
|
||||||
@@ -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]);
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -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]
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
42
app/src/hooks/useLastOpenedFile.ts
Normal file
42
app/src/hooks/useLastOpenedFile.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
88
app/src/hooks/useProfileSettings.ts
Normal file
88
app/src/hooks/useProfileSettings.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
37
app/src/hooks/useWorkspace.ts
Normal file
37
app/src/hooks/useWorkspace.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
117
app/src/hooks/useWorkspaceOperations.ts
Normal file
117
app/src/hooks/useWorkspaceOperations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -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
116
app/src/types/api.ts
Normal 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
287
app/src/types/models.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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',
|
|
||||||
]);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { IMAGE_EXTENSIONS } from './constants';
|
|
||||||
|
|
||||||
export const isImageFile = (filePath) => {
|
|
||||||
return IMAGE_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext));
|
|
||||||
};
|
|
||||||
17
app/src/utils/fileHelpers.ts
Normal file
17
app/src/utils/fileHelpers.ts
Normal 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)}`;
|
||||||
|
};
|
||||||
@@ -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]}`;
|
|
||||||
};
|
|
||||||
24
app/src/utils/formatBytes.ts
Normal file
24
app/src/utils/formatBytes.ts
Normal 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]}`;
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
303
app/src/utils/remarkWikiLinks.ts
Normal file
303
app/src/utils/remarkWikiLinks.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
80
app/src/utils/themeStyles.ts
Normal file
80
app/src/utils/themeStyles.ts
Normal 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
59
app/tsconfig.json
Normal 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
33
app/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user