opensidian 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +35 -0
- package/.github/ISSUE_TEMPLATE/question.md +23 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +45 -0
- package/.github/README.md +5 -0
- package/.github/workflows/cd.yml +44 -0
- package/.github/workflows/ci.yml +128 -0
- package/.github/workflows/qa.yml +45 -0
- package/.planning/PROJECT.md +96 -0
- package/.planning/REQUIREMENTS.md +66 -0
- package/.planning/ROADMAP.md +129 -0
- package/.planning/STATE.md +47 -0
- package/.planning/config.json +14 -0
- package/CONTRIBUTING.md +232 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dist/api/auth.d.ts +5 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +112 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/routes.d.ts +3 -0
- package/dist/api/routes.d.ts.map +1 -0
- package/dist/api/routes.js +119 -0
- package/dist/api/routes.js.map +1 -0
- package/dist/api/themes.d.ts +3 -0
- package/dist/api/themes.d.ts.map +1 -0
- package/dist/api/themes.js +48 -0
- package/dist/api/themes.js.map +1 -0
- package/dist/core/graph.d.ts +16 -0
- package/dist/core/graph.d.ts.map +1 -0
- package/dist/core/graph.js +115 -0
- package/dist/core/graph.js.map +1 -0
- package/dist/core/markdown.d.ts +21 -0
- package/dist/core/markdown.d.ts.map +1 -0
- package/dist/core/markdown.js +77 -0
- package/dist/core/markdown.js.map +1 -0
- package/dist/core/search.d.ts +34 -0
- package/dist/core/search.d.ts.map +1 -0
- package/dist/core/search.js +159 -0
- package/dist/core/search.js.map +1 -0
- package/dist/core/sync.d.ts +30 -0
- package/dist/core/sync.d.ts.map +1 -0
- package/dist/core/sync.js +121 -0
- package/dist/core/sync.js.map +1 -0
- package/dist/core/vault.d.ts +28 -0
- package/dist/core/vault.d.ts.map +1 -0
- package/dist/core/vault.js +235 -0
- package/dist/core/vault.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/cli.d.ts +3 -0
- package/dist/mcp/cli.d.ts.map +1 -0
- package/dist/mcp/cli.js +7 -0
- package/dist/mcp/cli.js.map +1 -0
- package/dist/mcp/server.d.ts +18 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +272 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/plugins/host.d.ts +23 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +104 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/sample-plugin.d.ts +10 -0
- package/dist/plugins/sample-plugin.d.ts.map +1 -0
- package/dist/plugins/sample-plugin.js +23 -0
- package/dist/plugins/sample-plugin.js.map +1 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +77 -0
- package/dist/server.js.map +1 -0
- package/dist/shared/types.d.ts +86 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/docker/Dockerfile +37 -0
- package/docker/docker-compose.yml +46 -0
- package/docs/ARCHITECTURE.md +321 -0
- package/futuras_implementacoes.md +0 -0
- package/package.json +65 -0
- package/scripts/fix-gitignore.ps1 +5 -0
- package/scripts/seed-notes.mjs +60 -0
- package/src/api/auth.ts +130 -0
- package/src/api/routes.ts +133 -0
- package/src/api/themes.ts +60 -0
- package/src/core/graph.ts +145 -0
- package/src/core/markdown.ts +92 -0
- package/src/core/search.ts +208 -0
- package/src/core/sync.ts +157 -0
- package/src/core/vault.ts +286 -0
- package/src/index.ts +37 -0
- package/src/mcp/cli.ts +7 -0
- package/src/mcp/server.ts +296 -0
- package/src/plugins/host.ts +120 -0
- package/src/plugins/sample-plugin.ts +29 -0
- package/src/server.ts +90 -0
- package/src/shared/types.ts +92 -0
- package/tests/api/routes.test.ts +167 -0
- package/tests/core/graph.test.ts +236 -0
- package/tests/core/markdown.test.ts +157 -0
- package/tests/core/search.test.ts +132 -0
- package/tests/core/sync.test.ts +62 -0
- package/tests/core/vault.test.ts +162 -0
- package/tests/mcp/server.test.ts +118 -0
- package/tests/plugins/host.test.ts +165 -0
- package/tests/plugins/sample-plugin.test.ts +35 -0
- package/tests/server.test.ts +76 -0
- package/tsconfig.json +27 -0
- package/vite.config.ts +27 -0
- package/vitest.config.ts +33 -0
- package/web/index.html +13 -0
- package/web/package.json +26 -0
- package/web/public/favicon.svg +4 -0
- package/web/src/App.tsx +63 -0
- package/web/src/api/auth.ts +65 -0
- package/web/src/api/client.ts +117 -0
- package/web/src/api/themes.ts +78 -0
- package/web/src/components/GraphView.tsx +139 -0
- package/web/src/components/Layout.tsx +74 -0
- package/web/src/components/LoginPage.tsx +52 -0
- package/web/src/components/NoteEditor.tsx +114 -0
- package/web/src/components/NoteList.tsx +95 -0
- package/web/src/components/RegisterPage.tsx +58 -0
- package/web/src/components/SearchBar.tsx +71 -0
- package/web/src/components/SearchPanel.tsx +152 -0
- package/web/src/components/ThemeEditor.tsx +129 -0
- package/web/src/components/ThemeSelector.tsx +41 -0
- package/web/src/components/VaultList.tsx +89 -0
- package/web/src/hooks/AuthContext.tsx +57 -0
- package/web/src/hooks/ThemeContext.tsx +77 -0
- package/web/src/hooks/useWebSocket.ts +34 -0
- package/web/src/main.tsx +10 -0
- package/web/src/styles/global.css +449 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +19 -0
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
globals: true,
|
|
7
|
+
environment: 'node',
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
reporter: ['text', 'json', 'html'],
|
|
11
|
+
reportsDirectory: './coverage',
|
|
12
|
+
include: ['src/**/*.ts'],
|
|
13
|
+
exclude: [
|
|
14
|
+
'src/index.ts',
|
|
15
|
+
'src/**/types.ts',
|
|
16
|
+
'**/*.d.ts',
|
|
17
|
+
],
|
|
18
|
+
thresholds: {
|
|
19
|
+
statements: 80,
|
|
20
|
+
branches: 80,
|
|
21
|
+
functions: 80,
|
|
22
|
+
lines: 80,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
include: ['tests/**/*.test.ts'],
|
|
26
|
+
testTimeout: 10000,
|
|
27
|
+
},
|
|
28
|
+
resolve: {
|
|
29
|
+
alias: {
|
|
30
|
+
'@': resolve(__dirname, './src'),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
package/web/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="pt-BR">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>OpenSidian</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/web/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opensidian-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"d3": "^7.9.0",
|
|
13
|
+
"marked": "^14.1.2",
|
|
14
|
+
"react": "^18.3.1",
|
|
15
|
+
"react-dom": "^18.3.1",
|
|
16
|
+
"react-router-dom": "^6.26.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/d3": "^7.4.3",
|
|
20
|
+
"@types/react": "^18.3.5",
|
|
21
|
+
"@types/react-dom": "^18.3.0",
|
|
22
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
23
|
+
"typescript": "^5.6.2",
|
|
24
|
+
"vite": "^5.4.8"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/web/src/App.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|
3
|
+
import { AuthProvider, useAuth } from './hooks/AuthContext';
|
|
4
|
+
import { ThemeProvider } from './hooks/ThemeContext';
|
|
5
|
+
import Layout from './components/Layout';
|
|
6
|
+
import VaultList from './components/VaultList';
|
|
7
|
+
import NoteList from './components/NoteList';
|
|
8
|
+
import NoteEditor from './components/NoteEditor';
|
|
9
|
+
import GraphView from './components/GraphView';
|
|
10
|
+
import ThemeEditor from './components/ThemeEditor';
|
|
11
|
+
import SearchPanel from './components/SearchPanel';
|
|
12
|
+
import LoginPage from './components/LoginPage';
|
|
13
|
+
import RegisterPage from './components/RegisterPage';
|
|
14
|
+
|
|
15
|
+
function AppRoutes() {
|
|
16
|
+
const [currentVault, setCurrentVault] = useState<string>('');
|
|
17
|
+
const { user, loading, logout } = useAuth();
|
|
18
|
+
|
|
19
|
+
if (loading) {
|
|
20
|
+
return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}><p>Carregando...</p></div>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!user) {
|
|
24
|
+
return (
|
|
25
|
+
<Routes>
|
|
26
|
+
<Route path="/login" element={<LoginPage />} />
|
|
27
|
+
<Route path="/register" element={<RegisterPage />} />
|
|
28
|
+
<Route path="*" element={<Navigate to="/login" replace />} />
|
|
29
|
+
</Routes>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ThemeProvider>
|
|
35
|
+
<Layout
|
|
36
|
+
currentVault={currentVault}
|
|
37
|
+
onVaultChange={setCurrentVault}
|
|
38
|
+
user={user}
|
|
39
|
+
onLogout={logout}
|
|
40
|
+
>
|
|
41
|
+
<Routes>
|
|
42
|
+
<Route path="/" element={<VaultList onSelect={setCurrentVault} />} />
|
|
43
|
+
<Route path="/notes" element={<NoteList vaultPath={currentVault} />} />
|
|
44
|
+
<Route path="/notes/:path" element={<NoteEditor vaultPath={currentVault} />} />
|
|
45
|
+
<Route path="/graph" element={<GraphView vaultPath={currentVault} />} />
|
|
46
|
+
<Route path="/themes" element={<ThemeEditor />} />
|
|
47
|
+
<Route path="/search" element={<SearchPanel />} />
|
|
48
|
+
<Route path="*" element={<Navigate to="/" replace />} />
|
|
49
|
+
</Routes>
|
|
50
|
+
</Layout>
|
|
51
|
+
</ThemeProvider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function App() {
|
|
56
|
+
return (
|
|
57
|
+
<BrowserRouter>
|
|
58
|
+
<AuthProvider>
|
|
59
|
+
<AppRoutes />
|
|
60
|
+
</AuthProvider>
|
|
61
|
+
</BrowserRouter>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const AUTH_BASE = '/auth';
|
|
2
|
+
|
|
3
|
+
function authRequest<T>(url: string, options?: RequestInit): Promise<T> {
|
|
4
|
+
return fetch(`${AUTH_BASE}${url}`, {
|
|
5
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6
|
+
...options,
|
|
7
|
+
}).then(async res => {
|
|
8
|
+
const body = await res.json();
|
|
9
|
+
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
|
10
|
+
return body;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuthUser {
|
|
15
|
+
id: string;
|
|
16
|
+
email: string;
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AuthResponse {
|
|
21
|
+
token: string;
|
|
22
|
+
user: AuthUser;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getToken(): string | null {
|
|
26
|
+
return localStorage.getItem('opensidian_token');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function setToken(token: string): void {
|
|
30
|
+
localStorage.setItem('opensidian_token', token);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function removeToken(): void {
|
|
34
|
+
localStorage.removeItem('opensidian_token');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function authHeaders(): Record<string, string> {
|
|
38
|
+
const token = getToken();
|
|
39
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const authApi = {
|
|
43
|
+
register: (email: string, password: string, name: string) =>
|
|
44
|
+
authRequest<AuthResponse>('/register', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
body: JSON.stringify({ email, password, name }),
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
login: (email: string, password: string) =>
|
|
50
|
+
authRequest<AuthResponse>('/login', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: JSON.stringify({ email, password }),
|
|
53
|
+
}),
|
|
54
|
+
|
|
55
|
+
me: () =>
|
|
56
|
+
authRequest<{ user: AuthUser & { createdAt: string } }>('/me', {
|
|
57
|
+
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
logout: () =>
|
|
61
|
+
authRequest<{ message: string }>('/logout', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const BASE = '/api';
|
|
2
|
+
|
|
3
|
+
function authHeaders(): Record<string, string> {
|
|
4
|
+
const token = localStorage.getItem('opensidian_token');
|
|
5
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
|
9
|
+
const res = await fetch(`${BASE}${url}`, {
|
|
10
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
11
|
+
...options,
|
|
12
|
+
});
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
const body = await res.json().catch(() => ({}));
|
|
15
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
16
|
+
}
|
|
17
|
+
if (res.status === 204) return undefined as T;
|
|
18
|
+
return res.json();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Vault {
|
|
22
|
+
path: string;
|
|
23
|
+
name: string;
|
|
24
|
+
created: string;
|
|
25
|
+
notes: Note[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Note {
|
|
29
|
+
path: string;
|
|
30
|
+
filename: string;
|
|
31
|
+
content: string;
|
|
32
|
+
frontmatter: Record<string, unknown>;
|
|
33
|
+
links: string[];
|
|
34
|
+
created: string;
|
|
35
|
+
modified: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GraphNode {
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
tags?: string[];
|
|
42
|
+
connections?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GraphEdge {
|
|
46
|
+
id: string;
|
|
47
|
+
source: string;
|
|
48
|
+
target: string;
|
|
49
|
+
type: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Graph {
|
|
53
|
+
nodes: GraphNode[];
|
|
54
|
+
edges: GraphEdge[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SearchResultItem {
|
|
58
|
+
path: string;
|
|
59
|
+
filename: string;
|
|
60
|
+
snippet: string;
|
|
61
|
+
score: number;
|
|
62
|
+
tags: string[];
|
|
63
|
+
modified: string;
|
|
64
|
+
backlinks: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const api = {
|
|
68
|
+
vaults: {
|
|
69
|
+
list: () => request<{ vaults: Vault[] }>('/vaults'),
|
|
70
|
+
create: (name: string, path: string) =>
|
|
71
|
+
request<{ vault: Vault }>('/vaults', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
body: JSON.stringify({ name, path }),
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
notes: {
|
|
77
|
+
list: (vaultPath: string) =>
|
|
78
|
+
request<{ notes: Note[] }>(`/notes?vault=${encodeURIComponent(vaultPath)}`),
|
|
79
|
+
get: (vaultPath: string, path: string) =>
|
|
80
|
+
request<{ note: Note }>(
|
|
81
|
+
`/notes/${encodeURIComponent(path)}?vault=${encodeURIComponent(vaultPath)}`
|
|
82
|
+
),
|
|
83
|
+
create: (vaultPath: string, filename: string, content: string) =>
|
|
84
|
+
request<{ note: Note }>('/notes', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
body: JSON.stringify({ vaultPath, filename, content }),
|
|
87
|
+
}),
|
|
88
|
+
update: (vaultPath: string, path: string, content: string) =>
|
|
89
|
+
request<{ note: Note }>(
|
|
90
|
+
`/notes/${encodeURIComponent(path)}?vault=${encodeURIComponent(vaultPath)}`,
|
|
91
|
+
{ method: 'PUT', body: JSON.stringify({ content }) }
|
|
92
|
+
),
|
|
93
|
+
delete: (vaultPath: string, path: string) =>
|
|
94
|
+
request<void>(
|
|
95
|
+
`/notes/${encodeURIComponent(path)}?vault=${encodeURIComponent(vaultPath)}`,
|
|
96
|
+
{ method: 'DELETE' }
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
graph: {
|
|
100
|
+
get: (vaultPath: string) =>
|
|
101
|
+
request<{ graph: Graph }>(`/graph?vault=${encodeURIComponent(vaultPath)}`),
|
|
102
|
+
neighbors: (vaultPath: string, path: string) =>
|
|
103
|
+
request<{ neighbors: GraphNode[] }>(
|
|
104
|
+
`/graph/neighbors/${encodeURIComponent(path)}?vault=${encodeURIComponent(vaultPath)}`
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
search: {
|
|
108
|
+
fullText: (vaultPath: string, q: string, filters?: { tag?: string; after?: string; before?: string; hasBacklinks?: boolean }) => {
|
|
109
|
+
let url = `/notes/search?vault=${encodeURIComponent(vaultPath)}&q=${encodeURIComponent(q)}`;
|
|
110
|
+
if (filters?.tag) url += `&tag=${encodeURIComponent(filters.tag)}`;
|
|
111
|
+
if (filters?.after) url += `&after=${encodeURIComponent(filters.after)}`;
|
|
112
|
+
if (filters?.before) url += `&before=${encodeURIComponent(filters.before)}`;
|
|
113
|
+
if (filters?.hasBacklinks !== undefined) url += `&hasBacklinks=${filters.hasBacklinks}`;
|
|
114
|
+
return request<{ results: SearchResultItem[] }>(url);
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { authHeaders } from './auth';
|
|
2
|
+
|
|
3
|
+
const BASE = '/api';
|
|
4
|
+
|
|
5
|
+
export interface CustomTheme {
|
|
6
|
+
name: string;
|
|
7
|
+
variables: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const themesApi = {
|
|
11
|
+
list: () =>
|
|
12
|
+
fetch(`${BASE}/themes`, { headers: { ...authHeaders() } })
|
|
13
|
+
.then(res => res.json() as Promise<{ themes: CustomTheme[] }>),
|
|
14
|
+
|
|
15
|
+
save: (theme: CustomTheme) =>
|
|
16
|
+
fetch(`${BASE}/themes`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
|
19
|
+
body: JSON.stringify(theme),
|
|
20
|
+
}).then(res => {
|
|
21
|
+
if (!res.ok) throw new Error('Falha ao salvar tema');
|
|
22
|
+
return res.json() as Promise<{ theme: CustomTheme }>;
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
delete: (name: string) =>
|
|
26
|
+
fetch(`${BASE}/themes/${encodeURIComponent(name)}`, {
|
|
27
|
+
method: 'DELETE',
|
|
28
|
+
headers: { ...authHeaders() },
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const BUILT_IN_THEMES: CustomTheme[] = [
|
|
33
|
+
{
|
|
34
|
+
name: 'Claro',
|
|
35
|
+
variables: {
|
|
36
|
+
'--bg': '#ffffff',
|
|
37
|
+
'--bg-secondary': '#f5f5f5',
|
|
38
|
+
'--bg-tertiary': '#ebebeb',
|
|
39
|
+
'--text': '#1a1a1a',
|
|
40
|
+
'--text-secondary': '#666',
|
|
41
|
+
'--border': '#ddd',
|
|
42
|
+
'--accent': '#6c31e0',
|
|
43
|
+
'--accent-hover': '#5a28c0',
|
|
44
|
+
'--danger': '#e03e3e',
|
|
45
|
+
'--success': '#2e7d32',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'Escuro',
|
|
50
|
+
variables: {
|
|
51
|
+
'--bg': '#1a1a2e',
|
|
52
|
+
'--bg-secondary': '#16213e',
|
|
53
|
+
'--bg-tertiary': '#0f3460',
|
|
54
|
+
'--text': '#e0e0e0',
|
|
55
|
+
'--text-secondary': '#999',
|
|
56
|
+
'--border': '#333',
|
|
57
|
+
'--accent': '#7c4dff',
|
|
58
|
+
'--accent-hover': '#651fff',
|
|
59
|
+
'--danger': '#cf6679',
|
|
60
|
+
'--success': '#4caf50',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export const DEFAULT_VARIABLES = BUILT_IN_THEMES[0].variables;
|
|
66
|
+
|
|
67
|
+
export const THEME_VARIABLE_LABELS: Record<string, string> = {
|
|
68
|
+
'--bg': 'Fundo principal',
|
|
69
|
+
'--bg-secondary': 'Fundo secundário',
|
|
70
|
+
'--bg-tertiary': 'Fundo terciário',
|
|
71
|
+
'--text': 'Texto principal',
|
|
72
|
+
'--text-secondary': 'Texto secundário',
|
|
73
|
+
'--border': 'Borda',
|
|
74
|
+
'--accent': 'Cor destaque',
|
|
75
|
+
'--accent-hover': 'Destaque hover',
|
|
76
|
+
'--danger': 'Erro/perigo',
|
|
77
|
+
'--success': 'Sucesso',
|
|
78
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import * as d3 from 'd3';
|
|
3
|
+
import { api, Graph, GraphNode } from '../api/client';
|
|
4
|
+
|
|
5
|
+
interface GraphViewProps {
|
|
6
|
+
vaultPath: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type SimNode = d3.SimulationNodeDatum & GraphNode;
|
|
10
|
+
type SimLink = d3.SimulationLinkDatum<SimNode>;
|
|
11
|
+
|
|
12
|
+
export default function GraphView({ vaultPath }: GraphViewProps) {
|
|
13
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
14
|
+
const [graph, setGraph] = useState<Graph | null>(null);
|
|
15
|
+
const [loading, setLoading] = useState(true);
|
|
16
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!vaultPath) return;
|
|
20
|
+
setLoading(true);
|
|
21
|
+
api.graph.get(vaultPath).then(res => {
|
|
22
|
+
setGraph(res.graph);
|
|
23
|
+
setLoading(false);
|
|
24
|
+
}).catch(() => {
|
|
25
|
+
setGraph(null);
|
|
26
|
+
setLoading(false);
|
|
27
|
+
});
|
|
28
|
+
}, [vaultPath]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!svgRef.current || !graph || graph.nodes.length === 0) return;
|
|
32
|
+
|
|
33
|
+
const svg = d3.select(svgRef.current);
|
|
34
|
+
const width = svgRef.current.clientWidth;
|
|
35
|
+
const height = svgRef.current.clientHeight;
|
|
36
|
+
|
|
37
|
+
svg.selectAll('*').remove();
|
|
38
|
+
|
|
39
|
+
const nodes: SimNode[] = graph.nodes.map(n => ({ ...n }));
|
|
40
|
+
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
41
|
+
const links: SimLink[] = graph.edges
|
|
42
|
+
.filter(e => nodeMap.has(e.source as string) && nodeMap.has(e.target as string))
|
|
43
|
+
.map(e => ({ source: e.source as string, target: e.target as string }));
|
|
44
|
+
|
|
45
|
+
const simulation = d3.forceSimulation(nodes)
|
|
46
|
+
.force('link', d3.forceLink(links).distance(120))
|
|
47
|
+
.force('charge', d3.forceManyBody().strength(-300))
|
|
48
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
49
|
+
.force('collision', d3.forceCollide(30));
|
|
50
|
+
|
|
51
|
+
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
52
|
+
.scaleExtent([0.1, 4])
|
|
53
|
+
.on('zoom', (event) => g.attr('transform', event.transform));
|
|
54
|
+
|
|
55
|
+
svg.call(zoom);
|
|
56
|
+
|
|
57
|
+
const g = svg.append('g');
|
|
58
|
+
|
|
59
|
+
const linkElements = g.append('g')
|
|
60
|
+
.selectAll('line')
|
|
61
|
+
.data(links)
|
|
62
|
+
.join('line')
|
|
63
|
+
.attr('stroke', 'var(--border)')
|
|
64
|
+
.attr('stroke-width', 1.5)
|
|
65
|
+
.attr('stroke-opacity', 0.6);
|
|
66
|
+
|
|
67
|
+
const nodeElements = g.append('g')
|
|
68
|
+
.selectAll('circle')
|
|
69
|
+
.data(nodes)
|
|
70
|
+
.join('circle')
|
|
71
|
+
.attr('r', 8)
|
|
72
|
+
.attr('fill', (d: SimNode) => d.id === selected ? 'var(--accent)' : 'var(--accent)')
|
|
73
|
+
.attr('stroke', '#fff')
|
|
74
|
+
.attr('stroke-width', 2)
|
|
75
|
+
.attr('opacity', (d: SimNode) => selected && d.id !== selected ? 0.3 : 1)
|
|
76
|
+
.style('cursor', 'pointer')
|
|
77
|
+
.on('click', (_event: unknown, d: SimNode) => {
|
|
78
|
+
setSelected(d.id);
|
|
79
|
+
nodeElements.attr('opacity', (n: SimNode) => n.id === d.id ? 1 : 0.3);
|
|
80
|
+
linkElements.attr('opacity', (l: SimLink) => {
|
|
81
|
+
const s = typeof l.source === 'object' ? (l.source as SimNode).id : l.source;
|
|
82
|
+
const t = typeof l.target === 'object' ? (l.target as SimNode).id : l.target;
|
|
83
|
+
return s === d.id || t === d.id ? 1 : 0.1;
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const labels = g.append('g')
|
|
88
|
+
.selectAll('text')
|
|
89
|
+
.data(nodes)
|
|
90
|
+
.join('text')
|
|
91
|
+
.text((d: SimNode) => d.label)
|
|
92
|
+
.attr('font-size', '11px')
|
|
93
|
+
.attr('dx', 12)
|
|
94
|
+
.attr('dy', 4)
|
|
95
|
+
.attr('fill', 'var(--text)')
|
|
96
|
+
.attr('opacity', (d: SimNode) => selected && d.id !== selected ? 0.3 : 0.8);
|
|
97
|
+
|
|
98
|
+
simulation.on('tick', () => {
|
|
99
|
+
linkElements
|
|
100
|
+
.attr('x1', (d: SimLink) => (d.source as SimNode).x!)
|
|
101
|
+
.attr('y1', (d: SimLink) => (d.source as SimNode).y!)
|
|
102
|
+
.attr('x2', (d: SimLink) => (d.target as SimNode).x!)
|
|
103
|
+
.attr('y2', (d: SimLink) => (d.target as SimNode).y!);
|
|
104
|
+
nodeElements
|
|
105
|
+
.attr('cx', (d: SimNode) => d.x!)
|
|
106
|
+
.attr('cy', (d: SimNode) => d.y!);
|
|
107
|
+
labels
|
|
108
|
+
.attr('x', (d: SimNode) => d.x!)
|
|
109
|
+
.attr('y', (d: SimNode) => d.y!);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return () => { simulation.stop(); };
|
|
113
|
+
}, [graph, selected]);
|
|
114
|
+
|
|
115
|
+
if (!vaultPath) {
|
|
116
|
+
return <div className="content-area"><div className="empty-state"><h2>Selecione um vault</h2><p>Escolha um vault para ver o grafo</p></div></div>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (loading) return <div className="content-area"><p>Carregando grafo...</p></div>;
|
|
120
|
+
|
|
121
|
+
if (!graph || graph.nodes.length === 0) {
|
|
122
|
+
return <div className="content-area"><div className="empty-state"><h2>Grafo vazio</h2><p>Crie notas com links para ver o grafo</p></div></div>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="content-area" style={{ padding: 0 }}>
|
|
127
|
+
<div className="toolbar">
|
|
128
|
+
<strong>Grafo de conhecimento</strong>
|
|
129
|
+
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
|
130
|
+
{graph.nodes.length} nós · {graph.edges.length} arestas
|
|
131
|
+
</span>
|
|
132
|
+
{selected && (
|
|
133
|
+
<button onClick={() => setSelected(null)} style={{ marginLeft: 'auto' }}>Limpar seleção</button>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
<svg ref={svgRef} className="graph-container" />
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
3
|
+
import { AuthUser } from '../api/auth';
|
|
4
|
+
import ThemeSelector from './ThemeSelector';
|
|
5
|
+
|
|
6
|
+
interface LayoutProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
currentVault: string;
|
|
9
|
+
onVaultChange: (vault: string) => void;
|
|
10
|
+
user: AuthUser;
|
|
11
|
+
onLogout: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function Layout(props: LayoutProps) {
|
|
15
|
+
const { children, currentVault, user, onLogout } = props;
|
|
16
|
+
const location = useLocation();
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="layout">
|
|
20
|
+
<aside className="sidebar">
|
|
21
|
+
<div className="sidebar-header">
|
|
22
|
+
<h1>
|
|
23
|
+
<svg width="20" height="20" viewBox="0 0 32 32" fill="none">
|
|
24
|
+
<rect width="32" height="32" rx="6" fill="#6C31E0"/>
|
|
25
|
+
<path d="M8 10h16M8 16h16M8 22h10" stroke="white" strokeWidth="2.5" strokeLinecap="round"/>
|
|
26
|
+
</svg>
|
|
27
|
+
OpenSidian
|
|
28
|
+
</h1>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<nav className="sidebar-nav">
|
|
32
|
+
<Link to="/" className={`nav-item ${location.pathname === '/' ? 'active' : ''}`}>
|
|
33
|
+
📂 Vaults
|
|
34
|
+
</Link>
|
|
35
|
+
<Link to="/search" className={`nav-item ${location.pathname === '/search' ? 'active' : ''}`}>
|
|
36
|
+
🔍 Busca avançada
|
|
37
|
+
</Link>
|
|
38
|
+
{currentVault && (
|
|
39
|
+
<>
|
|
40
|
+
<Link to="/notes" className={`nav-item ${location.pathname.startsWith('/notes') ? 'active' : ''}`}>
|
|
41
|
+
📝 Notas
|
|
42
|
+
</Link>
|
|
43
|
+
<Link to="/graph" className={`nav-item ${location.pathname === '/graph' ? 'active' : ''}`}>
|
|
44
|
+
🔗 Grafo
|
|
45
|
+
</Link>
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</nav>
|
|
49
|
+
|
|
50
|
+
<div style={{ flex: 1 }} />
|
|
51
|
+
|
|
52
|
+
<ThemeSelector />
|
|
53
|
+
|
|
54
|
+
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 16px' }}>
|
|
55
|
+
<div style={{ fontSize: '13px', fontWeight: 500, marginBottom: 4 }}>{user.name}</div>
|
|
56
|
+
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', marginBottom: 8 }}>{user.email}</div>
|
|
57
|
+
<button onClick={onLogout} style={{ width: '100%', fontSize: '12px', padding: '4px 8px' }}>
|
|
58
|
+
Sair
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{currentVault && (
|
|
63
|
+
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--border)', fontSize: '12px', color: 'var(--text-secondary)' }}>
|
|
64
|
+
Vault: {currentVault.split('/').pop() || currentVault}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
</aside>
|
|
68
|
+
|
|
69
|
+
<main className="main-content">
|
|
70
|
+
{children}
|
|
71
|
+
</main>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useNavigate, Link } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '../hooks/AuthContext';
|
|
4
|
+
|
|
5
|
+
export default function LoginPage() {
|
|
6
|
+
const [email, setEmail] = useState('');
|
|
7
|
+
const [password, setPassword] = useState('');
|
|
8
|
+
const [error, setError] = useState('');
|
|
9
|
+
const [busy, setBusy] = useState(false);
|
|
10
|
+
const { login } = useAuth();
|
|
11
|
+
const navigate = useNavigate();
|
|
12
|
+
|
|
13
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
setError('');
|
|
16
|
+
setBusy(true);
|
|
17
|
+
try {
|
|
18
|
+
await login(email, password);
|
|
19
|
+
navigate('/');
|
|
20
|
+
} catch (err: unknown) {
|
|
21
|
+
setError(err instanceof Error ? err.message : 'Erro ao entrar');
|
|
22
|
+
} finally {
|
|
23
|
+
setBusy(false);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="auth-page">
|
|
29
|
+
<div className="auth-card">
|
|
30
|
+
<div className="auth-logo">
|
|
31
|
+
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
|
32
|
+
<rect width="32" height="32" rx="6" fill="#6C31E0"/>
|
|
33
|
+
<path d="M8 10h16M8 16h16M8 22h10" stroke="white" strokeWidth="2.5" strokeLinecap="round"/>
|
|
34
|
+
</svg>
|
|
35
|
+
<h1>OpenSidian</h1>
|
|
36
|
+
</div>
|
|
37
|
+
<h2>Entrar</h2>
|
|
38
|
+
<form onSubmit={handleSubmit}>
|
|
39
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required />
|
|
40
|
+
<input type="password" placeholder="Senha" value={password} onChange={e => setPassword(e.target.value)} required />
|
|
41
|
+
{error && <p className="auth-error">{error}</p>}
|
|
42
|
+
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
|
|
43
|
+
{busy ? 'Entrando...' : 'Entrar'}
|
|
44
|
+
</button>
|
|
45
|
+
</form>
|
|
46
|
+
<p className="auth-link">
|
|
47
|
+
Não tem conta? <Link to="/register">Cadastre-se</Link>
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|