33 Commits

Author SHA1 Message Date
681b5a857c Merge pull request #77 from lordmathis/dependabot/npm_and_yarn/app/rehype-mathjax-7.1.0
Bump rehype-mathjax from 6.0.0 to 7.1.0 in /app
2025-11-04 23:53:33 +01:00
dependabot[bot]
bc97c21b1d Bump rehype-mathjax from 6.0.0 to 7.1.0 in /app
Bumps [rehype-mathjax](https://github.com/remarkjs/remark-math) from 6.0.0 to 7.1.0.
- [Release notes](https://github.com/remarkjs/remark-math/releases)
- [Commits](https://github.com/remarkjs/remark-math/compare/rehype-mathjax@6.0.0...rehype-mathjax@7.1.0)

---
updated-dependencies:
- dependency-name: rehype-mathjax
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 22:41:26 +00:00
a2cd260bca Merge pull request #72 from lordmathis/dependabot/npm_and_yarn/app/minor-and-patch-406c606fe0
Bump the minor-and-patch group in /app with 18 updates
2025-11-04 20:34:51 +01:00
dependabot[bot]
85f2bf23c1 Bump the minor-and-patch group in /app with 18 updates
Bumps the minor-and-patch group in /app with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.6.2` | `6.10.0` |
| [@codemirror/lang-markdown](https://github.com/codemirror/lang-markdown) | `6.2.5` | `6.5.0` |
| [@codemirror/state](https://github.com/codemirror/state) | `6.4.1` | `6.5.2` |
| [@codemirror/theme-one-dark](https://github.com/codemirror/theme-one-dark) | `6.1.2` | `6.1.3` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.34.0` | `6.38.6` |
| [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) | `3.19.0` | `3.35.0` |
| [codemirror](https://github.com/codemirror/basic-setup) | `6.0.1` | `6.0.2` |
| [react-arborist](https://github.com/brimdata/react-arborist) | `3.4.0` | `3.4.3` |
| [remark-rehype](https://github.com/remarkjs/remark-rehype) | `11.1.1` | `11.1.2` |
| [@eslint/compat](https://github.com/eslint/rewrite/tree/HEAD/packages/compat) | `1.2.9` | `1.4.1` |
| [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `6.6.3` | `6.9.1` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.32.1` | `8.46.3` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.32.1` | `8.46.3` |
| [eslint](https://github.com/eslint/eslint) | `9.27.0` | `9.39.1` |
| [postcss](https://github.com/postcss/postcss) | `8.5.3` | `8.5.6` |
| [postcss-preset-mantine](https://github.com/mantinedev/postcss-preset-mantine) | `1.17.0` | `1.18.0` |
| [sass](https://github.com/sass/dart-sass) | `1.80.4` | `1.93.3` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.8.3` | `5.9.3` |


Updates `@codemirror/commands` from 6.6.2 to 6.10.0
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/compare/6.6.2...6.10.0)

Updates `@codemirror/lang-markdown` from 6.2.5 to 6.5.0
- [Changelog](https://github.com/codemirror/lang-markdown/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/lang-markdown/compare/6.2.5...6.5.0)

Updates `@codemirror/state` from 6.4.1 to 6.5.2
- [Changelog](https://github.com/codemirror/state/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/state/compare/6.4.1...6.5.2)

Updates `@codemirror/theme-one-dark` from 6.1.2 to 6.1.3
- [Changelog](https://github.com/codemirror/theme-one-dark/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/theme-one-dark/compare/6.1.2...6.1.3)

Updates `@codemirror/view` from 6.34.0 to 6.38.6
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.34.0...6.38.6)

Updates `@tabler/icons-react` from 3.19.0 to 3.35.0
- [Release notes](https://github.com/tabler/tabler-icons/releases)
- [Commits](https://github.com/tabler/tabler-icons/commits/v3.35.0/packages/icons-react)

Updates `codemirror` from 6.0.1 to 6.0.2
- [Changelog](https://github.com/codemirror/basic-setup/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/basic-setup/compare/6.0.1...6.0.2)

Updates `react-arborist` from 3.4.0 to 3.4.3
- [Release notes](https://github.com/brimdata/react-arborist/releases)
- [Changelog](https://github.com/brimdata/react-arborist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/brimdata/react-arborist/compare/v3.4.0...v3.4.3)

Updates `remark-rehype` from 11.1.1 to 11.1.2
- [Release notes](https://github.com/remarkjs/remark-rehype/releases)
- [Commits](https://github.com/remarkjs/remark-rehype/compare/11.1.1...11.1.2)

Updates `@eslint/compat` from 1.2.9 to 1.4.1
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/compat/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/compat-v1.4.1/packages/compat)

Updates `@testing-library/jest-dom` from 6.6.3 to 6.9.1
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.6.3...v6.9.1)

Updates `@typescript-eslint/eslint-plugin` from 8.32.1 to 8.46.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.3/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.32.1 to 8.46.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.3/packages/parser)

Updates `eslint` from 9.27.0 to 9.39.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.27.0...v9.39.1)

Updates `postcss` from 8.5.3 to 8.5.6
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.3...8.5.6)

Updates `postcss-preset-mantine` from 1.17.0 to 1.18.0
- [Commits](https://github.com/mantinedev/postcss-preset-mantine/commits)

Updates `sass` from 1.80.4 to 1.93.3
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.80.4...1.93.3)

Updates `typescript` from 5.8.3 to 5.9.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.3)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/lang-markdown"
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/state"
  dependency-version: 6.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/theme-one-dark"
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/view"
  dependency-version: 6.38.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@tabler/icons-react"
  dependency-version: 3.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: codemirror
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-arborist
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: remark-rehype
  dependency-version: 11.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@eslint/compat"
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 6.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.46.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.46.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 9.39.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: postcss-preset-mantine
  dependency-version: 1.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: sass
  dependency-version: 1.93.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: typescript
  dependency-version: 5.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 18:20:06 +00:00
8f06d39a71 Merge pull request #73 from lordmathis/fix/compression
Fix asset compression
2025-11-04 19:17:20 +01:00
93f95f22ef Merge branch 'main' into fix/compression 2025-11-04 19:12:50 +01:00
0a4abdb48c Add Vary header 2025-11-04 19:04:26 +01:00
4ad5e682a8 Enhance StaticHandler to support brotli compression and update content type handling 2025-11-04 19:02:38 +01:00
dd55c81b51 Merge pull request #71 from lordmathis/lordmathis-patch-1
Create dependabot.yml
2025-11-04 18:59:49 +01:00
0939bc7213 Fix generating compressed assets 2025-11-04 18:58:33 +01:00
b8a9cee04d Create dependabot.yml 2025-11-04 18:55:51 +01:00
2259e7400a Merge pull request #69 from lordmathis/fix/drag-and-drop
Fix FileTree drag and drop
2025-11-04 18:25:09 +01:00
2045d36211 Fix FileTree drag and drop 2025-11-04 18:19:54 +01:00
76ab168c6e Merge pull request #68 from lordmathis/feat/user-theme
Add user theme setting
2025-11-03 23:43:23 +01:00
6117f7a58f Add theme property to mock user in ProfileSettings tests 2025-11-03 23:35:54 +01:00
ab6cb47047 Add theme attribute to test user creation 2025-11-03 20:53:54 +01:00
4acba662b6 Refactor theme toggle layout in ProfileSettings component 2025-11-03 19:11:50 +01:00
054d9da867 Fix user type guard to validate theme 2025-11-03 17:52:00 +01:00
efdc42cbd7 Add theme support to user settings and related components 2025-10-28 23:14:45 +01:00
3926954b74 Add theme to user preferences 2025-10-28 20:05:12 +01:00
6c408fdfbe Merge pull request #67 from lordmathis/fix/file-upload
Fix file upload
2025-10-28 19:19:01 +01:00
6753f32520 Remove test case for uploading with missing file_path parameter 2025-10-28 19:09:46 +01:00
de06939b01 Fix file upload 2025-10-28 18:58:10 +01:00
c11d956ced Merge pull request #66 from lordmathis/fix/head-request
Handle HEAD requests with static router
2025-10-23 20:35:47 +02:00
9a232819a8 Handle HEAD requests with static router 2025-10-23 20:26:34 +02:00
f9ce8b9e9f Merge pull request #65 from lordmathis/fix/code-highlight
Fix code highlight theme change
2025-10-23 19:24:14 +02:00
a6d2663a7d Add type declarations for CSS imports with ?inline modifier 2025-10-23 19:19:27 +02:00
071e99f4da Remove documentation.md 2025-10-23 19:17:51 +02:00
b13ee987c7 Fix code highlight theme change 2025-10-23 19:12:09 +02:00
543dbe6ffe Merge pull request #64 from lordmathis/fix/file-rename
Fix move file on frontend
2025-10-22 21:55:52 +02:00
d0842c515f Change move file endpoint from PUT to POST and add integration tests for file moving and renaming 2025-10-22 21:50:37 +02:00
01d9a984fc Merge pull request #63 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-7372c7abf2
Bump vite from 6.3.6 to 6.4.1 in /app in the npm_and_yarn group across 1 directory
2025-10-21 10:21:46 +02:00
dependabot[bot]
a3975c9acd Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.3.6 to 6.4.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 05:07:05 +00:00
50 changed files with 1058 additions and 2054 deletions

21
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/app"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"

3
.gitignore vendored
View File

@@ -162,3 +162,6 @@ go.work.sum
main main
*.db *.db
data data
# Feature specifications
spec.md

1034
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,54 +29,54 @@
}, },
"homepage": "https://github.com/LordMathis/Lemma#readme", "homepage": "https://github.com/LordMathis/Lemma#readme",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.6.2", "@codemirror/commands": "^6.10.0",
"@codemirror/lang-markdown": "^6.2.5", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.34.0", "@codemirror/view": "^6.38.6",
"@mantine/core": "^7.13.2", "@mantine/core": "^7.13.2",
"@mantine/hooks": "^7.13.2", "@mantine/hooks": "^7.13.2",
"@mantine/modals": "^7.13.2", "@mantine/modals": "^7.13.2",
"@mantine/notifications": "^7.13.2", "@mantine/notifications": "^7.13.2",
"@react-hook/resize-observer": "^2.0.2", "@react-hook/resize-observer": "^2.0.2",
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.35.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "^3.4.0", "react-arborist": "^3.4.3",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-mathjax": "^6.0.0", "rehype-mathjax": "^7.1.0",
"rehype-react": "^8.0.0", "rehype-react": "^8.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9", "@eslint/compat": "^1.4.1",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^18.3.20", "@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6", "@types/react-dom": "^18.3.6",
"@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.1.4", "@vitest/coverage-v8": "^3.1.4",
"eslint": "^9.27.0", "eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"postcss": "^8.4.47", "postcss": "^8.5.6",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4", "sass": "^1.93.3",
"typescript": "^5.8.2", "typescript": "^5.9.3",
"vite": "^6.3.6", "vite": "^6.4.1",
"vite-plugin-compression2": "^1.3.0", "vite-plugin-compression2": "^2.3.1",
"vitest": "^3.1.4" "vitest": "^3.1.4"
}, },
"browserslist": { "browserslist": {

View File

@@ -108,29 +108,3 @@ $navbar-height: 64px;
.tree { .tree {
padding-top: $padding; padding-top: $padding;
} }
// Syntax highlighting themes
@import 'highlight.js/styles/github.css' layer(light-theme);
@import 'highlight.js/styles/github-dark.css' layer(dark-theme);
// Show light theme by default
@layer light-theme {
[data-mantine-color-scheme='light'] .markdown-preview {
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
}
}
// Show dark theme in dark mode
@layer dark-theme {
[data-mantine-color-scheme='dark'] .markdown-preview {
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
}
}

View File

@@ -29,10 +29,16 @@ export const apiCall = async (
// Set up headers with CSRF token for non-GET requests // Set up headers with CSRF token for non-GET requests
const method = options.method || 'GET'; const method = options.method || 'GET';
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
// Only set Content-Type to application/json if not already set and body is not FormData
// FormData requires the browser to set Content-Type with the boundary parameter
const isFormData = options.body instanceof FormData;
if (!headers['Content-Type'] && !isFormData) {
headers['Content-Type'] = 'application/json';
}
// Add CSRF token for non-GET methods // Add CSRF token for non-GET methods
if (method !== 'GET') { if (method !== 'GET') {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
@@ -41,11 +47,18 @@ export const apiCall = async (
} }
} }
// For FormData, don't include Content-Type in headers - let the browser set it
const fetchHeaders = isFormData
? Object.fromEntries(
Object.entries(headers).filter(([key]) => key !== 'Content-Type')
)
: headers;
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
// Include credentials to send/receive cookies // Include credentials to send/receive cookies
credentials: 'include', credentials: 'include',
headers, headers: fetchHeaders,
}); });
console.debug(`Response status: ${response.status} for URL: ${url}`); console.debug(`Response status: ${response.status} for URL: ${url}`);

View File

@@ -120,6 +120,34 @@ describe('MarkdownPreview', () => {
}); });
}); });
it('renders code blocks with correct structure for theme switching', async () => {
const content = '```javascript\nconst hello = "world";\n```';
render(
<MarkdownPreview
content={content}
handleFileSelect={mockHandleFileSelect}
/>
);
await waitFor(() => {
// Check that rehype-highlight generates the correct structure
const preElement = screen
.getByRole('code', { hidden: true })
.closest('pre');
const codeElement = preElement?.querySelector('code');
expect(preElement).toBeInTheDocument();
expect(codeElement).toBeInTheDocument();
// The code element should have hljs class for theme switching to work
expect(codeElement).toHaveClass('hljs');
// Should also have language class
expect(codeElement).toHaveClass('language-javascript');
});
});
it('handles image loading errors gracefully', async () => { it('handles image loading errors gracefully', async () => {
const content = '![Test Image](invalid-image.jpg)'; const content = '![Test Image](invalid-image.jpg)';

View File

@@ -10,6 +10,7 @@ import * as prod from 'react/jsx-runtime';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { remarkWikiLinks } from '../../utils/remarkWikiLinks'; import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
import { useWorkspace } from '../../hooks/useWorkspace'; import { useWorkspace } from '../../hooks/useWorkspace';
import { useHighlightTheme } from '../../hooks/useHighlightTheme';
interface MarkdownPreviewProps { interface MarkdownPreviewProps {
content: string; content: string;
@@ -28,12 +29,6 @@ interface MarkdownLinkProps {
[key: string]: unknown; [key: string]: unknown;
} }
interface MarkdownCodeProps {
children: ReactNode;
className?: string;
[key: string]: unknown;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
content, content,
handleFileSelect, handleFileSelect,
@@ -42,7 +37,10 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
null null
); );
const baseUrl = window.API_BASE_URL; const baseUrl = window.API_BASE_URL;
const { currentWorkspace } = useWorkspace(); const { currentWorkspace, colorScheme } = useWorkspace();
// Use the highlight theme hook
useHighlightTheme(colorScheme === 'auto' ? 'light' : colorScheme);
const processor = useMemo(() => { const processor = useMemo(() => {
const handleLinkClick = ( const handleLinkClick = (
@@ -107,13 +105,6 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
{children} {children}
</a> </a>
), ),
code: ({ children, className, ...props }: MarkdownCodeProps) => {
return (
<pre className={className}>
<code {...props}>{children}</code>
</pre>
);
},
}, },
} as Options); } as Options);
}, [currentWorkspace?.name, baseUrl, handleFileSelect]); }, [currentWorkspace?.name, baseUrl, handleFileSelect]);

View File

@@ -6,7 +6,7 @@ import {
IconFolderOpen, IconFolderOpen,
IconUpload, IconUpload,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Tooltip, Text, Box } from '@mantine/core'; import { Text, Box } from '@mantine/core';
import useResizeObserver from '@react-hook/resize-observer'; import useResizeObserver from '@react-hook/resize-observer';
import { useFileOperations } from '../../hooks/useFileOperations'; import { useFileOperations } from '../../hooks/useFileOperations';
import type { FileNode } from '@/types/models'; import type { FileNode } from '@/types/models';
@@ -53,13 +53,12 @@ function Node({
style, style,
dragHandle, dragHandle,
onNodeClick, onNodeClick,
...rest
}: { }: {
node: NodeApi<FileNode>; node: NodeApi<FileNode>;
style: React.CSSProperties; style: React.CSSProperties;
dragHandle?: React.Ref<HTMLDivElement>; dragHandle?: React.Ref<HTMLDivElement>;
onNodeClick?: (node: NodeApi<FileNode>) => void; onNodeClick?: (node: NodeApi<FileNode>) => void;
} & Record<string, unknown>) { }) {
const handleClick = () => { const handleClick = () => {
if (node.isInternal) { if (node.isInternal) {
node.toggle(); node.toggle();
@@ -69,37 +68,40 @@ function Node({
}; };
return ( return (
<Tooltip label={node.data.name} openDelay={500}> <div
<div ref={dragHandle} // This enables dragging for the node
ref={dragHandle} // This enables dragging for the node style={{
...style,
paddingLeft: `${node.level * 20}px`,
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
// Add visual feedback when being dragged
opacity: node.state?.isDragging ? 0.5 : 1,
// Highlight when this node will receive the drop
backgroundColor: node.state?.willReceiveDrop
? 'rgba(0, 123, 255, 0.2)'
: 'transparent',
borderRadius: '4px',
}}
onClick={handleClick}
title={node.data.name}
>
<FileIcon node={node} />
<span
style={{ style={{
...style, marginLeft: '8px',
paddingLeft: `${node.level * 20}px`, fontSize: '14px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
// Add visual feedback when being dragged textOverflow: 'ellipsis',
opacity: node.state?.isDragging ? 0.5 : 1, flexGrow: 1,
}} }}
onClick={handleClick}
{...rest}
> >
<FileIcon node={node} /> {node.data.name}
<span </span>
style={{ </div>
marginLeft: '8px',
fontSize: '14px',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexGrow: 1,
}}
>
{node.data.name}
</span>
</div>
</Tooltip>
); );
} }
@@ -205,41 +207,46 @@ export const FileTree: React.FC<FileTreeProps> = ({
// External file drag and drop handlers // External file drag and drop handlers
const handleDragEnter = useCallback((e: React.DragEvent) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Check if drag contains files (not internal tree nodes) // Check if drag contains files (not internal tree nodes)
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true); setIsDragOver(true);
} }
}, []); }, []);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); // Only handle if it's an external file drag
e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
// Only hide overlay when leaving the container itself // Only hide overlay when leaving the container itself
if (e.currentTarget === e.target) { if (e.currentTarget === e.target) {
setIsDragOver(false); setIsDragOver(false);
}
} }
}, []); }, []);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); // Only handle external file drags
e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) {
// Set the drop effect to indicate this is a valid drop target e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.stopPropagation();
// Set the drop effect to indicate this is a valid drop target
e.dataTransfer.dropEffect = 'copy';
}
}, []); }, []);
const handleDrop = useCallback( const handleDrop = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const { files } = e.dataTransfer; const { files } = e.dataTransfer;
// Only handle if it's an external file drop
if (files && files.length > 0) { if (files && files.length > 0) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const uploadFiles = async () => { const uploadFiles = async () => {
try { try {
const success = await handleUpload(files); const success = await handleUpload(files);
@@ -305,7 +312,10 @@ export const FileTree: React.FC<FileTreeProps> = ({
height={size.height} height={size.height}
indent={24} indent={24}
rowHeight={28} rowHeight={28}
idAccessor="id"
onMove={handleTreeMove} onMove={handleTreeMove}
disableDrag={() => false}
disableDrop={() => false}
onActivate={(node) => { onActivate={(node) => {
const fileNode = node.data; const fileNode = node.data;
if (!node.isInternal) { if (!node.isInternal) {

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import DeleteUserModal from './DeleteUserModal'; import DeleteUserModal from './DeleteUserModal';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock notifications // Mock notifications
vi.mock('@mantine/notifications', () => ({ vi.mock('@mantine/notifications', () => ({
@@ -36,6 +36,7 @@ describe('DeleteUserModal', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import EditUserModal from './EditUserModal'; import EditUserModal from './EditUserModal';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock notifications // Mock notifications
vi.mock('@mantine/notifications', () => ({ vi.mock('@mantine/notifications', () => ({
@@ -36,6 +36,7 @@ describe('EditUserModal', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -187,6 +188,7 @@ describe('EditUserModal', () => {
email: 'newuser@example.com', email: 'newuser@example.com',
displayName: 'New User', displayName: 'New User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
}; };
rerender( rerender(

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../test/utils'; import { render } from '../../test/utils';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import { UserRole } from '../../types/models'; import { UserRole, Theme } from '../../types/models';
// Mock the contexts // Mock the contexts
vi.mock('../../contexts/AuthContext', () => ({ vi.mock('../../contexts/AuthContext', () => ({
@@ -37,6 +37,7 @@ describe('UserMenu', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -53,6 +54,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
}); });
@@ -84,6 +86,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
const { getByLabelText, getByText } = render( const { getByLabelText, getByText } = render(
@@ -145,6 +148,7 @@ describe('UserMenu', () => {
id: mockUser.id, id: mockUser.id,
email: mockUser.email, email: mockUser.email,
role: mockUser.role, role: mockUser.role,
theme: mockUser.theme,
createdAt: mockUser.createdAt, createdAt: mockUser.createdAt,
lastWorkspaceId: mockUser.lastWorkspaceId, lastWorkspaceId: mockUser.lastWorkspaceId,
}; };
@@ -157,6 +161,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
const { getByLabelText, getByText } = render( const { getByLabelText, getByText } = render(

View File

@@ -89,6 +89,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
email: user.email, email: user.email,
currentPassword: '', currentPassword: '',
newPassword: '', newPassword: '',
theme: user.theme,
}; };
dispatch({ dispatch({
type: SettingsActionType.INIT_SETTINGS, type: SettingsActionType.INIT_SETTINGS,
@@ -107,6 +108,13 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
}); });
}; };
const handleThemeChange = (theme: string): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { theme } as UserProfileSettings,
});
};
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
const updates: UserProfileSettings = {}; const updates: UserProfileSettings = {};
const needsPasswordConfirmation = const needsPasswordConfirmation =
@@ -117,6 +125,14 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
updates.displayName = state.localSettings.displayName || ''; updates.displayName = state.localSettings.displayName || '';
} }
// Add theme if changed
if (
state.localSettings.theme &&
state.localSettings.theme !== state.initialSettings.theme
) {
updates.theme = state.localSettings.theme;
}
// Handle password change // Handle password change
if (state.localSettings.newPassword) { if (state.localSettings.newPassword) {
if (!state.localSettings.currentPassword) { if (!state.localSettings.currentPassword) {
@@ -216,6 +232,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
<ProfileSettings <ProfileSettings
settings={state.localSettings} settings={state.localSettings}
onInputChange={handleInputChange} onInputChange={handleInputChange}
onThemeChange={handleThemeChange}
/> />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>

View File

@@ -4,6 +4,25 @@ import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import ProfileSettings from './ProfileSettings'; import ProfileSettings from './ProfileSettings';
import type { UserProfileSettings } from '@/types/models'; import type { UserProfileSettings } from '@/types/models';
import { Theme, UserRole, type User } from '@/types/models';
// Mock user for AuthContext
const mockUser: User = {
id: 1,
email: 'test@example.com',
displayName: 'Test User',
role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
};
// Mock the auth context
vi.mock('../../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockUser,
}),
}));
// Helper wrapper component for testing // Helper wrapper component for testing
const TestWrapper = ({ children }: { children: React.ReactNode }) => ( const TestWrapper = ({ children }: { children: React.ReactNode }) => (

View File

@@ -1,36 +1,66 @@
import React from 'react'; import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core'; import { Box, Stack, TextInput, Group, Text, Switch } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models'; import { useAuth } from '@/contexts/AuthContext';
import { Theme, type UserProfileSettings } from '@/types/models';
interface ProfileSettingsProps { interface ProfileSettingsProps {
settings: UserProfileSettings; settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void; onInputChange: (key: keyof UserProfileSettings, value: string) => void;
onThemeChange?: (theme: Theme) => void;
} }
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
settings, settings,
onInputChange, onInputChange,
}) => ( onThemeChange,
<Box> }) => {
<Stack gap="md"> const { user } = useAuth();
<TextInput const currentTheme = settings.theme || user?.theme || Theme.Dark;
label="Display Name"
type="text" const handleThemeToggle = () => {
value={settings.displayName || ''} const newTheme = currentTheme === Theme.Dark ? Theme.Light : Theme.Dark;
onChange={(e) => onInputChange('displayName', e.currentTarget.value)} if (onThemeChange) {
placeholder="Enter display name" onThemeChange(newTheme);
data-testid="display-name-input" }
/> };
<TextInput
label="Email" return (
type="email" <Box>
value={settings.email || ''} <Stack gap="md">
onChange={(e) => onInputChange('email', e.currentTarget.value)} <TextInput
placeholder="Enter email" label="Display Name"
data-testid="email-input" type="text"
/> value={settings.displayName || ''}
</Stack> onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
</Box> placeholder="Enter display name"
); data-testid="display-name-input"
/>
<TextInput
label="Email"
type="email"
value={settings.email || ''}
onChange={(e) => onInputChange('email', e.currentTarget.value)}
placeholder="Enter email"
data-testid="email-input"
/>
<Box mb="md">
<Group justify="space-between" align="center">
<div>
<Text size="sm">Default Dark Mode</Text>
<Text size="xs" c="dimmed">
Sets the default theme for new workspaces
</Text>
</div>
<Switch
checked={currentTheme === Theme.Dark}
onChange={handleThemeToggle}
data-testid="theme-toggle"
/>
</Group>
</Box>
</Stack>
</Box>
);
};
export default ProfileSettings; export default ProfileSettings;

View File

@@ -3,7 +3,7 @@ import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import AdminDashboard from './AdminDashboard'; import AdminDashboard from './AdminDashboard';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock the auth context // Mock the auth context
const mockCurrentUser: User = { const mockCurrentUser: User = {
@@ -11,6 +11,7 @@ const mockCurrentUser: User = {
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import AdminUsersTab from './AdminUsersTab'; import AdminUsersTab from './AdminUsersTab';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock the user admin hook // Mock the user admin hook
const mockCreate = vi.fn(); const mockCreate = vi.fn();
@@ -123,6 +123,7 @@ describe('AdminUsersTab', () => {
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -134,6 +135,7 @@ describe('AdminUsersTab', () => {
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-15T00:00:00Z', createdAt: '2024-01-15T00:00:00Z',
lastWorkspaceId: 2, lastWorkspaceId: 2,
}, },
@@ -142,6 +144,7 @@ describe('AdminUsersTab', () => {
email: 'viewer@example.com', email: 'viewer@example.com',
displayName: 'Viewer User', displayName: 'Viewer User',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-02-01T00:00:00Z', createdAt: '2024-02-01T00:00:00Z',
lastWorkspaceId: 3, lastWorkspaceId: 3,
}, },

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react'; import { renderHook, act, waitFor } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { AuthProvider, useAuth } from './AuthContext'; import { AuthProvider, useAuth } from './AuthContext';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Set up mocks before imports are used // Set up mocks before imports are used
vi.mock('@/api/auth', () => { vi.mock('@/api/auth', () => {
@@ -42,6 +42,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -12,7 +12,8 @@ import {
refreshToken as apiRefreshToken, refreshToken as apiRefreshToken,
getCurrentUser, getCurrentUser,
} from '@/api/auth'; } from '@/api/auth';
import type { User } from '@/types/models'; import { updateProfile as apiUpdateProfile } from '@/api/user';
import type { User, UserProfileSettings } from '@/types/models';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
@@ -22,6 +23,7 @@ interface AuthContextType {
logout: () => Promise<void>; logout: () => Promise<void>;
refreshToken: () => Promise<boolean>; refreshToken: () => Promise<boolean>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
updateProfile: (updates: UserProfileSettings) => Promise<User>;
} }
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
@@ -109,6 +111,31 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
} }
}, []); }, []);
const updateProfile = useCallback(
async (updates: UserProfileSettings): Promise<User> => {
try {
const updatedUser = await apiUpdateProfile(updates);
setUser(updatedUser);
notifications.show({
title: 'Success',
message: 'Profile updated successfully',
color: 'green',
});
return updatedUser;
} catch (error) {
console.error('Failed to update profile:', error);
notifications.show({
title: 'Error',
message:
error instanceof Error ? error.message : 'Failed to update profile',
color: 'red',
});
throw error;
}
},
[]
);
const value: AuthContextType = { const value: AuthContextType = {
user, user,
loading, loading,
@@ -117,6 +144,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
logout, logout,
refreshToken, refreshToken,
refreshUser, refreshUser,
updateProfile,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -4,6 +4,7 @@ import { useAdminData } from './useAdminData';
import * as adminApi from '@/api/admin'; import * as adminApi from '@/api/admin';
import { import {
UserRole, UserRole,
Theme,
type SystemStats, type SystemStats,
type User, type User,
type WorkspaceStats, type WorkspaceStats,
@@ -35,6 +36,7 @@ const mockUsers: User[] = [
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -43,6 +45,7 @@ const mockUsers: User[] = [
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
lastWorkspaceId: 2, lastWorkspaceId: 2,
}, },

View File

@@ -122,7 +122,8 @@ export const useFileOperations = (): UseFileOperationsResult => {
if (!currentWorkspace) return false; if (!currentWorkspace) return false;
try { try {
await uploadFile(currentWorkspace.name, targetPath || '', files); // Default to '.' (root directory) if no target path is provided
await uploadFile(currentWorkspace.name, targetPath || '.', files);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
// Import theme CSS as text that will be bundled
import atomOneLightTheme from 'highlight.js/styles/atom-one-light.css?inline';
import atomOneDarkTheme from 'highlight.js/styles/atom-one-dark.css?inline';
export const useHighlightTheme = (colorScheme: 'light' | 'dark') => {
useEffect(() => {
// Remove existing highlight theme
const existingStylesheet = document.querySelector(
'style[data-highlight-theme]'
);
if (existingStylesheet) {
existingStylesheet.remove();
}
// Add new theme stylesheet using bundled CSS
const style = document.createElement('style');
style.setAttribute('data-highlight-theme', 'true');
if (colorScheme === 'dark') {
style.textContent = atomOneDarkTheme as string;
} else {
style.textContent = atomOneLightTheme as string;
}
document.head.appendChild(style);
return () => {
// Cleanup on unmount
const stylesheet = document.querySelector('style[data-highlight-theme]');
if (stylesheet) {
stylesheet.remove();
}
};
}, [colorScheme]);
};

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react';
import { useProfileSettings } from './useProfileSettings'; import { useProfileSettings } from './useProfileSettings';
import * as userApi from '@/api/user'; import * as userApi from '@/api/user';
import type { UpdateProfileRequest } from '@/types/api'; import type { UpdateProfileRequest } from '@/types/api';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock dependencies // Mock dependencies
vi.mock('@/api/user'); vi.mock('@/api/user');
@@ -22,6 +22,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react';
import { useUserAdmin } from './useUserAdmin'; import { useUserAdmin } from './useUserAdmin';
import * as adminApi from '@/api/admin'; import * as adminApi from '@/api/admin';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api'; import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock dependencies // Mock dependencies
vi.mock('@/api/admin'); vi.mock('@/api/admin');
@@ -35,6 +35,7 @@ const mockUsers: User[] = [
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -43,6 +44,7 @@ const mockUsers: User[] = [
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -112,6 +114,7 @@ describe('useUserAdmin', () => {
email: 'newuser@example.com', email: 'newuser@example.com',
displayName: 'New User', displayName: 'New User',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z', createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -124,6 +127,7 @@ describe('useUserAdmin', () => {
displayName: 'New User', displayName: 'New User',
password: 'password123', password: 'password123',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -152,6 +156,7 @@ describe('useUserAdmin', () => {
displayName: 'Test User', displayName: 'Test User',
password: 'password123', password: 'password123',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -179,6 +184,7 @@ describe('useUserAdmin', () => {
displayName: 'Test User', displayName: 'Test User',
password: 'password123', password: 'password123',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -204,6 +210,7 @@ describe('useUserAdmin', () => {
email: user.email, email: user.email,
displayName: 'Updated Editor', displayName: 'Updated Editor',
role: user.role, role: user.role,
theme: user.theme,
createdAt: user.createdAt, createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId, lastWorkspaceId: user.lastWorkspaceId,
}; };
@@ -238,6 +245,7 @@ describe('useUserAdmin', () => {
email: 'newemail@example.com', email: 'newemail@example.com',
displayName: user.displayName || '', displayName: user.displayName || '',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: user.createdAt, createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId, lastWorkspaceId: user.lastWorkspaceId,
}; };
@@ -248,6 +256,7 @@ describe('useUserAdmin', () => {
const updateRequest: UpdateUserRequest = { const updateRequest: UpdateUserRequest = {
email: 'newemail@example.com', email: 'newemail@example.com',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
}; };
let updateResult: boolean | undefined; let updateResult: boolean | undefined;
@@ -436,6 +445,7 @@ describe('useUserAdmin', () => {
displayName: 'Test', displayName: 'Test',
password: 'pass', password: 'pass',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}); });
}); });
@@ -474,6 +484,7 @@ describe('useUserAdmin', () => {
displayName: 'Test', displayName: 'Test',
password: 'pass', password: 'pass',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}); });
}); });
@@ -500,6 +511,7 @@ describe('useUserAdmin', () => {
email: 'user1@example.com', email: 'user1@example.com',
displayName: 'User 1', displayName: 'User 1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z', createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}) })
@@ -508,6 +520,7 @@ describe('useUserAdmin', () => {
email: 'user2@example.com', email: 'user2@example.com',
displayName: 'User 2', displayName: 'User 2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-04T00:00:00Z', createdAt: '2024-01-04T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}); });
@@ -520,12 +533,14 @@ describe('useUserAdmin', () => {
displayName: 'User 1', displayName: 'User 1',
password: 'pass1', password: 'pass1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}, },
{ {
email: 'user2@example.com', email: 'user2@example.com',
displayName: 'User 2', displayName: 'User 2',
password: 'pass2', password: 'pass2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}, },
]; ];
@@ -555,12 +570,14 @@ describe('useUserAdmin', () => {
displayName: 'Success User', displayName: 'Success User',
password: 'pass1', password: 'pass1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}, },
{ {
email: 'fail@example.com', email: 'fail@example.com',
displayName: 'Fail User', displayName: 'Fail User',
password: 'pass2', password: 'pass2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}, },
]; ];

View File

@@ -9,7 +9,7 @@ import {
type SaveFileResponse, type SaveFileResponse,
type UploadFilesResponse, type UploadFilesResponse,
} from './api'; } from './api';
import { UserRole, type User } from './models'; import { UserRole, Theme, type User } from './models';
// Mock user data for testing // Mock user data for testing
const mockUser: User = { const mockUser: User = {
@@ -17,6 +17,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -1,4 +1,4 @@
import { isUser, type User, type UserRole } from './models'; import { isUser, type User, type UserRole, type Theme } from './models';
declare global { declare global {
interface Window { interface Window {
@@ -55,6 +55,7 @@ export interface CreateUserRequest {
displayName: string; displayName: string;
password: string; password: string;
role: UserRole; role: UserRole;
theme?: Theme;
} }
// UpdateUserRequest holds the request fields for updating a user // UpdateUserRequest holds the request fields for updating a user
@@ -63,6 +64,7 @@ export interface UpdateUserRequest {
displayName?: string; displayName?: string;
password?: string; password?: string;
role?: UserRole; role?: UserRole;
theme?: Theme;
} }
export interface LookupResponse { export interface LookupResponse {
@@ -126,6 +128,7 @@ export interface UpdateProfileRequest {
email?: string; email?: string;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
theme?: Theme;
} }
// DeleteAccountRequest represents a user account deletion request // DeleteAccountRequest represents a user account deletion request

5
app/src/types/css-inline.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
// Type declarations for CSS imports with ?inline modifier
declare module '*.css?inline' {
const content: string;
export default content;
}

View File

@@ -63,6 +63,7 @@ describe('Models Type Guards', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -76,6 +77,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -186,6 +188,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
__proto__: { malicious: true }, __proto__: { malicious: true },
@@ -771,6 +774,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -804,6 +808,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}); });
@@ -852,6 +857,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: longString, email: longString,
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,6 +8,7 @@ export interface User {
email: string; email: string;
displayName?: string; displayName?: string;
role: UserRole; role: UserRole;
theme: Theme;
createdAt: string; createdAt: string;
lastWorkspaceId: number; lastWorkspaceId: number;
} }
@@ -28,6 +29,9 @@ export function isUser(value: unknown): value is User {
: true) && : true) &&
'role' in value && 'role' in value &&
isUserRole((value as User).role) && isUserRole((value as User).role) &&
'theme' in value &&
typeof (value as User).theme === 'string' &&
Object.values(Theme).includes((value as User).theme) &&
'createdAt' in value && 'createdAt' in value &&
typeof (value as User).createdAt === 'string' && typeof (value as User).createdAt === 'string' &&
'lastWorkspaceId' in value && 'lastWorkspaceId' in value &&
@@ -309,6 +313,7 @@ export interface UserProfileSettings {
email?: string; email?: string;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
theme?: Theme;
} }
export interface ProfileSettingsState { export interface ProfileSettingsState {

View File

@@ -11,7 +11,10 @@ export default defineConfig(({ mode }) => ({
react({ react({
include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'], include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
}), }),
compression(), compression({
threshold: 1024, // Only compress files > 1KB
deleteOriginalAssets: false, // Keep original files
}),
], ],
root: 'src', root: 'src',

File diff suppressed because it is too large Load Diff

View File

@@ -118,6 +118,7 @@ func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *C
DisplayName: "Admin", DisplayName: "Admin",
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "dark", // default theme
} }
createdUser, err := database.CreateUser(adminUser) createdUser, err := database.CreateUser(adminUser)
@@ -132,7 +133,8 @@ func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *C
logging.Info("admin user setup completed", logging.Info("admin user setup completed",
"userId", createdUser.ID, "userId", createdUser.ID,
"workspaceId", createdUser.LastWorkspaceID) "workspaceId", createdUser.LastWorkspaceID,
"theme", createdUser.Theme)
return nil return nil
} }

View File

@@ -134,7 +134,7 @@ func setupRouter(o Options) *chi.Mux {
r.Get("/lookup", handler.LookupFileByName()) r.Get("/lookup", handler.LookupFileByName())
r.Post("/upload", handler.UploadFile()) r.Post("/upload", handler.UploadFile())
r.Put("/move", handler.MoveFile()) r.Post("/move", handler.MoveFile())
r.Post("/", handler.SaveFile()) r.Post("/", handler.SaveFile())
r.Get("/content", handler.GetFileContent()) r.Get("/content", handler.GetFileContent())
@@ -152,7 +152,9 @@ func setupRouter(o Options) *chi.Mux {
}) })
// Handle all other routes with static file server // Handle all other routes with static file server
r.Get("/*", handlers.NewStaticHandler(o.Config.StaticPath).ServeHTTP) staticHandler := handlers.NewStaticHandler(o.Config.StaticPath)
r.Get("/*", staticHandler.ServeHTTP)
r.Head("/*", staticHandler.ServeHTTP)
return r return r
} }

View File

@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT, display_name TEXT,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER last_workspace_id INTEGER
); );
@@ -19,7 +20,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT, last_opened_file_path TEXT,
-- Settings fields -- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')), theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT FALSE, auto_save BOOLEAN NOT NULL DEFAULT FALSE,
git_enabled BOOLEAN NOT NULL DEFAULT FALSE, git_enabled BOOLEAN NOT NULL DEFAULT FALSE,
git_url TEXT, git_url TEXT,

View File

@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT, display_name TEXT,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER last_workspace_id INTEGER
); );
@@ -18,7 +19,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT, last_opened_file_path TEXT,
-- Settings fields -- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')), theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT 0, auto_save BOOLEAN NOT NULL DEFAULT 0,
git_enabled BOOLEAN NOT NULL DEFAULT 0, git_enabled BOOLEAN NOT NULL DEFAULT 0,
git_url TEXT, git_url TEXT,

View File

@@ -29,6 +29,7 @@ func TestSessionOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hash", PasswordHash: "hash",
Role: "editor", Role: "editor",
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)

View File

@@ -156,6 +156,7 @@ func TestStructQueries(t *testing.T) {
DisplayName: "Struct Query Test", DisplayName: "Struct Query Test",
PasswordHash: "hashed_password", PasswordHash: "hashed_password",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
} }
t.Run("InsertStructQuery", func(t *testing.T) { t.Run("InsertStructQuery", func(t *testing.T) {
@@ -243,6 +244,7 @@ func TestStructQueries(t *testing.T) {
DisplayName: "Struct Query Test 2", DisplayName: "Struct Query Test 2",
PasswordHash: "hashed_password2", PasswordHash: "hashed_password2",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
} }
createdUser2, err := database.CreateUser(secondUser) createdUser2, err := database.CreateUser(secondUser)
@@ -437,6 +439,7 @@ func TestEncryptedFields(t *testing.T) {
DisplayName: "Encryption Test", DisplayName: "Encryption Test",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("Failed to create test user: %v", err) t.Fatalf("Failed to create test user: %v", err)

View File

@@ -31,12 +31,14 @@ func TestSystemOperations(t *testing.T) {
DisplayName: "User 1", DisplayName: "User 1",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: "editor", Role: "editor",
Theme: "dark",
}, },
{ {
Email: "user2@test.com", Email: "user2@test.com",
DisplayName: "User 2", DisplayName: "User 2",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: "viewer", Role: "viewer",
Theme: "light",
}, },
} }

View File

@@ -34,6 +34,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hashed_password", PasswordHash: "hashed_password",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
wantErr: false, wantErr: false,
}, },
@@ -44,6 +45,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Another User", DisplayName: "Another User",
PasswordHash: "different_hash", PasswordHash: "different_hash",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
}, },
wantErr: true, wantErr: true,
errContains: "UNIQUE constraint failed", errContains: "UNIQUE constraint failed",
@@ -108,6 +110,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Get By ID User", DisplayName: "Get By ID User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -159,6 +162,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Get By Email User", DisplayName: "Get By Email User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -210,6 +214,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Original Name", DisplayName: "Original Name",
PasswordHash: "original_hash", PasswordHash: "original_hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -249,12 +254,14 @@ func TestUserOperations(t *testing.T) {
DisplayName: "User One", DisplayName: "User One",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
{ {
Email: "user2@example.com", Email: "user2@example.com",
DisplayName: "User Two", DisplayName: "User Two",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
}, },
} }
@@ -305,6 +312,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Workspace User", DisplayName: "Workspace User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -343,6 +351,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Delete User", DisplayName: "Delete User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -377,18 +386,21 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Admin One", DisplayName: "Admin One",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "dark",
}, },
{ {
Email: "admin2@example.com", Email: "admin2@example.com",
DisplayName: "Admin Two", DisplayName: "Admin Two",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "light",
}, },
{ {
Email: "editor@example.com", Email: "editor@example.com",
DisplayName: "Editor", DisplayName: "Editor",
PasswordHash: "hash3", PasswordHash: "hash3",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
} }

View File

@@ -26,6 +26,7 @@ func TestWorkspaceOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)

View File

@@ -22,6 +22,7 @@ type CreateUserRequest struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Password string `json:"password"` Password string `json:"password"`
Role models.UserRole `json:"role"` Role models.UserRole `json:"role"`
Theme string `json:"theme,omitempty"`
} }
// UpdateUserRequest holds the request fields for updating a user // UpdateUserRequest holds the request fields for updating a user
@@ -30,6 +31,7 @@ type UpdateUserRequest struct {
DisplayName string `json:"displayName,omitempty"` DisplayName string `json:"displayName,omitempty"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Role models.UserRole `json:"role,omitempty"` Role models.UserRole `json:"role,omitempty"`
Theme string `json:"theme,omitempty"`
} }
// WorkspaceStats holds workspace statistics // WorkspaceStats holds workspace statistics
@@ -164,11 +166,24 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
return return
} }
// Handle theme with validation and default
theme := req.Theme
if theme == "" {
theme = "dark" // Default theme
} else if theme != "light" && theme != "dark" {
// Invalid theme, fallback to dark
log.Debug("invalid theme value in user creation, falling back to dark",
"theme", theme,
)
theme = "dark"
}
user := &models.User{ user := &models.User{
Email: req.Email, Email: req.Email,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: req.Role, Role: req.Role,
Theme: theme,
} }
insertedUser, err := h.DB.CreateUser(user) insertedUser, err := h.DB.CreateUser(user)
@@ -196,6 +211,7 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
"newUserID", insertedUser.ID, "newUserID", insertedUser.ID,
"email", insertedUser.Email, "email", insertedUser.Email,
"role", insertedUser.Role, "role", insertedUser.Role,
"theme", insertedUser.Theme,
) )
respondJSON(w, insertedUser) respondJSON(w, insertedUser)
} }
@@ -322,6 +338,17 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc {
user.Role = req.Role user.Role = req.Role
updates["role"] = req.Role updates["role"] = req.Role
} }
if req.Theme != "" {
// Validate theme value, fallback to "dark" if invalid
if req.Theme != "light" && req.Theme != "dark" {
log.Debug("invalid theme value, falling back to dark",
"theme", req.Theme,
)
req.Theme = "dark"
}
user.Theme = req.Theme
updates["theme"] = req.Theme
}
if req.Password != "" { if req.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {

View File

@@ -410,7 +410,8 @@ func (h *Handler) UploadFile() http.HandlerFunc {
} }
}() }()
filePath := decodedPath + "/" + formFile.Filename // Use filepath.Join to properly construct the path
filePath := filepath.Join(decodedPath, formFile.Filename)
content, err := io.ReadAll(file) content, err := io.ReadAll(file)
if err != nil { if err != nil {

View File

@@ -156,6 +156,54 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
t.Run("move file", func(t *testing.T) {
srcPath := "original.md"
destPath := "moved.md"
content := "This file will be moved"
// Create file
rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape(srcPath), strings.NewReader(content), h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
// Move file
moveURL := baseURL + "/move?src_path=" + url.QueryEscape(srcPath) + "&dest_path=" + url.QueryEscape(destPath)
rr = h.makeRequest(t, http.MethodPost, moveURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
// Verify source is gone
rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(srcPath), nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code)
// Verify destination exists with correct content
rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(destPath), nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, content, rr.Body.String())
})
t.Run("rename file in directory", func(t *testing.T) {
srcPath := "folder/old-name.md"
destPath := "folder/new-name.md"
content := "This file will be renamed"
// Create file
rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape(srcPath), strings.NewReader(content), h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
// Rename file (move within same directory)
moveURL := baseURL + "/move?src_path=" + url.QueryEscape(srcPath) + "&dest_path=" + url.QueryEscape(destPath)
rr = h.makeRequest(t, http.MethodPost, moveURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
// Verify source is gone
rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(srcPath), nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code)
// Verify destination exists with correct content
rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(destPath), nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, content, rr.Body.String())
})
t.Run("last opened file", func(t *testing.T) { t.Run("last opened file", func(t *testing.T) {
// Initially should be empty // Initially should be empty
rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser) rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser)
@@ -304,15 +352,6 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
t.Run("upload with missing file_path parameter", func(t *testing.T) {
fileName := "test.txt"
fileContent := "test content"
files := map[string]string{fileName: fileContent}
rr := h.makeUploadRequest(t, baseURL+"/upload", files, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("upload with invalid file_path", func(t *testing.T) { t.Run("upload with invalid file_path", func(t *testing.T) {
fileName := "test.txt" fileName := "test.txt"
fileContent := "test content" fileContent := "test content"

View File

@@ -213,6 +213,7 @@ func (h *testHarness) createTestUser(t *testing.T, email, password string, role
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: role, Role: role,
Theme: "dark",
} }
user, err = h.DB.CreateUser(user) user, err = h.DB.CreateUser(user)

View File

@@ -24,6 +24,28 @@ func getStaticLogger() logging.Logger {
return logging.WithGroup("static") return logging.WithGroup("static")
} }
// getContentType returns the appropriate content type based on file extension
func getContentType(path string) string {
switch filepath.Ext(path) {
case ".js":
return "application/javascript"
case ".css":
return "text/css"
case ".html":
return "text/html"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".xml":
return "application/xml"
case ".yaml", ".yml":
return "application/x-yaml"
default:
return "application/octet-stream"
}
}
// ServeHTTP serves the static files // ServeHTTP serves the static files
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log := getStaticLogger().With( log := getStaticLogger().With(
@@ -77,23 +99,28 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check for pre-compressed version // Check for pre-compressed versions (prefer brotli over gzip)
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { acceptEncoding := r.Header.Get("Accept-Encoding")
// Try brotli first (better compression ratio)
if strings.Contains(acceptEncoding, "br") {
brPath := cleanPath + ".br"
if _, err := os.Stat(brPath); err == nil {
w.Header().Set("Content-Encoding", "br")
w.Header().Set("Content-Type", getContentType(cleanPath))
w.Header().Set("Vary", "Accept-Encoding")
http.ServeFile(w, r, brPath)
return
}
}
// Fall back to gzip
if strings.Contains(acceptEncoding, "gzip") {
gzPath := cleanPath + ".gz" gzPath := cleanPath + ".gz"
if _, err := os.Stat(gzPath); err == nil { if _, err := os.Stat(gzPath); err == nil {
w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Type", getContentType(cleanPath))
// Set proper content type based on original file w.Header().Set("Vary", "Accept-Encoding")
contentType := "application/octet-stream"
switch filepath.Ext(cleanPath) {
case ".js":
contentType = "application/javascript"
case ".css":
contentType = "text/css"
case ".html":
contentType = "text/html"
}
w.Header().Set("Content-Type", contentType)
http.ServeFile(w, r, gzPath) http.ServeFile(w, r, gzPath)
return return
} }

View File

@@ -26,8 +26,12 @@ func TestStaticHandler_Integration(t *testing.T) {
"index.html": []byte("<html><body>Index</body></html>"), "index.html": []byte("<html><body>Index</body></html>"),
"assets/style.css": []byte("body { color: blue; }"), "assets/style.css": []byte("body { color: blue; }"),
"assets/style.css.gz": []byte("gzipped css content"), "assets/style.css.gz": []byte("gzipped css content"),
"assets/style.css.br": []byte("brotli css content"),
"assets/script.js": []byte("console.log('test');"), "assets/script.js": []byte("console.log('test');"),
"assets/script.js.gz": []byte("gzipped js content"), "assets/script.js.gz": []byte("gzipped js content"),
"assets/script.js.br": []byte("brotli js content"),
"assets/app.js": []byte("console.log('app');"),
"assets/app.js.br": []byte("brotli app content"),
"subdir/page.html": []byte("<html><body>Page</body></html>"), "subdir/page.html": []byte("<html><body>Page</body></html>"),
"subdir/page.html.gz": []byte("gzipped html content"), "subdir/page.html.gz": []byte("gzipped html content"),
} }
@@ -52,6 +56,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType string wantType string
wantEncoding string wantEncoding string
wantCacheHeader string wantCacheHeader string
wantVary string
}{ }{
{ {
name: "serve index.html", name: "serve index.html",
@@ -69,6 +74,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType: "text/css", wantType: "text/css",
wantEncoding: "gzip", wantEncoding: "gzip",
wantCacheHeader: "public, max-age=31536000", wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
}, },
{ {
name: "serve JS with gzip support", name: "serve JS with gzip support",
@@ -79,6 +85,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType: "application/javascript", wantType: "application/javascript",
wantEncoding: "gzip", wantEncoding: "gzip",
wantCacheHeader: "public, max-age=31536000", wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
}, },
{ {
name: "serve CSS without gzip", name: "serve CSS without gzip",
@@ -114,6 +121,50 @@ func TestStaticHandler_Integration(t *testing.T) {
wantBody: []byte("<html><body>Index</body></html>"), wantBody: []byte("<html><body>Index</body></html>"),
wantType: "text/html; charset=utf-8", wantType: "text/html; charset=utf-8",
}, },
{
name: "serve CSS with brotli support",
path: "/assets/style.css",
acceptEncoding: "br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli css content"),
wantType: "text/css",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "serve JS with brotli support",
path: "/assets/script.js",
acceptEncoding: "br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli js content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "prefer brotli over gzip when both supported",
path: "/assets/script.js",
acceptEncoding: "gzip, br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli js content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "fallback to gzip when brotli not available",
path: "/assets/app.js",
acceptEncoding: "gzip, br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli app content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -139,6 +190,10 @@ func TestStaticHandler_Integration(t *testing.T) {
if tc.wantCacheHeader != "" { if tc.wantCacheHeader != "" {
assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control")) assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control"))
} }
if tc.wantVary != "" {
assert.Equal(t, tc.wantVary, w.Header().Get("Vary"))
}
} }
}) })
} }

View File

@@ -16,6 +16,7 @@ type UpdateProfileRequest struct {
Email string `json:"email"` Email string `json:"email"`
CurrentPassword string `json:"currentPassword"` CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"` NewPassword string `json:"newPassword"`
Theme string `json:"theme"`
} }
// DeleteAccountRequest represents a user account deletion request // DeleteAccountRequest represents a user account deletion request
@@ -149,6 +150,19 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
updates["displayNameChanged"] = true updates["displayNameChanged"] = true
} }
// Update theme if provided
if req.Theme != "" {
// Validate theme value, fallback to "dark" if invalid
if req.Theme != "light" && req.Theme != "dark" {
log.Debug("invalid theme value, falling back to dark",
"theme", req.Theme,
)
req.Theme = "dark"
}
user.Theme = req.Theme
updates["themeChanged"] = true
}
// Update user in database // Update user in database
if err := h.DB.UpdateUser(user); err != nil { if err := h.DB.UpdateUser(user); err != nil {
log.Error("failed to update user in database", log.Error("failed to update user in database",

View File

@@ -87,7 +87,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
"clientIP", r.RemoteAddr, "clientIP", r.RemoteAddr,
) )
var workspace models.Workspace var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
log.Debug("invalid request body received", log.Debug("invalid request body received",
"error", err.Error(), "error", err.Error(),
@@ -104,7 +104,21 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
return return
} }
// Get user to access their theme preference
user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil {
log.Error("failed to fetch user from database",
"error", err.Error(),
)
respondError(w, "Failed to get user", http.StatusInternalServerError)
return
}
workspace.UserID = ctx.UserID workspace.UserID = ctx.UserID
// Use user's theme as default if not provided
if workspace.Theme == "" {
workspace.Theme = user.Theme
}
if err := h.DB.CreateWorkspace(&workspace); err != nil { if err := h.DB.CreateWorkspace(&workspace); err != nil {
log.Error("failed to create workspace in database", log.Error("failed to create workspace in database",
"error", err.Error(), "error", err.Error(),
@@ -145,6 +159,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
log.Info("workspace created", log.Info("workspace created",
"workspaceID", workspace.ID, "workspaceID", workspace.ID,
"workspaceName", workspace.Name, "workspaceName", workspace.Name,
"theme", workspace.Theme,
"gitEnabled", workspace.GitEnabled, "gitEnabled", workspace.GitEnabled,
) )
respondJSON(w, workspace) respondJSON(w, workspace)

View File

@@ -25,6 +25,7 @@ type User struct {
DisplayName string `json:"displayName" db:"display_name"` DisplayName string `json:"displayName" db:"display_name"`
PasswordHash string `json:"-" db:"password_hash"` PasswordHash string `json:"-" db:"password_hash"`
Role UserRole `json:"role" db:"role" validate:"required,oneof=admin editor viewer"` Role UserRole `json:"role" db:"role" validate:"required,oneof=admin editor viewer"`
Theme string `json:"theme" db:"theme" validate:"required,oneof=light dark"`
CreatedAt time.Time `json:"createdAt" db:"created_at,default"` CreatedAt time.Time `json:"createdAt" db:"created_at,default"`
LastWorkspaceID int `json:"lastWorkspaceId" db:"last_workspace_id"` LastWorkspaceID int `json:"lastWorkspaceId" db:"last_workspace_id"`
} }

View File

@@ -13,7 +13,7 @@ type Workspace struct {
LastOpenedFilePath string `json:"lastOpenedFilePath" db:"last_opened_file_path"` LastOpenedFilePath string `json:"lastOpenedFilePath" db:"last_opened_file_path"`
// Integrated settings // Integrated settings
Theme string `json:"theme" db:"theme" validate:"oneof=light dark"` Theme string `json:"theme" db:"theme" validate:"required,oneof=light dark"`
AutoSave bool `json:"autoSave" db:"auto_save"` AutoSave bool `json:"autoSave" db:"auto_save"`
ShowHiddenFiles bool `json:"showHiddenFiles" db:"show_hidden_files"` ShowHiddenFiles bool `json:"showHiddenFiles" db:"show_hidden_files"`
GitEnabled bool `json:"gitEnabled" db:"git_enabled"` GitEnabled bool `json:"gitEnabled" db:"git_enabled"`
@@ -40,7 +40,7 @@ func (w *Workspace) ValidateGitSettings() error {
func (w *Workspace) SetDefaultSettings() { func (w *Workspace) SetDefaultSettings() {
if w.Theme == "" { if w.Theme == "" {
w.Theme = "light" w.Theme = "dark"
} }
w.AutoSave = w.AutoSave || false w.AutoSave = w.AutoSave || false