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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
2
|
+
import { useParams, useNavigate } from 'react-router-dom';
|
|
3
|
+
import { marked } from 'marked';
|
|
4
|
+
import { api, Note } from '../api/client';
|
|
5
|
+
|
|
6
|
+
interface NoteEditorProps {
|
|
7
|
+
vaultPath: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function NoteEditor({ vaultPath }: NoteEditorProps) {
|
|
11
|
+
const { path: notePath } = useParams();
|
|
12
|
+
const [note, setNote] = useState<Note | null>(null);
|
|
13
|
+
const [content, setContent] = useState('');
|
|
14
|
+
const [html, setHtml] = useState('');
|
|
15
|
+
const [saving, setSaving] = useState(false);
|
|
16
|
+
const [dirty, setDirty] = useState(false);
|
|
17
|
+
const navigate = useNavigate();
|
|
18
|
+
|
|
19
|
+
const decodePath = useCallback(() => {
|
|
20
|
+
return notePath ? decodeURIComponent(notePath) : '';
|
|
21
|
+
}, [notePath]);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!vaultPath || !notePath) return;
|
|
25
|
+
const load = async () => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await api.notes.get(vaultPath, decodePath());
|
|
28
|
+
setNote(res.note);
|
|
29
|
+
setContent(res.note.content);
|
|
30
|
+
} catch {
|
|
31
|
+
setNote(null);
|
|
32
|
+
setContent('');
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
load();
|
|
36
|
+
}, [vaultPath, notePath]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
40
|
+
Promise.resolve(marked.parse(content)).then(h => setHtml(h));
|
|
41
|
+
}, [content]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!dirty || saving) return;
|
|
45
|
+
const timer = setTimeout(async () => {
|
|
46
|
+
setSaving(true);
|
|
47
|
+
try {
|
|
48
|
+
await api.notes.update(vaultPath, decodePath(), content);
|
|
49
|
+
setDirty(false);
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
} finally {
|
|
53
|
+
setSaving(false);
|
|
54
|
+
}
|
|
55
|
+
}, 1500);
|
|
56
|
+
return () => clearTimeout(timer);
|
|
57
|
+
}, [content, dirty, saving]);
|
|
58
|
+
|
|
59
|
+
const handleChange = (val: string) => {
|
|
60
|
+
setContent(val);
|
|
61
|
+
setDirty(true);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleDelete = async () => {
|
|
65
|
+
if (!confirm('Deletar esta nota?')) return;
|
|
66
|
+
try {
|
|
67
|
+
await api.notes.delete(vaultPath, decodePath());
|
|
68
|
+
navigate('/notes');
|
|
69
|
+
} catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleCreateLink = () => {
|
|
75
|
+
const target = prompt('Nome da nota para link:');
|
|
76
|
+
if (!target) return;
|
|
77
|
+
const linkText = `[[${target}]]`;
|
|
78
|
+
setContent(prev => prev + '\n' + linkText);
|
|
79
|
+
setDirty(true);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (!vaultPath || !notePath) {
|
|
83
|
+
return <div className="content-area"><div className="empty-state"><h2>Selecione uma nota</h2></div></div>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!note) {
|
|
87
|
+
return <div className="content-area"><div className="empty-state"><h2>Nota não encontrada</h2></div></div>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
92
|
+
<div className="toolbar">
|
|
93
|
+
<button onClick={() => navigate('/notes')}>← Voltar</button>
|
|
94
|
+
<span style={{ fontWeight: 500 }}>{note.filename}.md</span>
|
|
95
|
+
<span style={{ flex: 1 }} />
|
|
96
|
+
<button onClick={handleCreateLink}>🔗 Link</button>
|
|
97
|
+
<button onClick={handleDelete} style={{ color: 'var(--danger)' }}>🗑️</button>
|
|
98
|
+
{dirty && <span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Salvando...</span>}
|
|
99
|
+
{!dirty && note && <span style={{ fontSize: 12, color: 'var(--success)' }}>Salvo</span>}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="editor-layout">
|
|
103
|
+
<div className="editor-pane">
|
|
104
|
+
<textarea
|
|
105
|
+
value={content}
|
|
106
|
+
onChange={e => handleChange(e.target.value)}
|
|
107
|
+
spellCheck={false}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="preview-pane" dangerouslySetInnerHTML={{ __html: html }} />
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { api, Note } from '../api/client';
|
|
4
|
+
import SearchBar from './SearchBar';
|
|
5
|
+
|
|
6
|
+
interface NoteListProps {
|
|
7
|
+
vaultPath: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function NoteList({ vaultPath }: NoteListProps) {
|
|
11
|
+
const [notes, setNotes] = useState<Note[]>([]);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
14
|
+
const [newFilename, setNewFilename] = useState('');
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
|
|
17
|
+
const load = async () => {
|
|
18
|
+
if (!vaultPath) return;
|
|
19
|
+
try {
|
|
20
|
+
const res = await api.notes.list(vaultPath);
|
|
21
|
+
setNotes(res.notes);
|
|
22
|
+
} catch {
|
|
23
|
+
setNotes([]);
|
|
24
|
+
} finally {
|
|
25
|
+
setLoading(false);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
useEffect(() => { setLoading(true); load(); }, [vaultPath]);
|
|
30
|
+
|
|
31
|
+
const handleCreate = async () => {
|
|
32
|
+
if (!newFilename) return;
|
|
33
|
+
try {
|
|
34
|
+
await api.notes.create(vaultPath, newFilename, `# ${newFilename}\n\n`);
|
|
35
|
+
setShowCreate(false);
|
|
36
|
+
setNewFilename('');
|
|
37
|
+
await load();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('Failed to create note:', err);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!vaultPath) {
|
|
44
|
+
return <div className="content-area"><div className="empty-state"><h2>Selecione um vault</h2><p>Escolha um vault na página inicial</p></div></div>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (loading) return <div className="content-area"><p>Carregando...</p></div>;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="content-area">
|
|
51
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20, position: 'relative' }}>
|
|
52
|
+
<h2>Notas</h2>
|
|
53
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
54
|
+
<SearchBar vaultPath={vaultPath} onSelect={(path) => navigate(`/notes/${encodeURIComponent(path)}`)} />
|
|
55
|
+
<button className="primary" onClick={() => setShowCreate(true)}>+ Nova</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{notes.length === 0 ? (
|
|
60
|
+
<div className="empty-state">
|
|
61
|
+
<h2>Nenhuma nota</h2>
|
|
62
|
+
<p>Crie sua primeira nota</p>
|
|
63
|
+
</div>
|
|
64
|
+
) : (
|
|
65
|
+
<div className="note-list">
|
|
66
|
+
{notes.map(note => (
|
|
67
|
+
<div key={note.path} className="note-item" onClick={() => navigate(`/notes/${encodeURIComponent(note.path)}`)}>
|
|
68
|
+
<span className="note-title">{note.filename}</span>
|
|
69
|
+
<span className="note-date">{new Date(note.modified).toLocaleDateString()}</span>
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
{showCreate && (
|
|
76
|
+
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
|
77
|
+
<div className="modal" onClick={e => e.stopPropagation()}>
|
|
78
|
+
<h2>Nova Nota</h2>
|
|
79
|
+
<input
|
|
80
|
+
placeholder="Nome da nota"
|
|
81
|
+
value={newFilename}
|
|
82
|
+
onChange={e => setNewFilename(e.target.value)}
|
|
83
|
+
style={{ width: '100%' }}
|
|
84
|
+
autoFocus
|
|
85
|
+
/>
|
|
86
|
+
<div className="modal-actions">
|
|
87
|
+
<button onClick={() => setShowCreate(false)}>Cancelar</button>
|
|
88
|
+
<button className="primary" onClick={handleCreate}>Criar</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useNavigate, Link } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '../hooks/AuthContext';
|
|
4
|
+
|
|
5
|
+
export default function RegisterPage() {
|
|
6
|
+
const [name, setName] = useState('');
|
|
7
|
+
const [email, setEmail] = useState('');
|
|
8
|
+
const [password, setPassword] = useState('');
|
|
9
|
+
const [error, setError] = useState('');
|
|
10
|
+
const [busy, setBusy] = useState(false);
|
|
11
|
+
const { register } = useAuth();
|
|
12
|
+
const navigate = useNavigate();
|
|
13
|
+
|
|
14
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
setError('');
|
|
17
|
+
if (password.length < 6) {
|
|
18
|
+
setError('Senha deve ter no mínimo 6 caracteres');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
setBusy(true);
|
|
22
|
+
try {
|
|
23
|
+
await register(email, password, name);
|
|
24
|
+
navigate('/');
|
|
25
|
+
} catch (err: unknown) {
|
|
26
|
+
setError(err instanceof Error ? err.message : 'Erro ao cadastrar');
|
|
27
|
+
} finally {
|
|
28
|
+
setBusy(false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="auth-page">
|
|
34
|
+
<div className="auth-card">
|
|
35
|
+
<div className="auth-logo">
|
|
36
|
+
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
|
37
|
+
<rect width="32" height="32" rx="6" fill="#6C31E0"/>
|
|
38
|
+
<path d="M8 10h16M8 16h16M8 22h10" stroke="white" strokeWidth="2.5" strokeLinecap="round"/>
|
|
39
|
+
</svg>
|
|
40
|
+
<h1>OpenSidian</h1>
|
|
41
|
+
</div>
|
|
42
|
+
<h2>Criar conta</h2>
|
|
43
|
+
<form onSubmit={handleSubmit}>
|
|
44
|
+
<input placeholder="Nome" value={name} onChange={e => setName(e.target.value)} required />
|
|
45
|
+
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required />
|
|
46
|
+
<input type="password" placeholder="Senha (mín. 6 caracteres)" value={password} onChange={e => setPassword(e.target.value)} required />
|
|
47
|
+
{error && <p className="auth-error">{error}</p>}
|
|
48
|
+
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
|
|
49
|
+
{busy ? 'Cadastrando...' : 'Cadastrar'}
|
|
50
|
+
</button>
|
|
51
|
+
</form>
|
|
52
|
+
<p className="auth-link">
|
|
53
|
+
Já tem conta? <Link to="/login">Entre</Link>
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { api, SearchResultItem } from '../api/client';
|
|
4
|
+
|
|
5
|
+
interface SearchBarProps {
|
|
6
|
+
vaultPath: string;
|
|
7
|
+
onSelect: (path: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function SearchBar({ vaultPath, onSelect }: SearchBarProps) {
|
|
11
|
+
const [query, setQuery] = useState('');
|
|
12
|
+
const [results, setResults] = useState<SearchResultItem[]>([]);
|
|
13
|
+
const [open, setOpen] = useState(false);
|
|
14
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!vaultPath || query.length < 2) {
|
|
19
|
+
setResults([]);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const timer = setTimeout(async () => {
|
|
23
|
+
try {
|
|
24
|
+
const res = await api.search.fullText(vaultPath, query);
|
|
25
|
+
setResults(res.results.slice(0, 8));
|
|
26
|
+
setOpen(true);
|
|
27
|
+
} catch {
|
|
28
|
+
setResults([]);
|
|
29
|
+
}
|
|
30
|
+
}, 300);
|
|
31
|
+
return () => clearTimeout(timer);
|
|
32
|
+
}, [query, vaultPath]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const handler = (e: MouseEvent) => {
|
|
36
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
37
|
+
setOpen(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
document.addEventListener('mousedown', handler);
|
|
41
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
const select = (path: string) => {
|
|
45
|
+
setOpen(false);
|
|
46
|
+
setQuery('');
|
|
47
|
+
onSelect(path);
|
|
48
|
+
navigate(`/notes/${encodeURIComponent(path)}`);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div ref={ref} style={{ position: 'relative' }}>
|
|
53
|
+
<input
|
|
54
|
+
placeholder="Buscar notas..."
|
|
55
|
+
value={query}
|
|
56
|
+
onChange={e => setQuery(e.target.value)}
|
|
57
|
+
style={{ width: 220 }}
|
|
58
|
+
/>
|
|
59
|
+
{open && results.length > 0 && (
|
|
60
|
+
<div className="search-results">
|
|
61
|
+
{results.map(item => (
|
|
62
|
+
<div key={item.path} className="search-result-item" onClick={() => select(item.path)}>
|
|
63
|
+
<div className="result-title">{item.filename}</div>
|
|
64
|
+
<div className="result-excerpt" dangerouslySetInnerHTML={{ __html: item.snippet }} />
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { api, SearchResultItem } from '../api/client';
|
|
4
|
+
|
|
5
|
+
interface Filters {
|
|
6
|
+
tag: string;
|
|
7
|
+
after: string;
|
|
8
|
+
before: string;
|
|
9
|
+
hasBacklinks: '' | 'true' | 'false';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const emptyFilters: Filters = { tag: '', after: '', before: '', hasBacklinks: '' };
|
|
13
|
+
|
|
14
|
+
export default function SearchPanel() {
|
|
15
|
+
const [query, setQuery] = useState('');
|
|
16
|
+
const [filters, setFilters] = useState<Filters>(emptyFilters);
|
|
17
|
+
const [results, setResults] = useState<SearchResultItem[]>([]);
|
|
18
|
+
const [searched, setSearched] = useState(false);
|
|
19
|
+
const [loading, setLoading] = useState(false);
|
|
20
|
+
const navigate = useNavigate();
|
|
21
|
+
|
|
22
|
+
const vaultPath = new URLSearchParams(window.location.search).get('vault') || '';
|
|
23
|
+
|
|
24
|
+
const doSearch = useCallback(async () => {
|
|
25
|
+
if (!query || !vaultPath) return;
|
|
26
|
+
setLoading(true);
|
|
27
|
+
setSearched(true);
|
|
28
|
+
try {
|
|
29
|
+
const res = await api.search.fullText(vaultPath, query, {
|
|
30
|
+
tag: filters.tag || undefined,
|
|
31
|
+
after: filters.after || undefined,
|
|
32
|
+
before: filters.before || undefined,
|
|
33
|
+
hasBacklinks: filters.hasBacklinks ? filters.hasBacklinks === 'true' : undefined,
|
|
34
|
+
});
|
|
35
|
+
setResults(res.results);
|
|
36
|
+
} catch {
|
|
37
|
+
setResults([]);
|
|
38
|
+
} finally {
|
|
39
|
+
setLoading(false);
|
|
40
|
+
}
|
|
41
|
+
}, [query, filters, vaultPath]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
if (query.length >= 2) doSearch();
|
|
46
|
+
}, 400);
|
|
47
|
+
return () => clearTimeout(timer);
|
|
48
|
+
}, [query, filters]);
|
|
49
|
+
|
|
50
|
+
const selectNote = (path: string) => {
|
|
51
|
+
navigate(`/notes/${encodeURIComponent(path)}`);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const updateFilter = (key: keyof Filters, value: string) => {
|
|
55
|
+
setFilters(prev => ({ ...prev, [key]: value }));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const clearFilters = () => {
|
|
59
|
+
setFilters(emptyFilters);
|
|
60
|
+
setQuery('');
|
|
61
|
+
setResults([]);
|
|
62
|
+
setSearched(false);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="content-area">
|
|
67
|
+
<div style={{ maxWidth: 800 }}>
|
|
68
|
+
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 20 }}>
|
|
69
|
+
<div style={{ flex: 1 }}>
|
|
70
|
+
<input
|
|
71
|
+
placeholder="Pesquisar no conteúdo das notas..."
|
|
72
|
+
value={query}
|
|
73
|
+
onChange={e => setQuery(e.target.value)}
|
|
74
|
+
style={{ width: '100%', fontSize: 16, padding: '10px 14px' }}
|
|
75
|
+
autoFocus
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
<button onClick={clearFilters}>Limpar</button>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<details open style={{ marginBottom: 20 }}>
|
|
82
|
+
<summary style={{ cursor: 'pointer', fontSize: 14, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
|
83
|
+
Filtros
|
|
84
|
+
</summary>
|
|
85
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
86
|
+
<FilterField label="Tag" value={filters.tag} onChange={v => updateFilter('tag', v)} placeholder="ex: javascript" />
|
|
87
|
+
<div />
|
|
88
|
+
<FilterField label="Após data" value={filters.after} onChange={v => updateFilter('after', v)} type="date" />
|
|
89
|
+
<FilterField label="Antes de" value={filters.before} onChange={v => updateFilter('before', v)} type="date" />
|
|
90
|
+
</div>
|
|
91
|
+
<div style={{ marginTop: 12 }}>
|
|
92
|
+
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Backlinks</label>
|
|
93
|
+
<select
|
|
94
|
+
value={filters.hasBacklinks}
|
|
95
|
+
onChange={e => updateFilter('hasBacklinks', e.target.value)}
|
|
96
|
+
style={{ padding: '6px 10px', borderRadius: 'var(--radius)', border: '1px solid var(--border)', background: 'var(--bg)', color: 'var(--text)' }}
|
|
97
|
+
>
|
|
98
|
+
<option value="">Qualquer</option>
|
|
99
|
+
<option value="true">Com backlinks</option>
|
|
100
|
+
<option value="false">Sem backlinks</option>
|
|
101
|
+
</select>
|
|
102
|
+
</div>
|
|
103
|
+
</details>
|
|
104
|
+
|
|
105
|
+
{loading && <p>Buscando...</p>}
|
|
106
|
+
|
|
107
|
+
{searched && !loading && results.length === 0 && (
|
|
108
|
+
<div className="empty-state">
|
|
109
|
+
<h2>Nenhum resultado</h2>
|
|
110
|
+
<p>Tente termos diferentes ou ajuste os filtros</p>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<div className="search-results-list">
|
|
115
|
+
{results.map(item => (
|
|
116
|
+
<div key={item.path} className="search-result-full" onClick={() => selectNote(item.path)}>
|
|
117
|
+
<div className="result-title">{item.filename}</div>
|
|
118
|
+
<div className="result-excerpt" dangerouslySetInnerHTML={{ __html: item.snippet }} />
|
|
119
|
+
<div style={{ display: 'flex', gap: 12, fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>
|
|
120
|
+
<span>Relevância: {item.score.toFixed(2)}</span>
|
|
121
|
+
{item.tags.length > 0 && <span>Tags: {item.tags.join(', ')}</span>}
|
|
122
|
+
<span>{item.backlinks} backlinks</span>
|
|
123
|
+
<span>{new Date(item.modified).toLocaleDateString()}</span>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function FilterField({ label, value, onChange, placeholder, type }: {
|
|
134
|
+
label: string;
|
|
135
|
+
value: string;
|
|
136
|
+
onChange: (v: string) => void;
|
|
137
|
+
placeholder?: string;
|
|
138
|
+
type?: string;
|
|
139
|
+
}) {
|
|
140
|
+
return (
|
|
141
|
+
<div>
|
|
142
|
+
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>{label}</label>
|
|
143
|
+
<input
|
|
144
|
+
type={type || 'text'}
|
|
145
|
+
value={value}
|
|
146
|
+
onChange={e => onChange(e.target.value)}
|
|
147
|
+
placeholder={placeholder}
|
|
148
|
+
style={{ width: '100%' }}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useTheme } from '../hooks/ThemeContext';
|
|
3
|
+
import { BUILT_IN_THEMES, THEME_VARIABLE_LABELS, DEFAULT_VARIABLES, CustomTheme } from '../api/themes';
|
|
4
|
+
|
|
5
|
+
export default function ThemeEditor() {
|
|
6
|
+
const { customThemes, saveCustomTheme, deleteCustomTheme, selectTheme, themeName } = useTheme();
|
|
7
|
+
const allThemes = [...BUILT_IN_THEMES, ...customThemes];
|
|
8
|
+
|
|
9
|
+
const [editing, setEditing] = useState<CustomTheme | null>(null);
|
|
10
|
+
const [newName, setNewName] = useState('');
|
|
11
|
+
const [variables, setVariables] = useState<Record<string, string>>({});
|
|
12
|
+
|
|
13
|
+
const startEditing = (theme: CustomTheme) => {
|
|
14
|
+
setEditing(theme);
|
|
15
|
+
setNewName(theme.name);
|
|
16
|
+
setVariables({ ...theme.variables });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const startNew = () => {
|
|
20
|
+
setEditing({ name: '', variables: { ...DEFAULT_VARIABLES } });
|
|
21
|
+
setNewName('');
|
|
22
|
+
setVariables({ ...DEFAULT_VARIABLES });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const handleSave = async () => {
|
|
26
|
+
if (!editing || !newName.trim()) return;
|
|
27
|
+
await saveCustomTheme({ name: newName.trim(), variables });
|
|
28
|
+
setEditing(null);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const handleDelete = async (name: string) => {
|
|
32
|
+
if (!confirm(`Deletar tema "${name}"?`)) return;
|
|
33
|
+
await deleteCustomTheme(name);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const updateVariable = (key: string, value: string) => {
|
|
37
|
+
const root = document.documentElement;
|
|
38
|
+
root.style.setProperty(key, value);
|
|
39
|
+
setVariables(prev => ({ ...prev, [key]: value }));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (editing) {
|
|
44
|
+
for (const [key, val] of Object.entries(variables)) {
|
|
45
|
+
document.documentElement.style.setProperty(key, val as string);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
const current = allThemes.find(t => t.name === themeName);
|
|
49
|
+
if (current) {
|
|
50
|
+
for (const [key, val] of Object.entries(current.variables)) {
|
|
51
|
+
document.documentElement.style.setProperty(key, val as string);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}, [editing]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="content-area">
|
|
59
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
|
60
|
+
<h2>Temas</h2>
|
|
61
|
+
<button className="primary" onClick={startNew}>+ Novo Tema</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 32 }}>
|
|
65
|
+
{allThemes.map(theme => (
|
|
66
|
+
<div
|
|
67
|
+
key={theme.name}
|
|
68
|
+
className={`theme-card ${themeName === theme.name ? 'theme-card--active' : ''}`}
|
|
69
|
+
onClick={() => selectTheme(theme.name)}
|
|
70
|
+
>
|
|
71
|
+
<div className="theme-card-preview">
|
|
72
|
+
<div style={{ background: theme.variables['--accent'], width: 8, height: 8, borderRadius: '50%' }} />
|
|
73
|
+
<div style={{ background: theme.variables['--bg'], width: 24, height: 24, borderRadius: 4, border: `1px solid ${theme.variables['--border']}` }} />
|
|
74
|
+
<div style={{ background: theme.variables['--bg-secondary'], width: 24, height: 24, borderRadius: 4, border: `1px solid ${theme.variables['--border']}` }} />
|
|
75
|
+
<div style={{ background: theme.variables['--bg-tertiary'], width: 24, height: 24, borderRadius: 4 }} />
|
|
76
|
+
</div>
|
|
77
|
+
<div className="theme-card-name">{theme.name}</div>
|
|
78
|
+
<div style={{ display: 'flex', gap: 4 }}>
|
|
79
|
+
<button onClick={e => { e.stopPropagation(); startEditing(theme); }} style={{ fontSize: 12, padding: '2px 8px' }}>
|
|
80
|
+
Editar
|
|
81
|
+
</button>
|
|
82
|
+
{!BUILT_IN_THEMES.find(t => t.name === theme.name) && (
|
|
83
|
+
<button onClick={e => { e.stopPropagation(); handleDelete(theme.name); }} style={{ fontSize: 12, padding: '2px 8px', color: 'var(--danger)' }}>
|
|
84
|
+
Deletar
|
|
85
|
+
</button>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{editing && (
|
|
93
|
+
<div className="theme-editor">
|
|
94
|
+
<h3>{editing.name ? 'Editar Tema' : 'Novo Tema'}</h3>
|
|
95
|
+
<input
|
|
96
|
+
placeholder="Nome do tema"
|
|
97
|
+
value={newName}
|
|
98
|
+
onChange={e => setNewName(e.target.value)}
|
|
99
|
+
style={{ width: '100%', marginBottom: 16 }}
|
|
100
|
+
/>
|
|
101
|
+
<div className="theme-editor-grid">
|
|
102
|
+
{Object.entries(THEME_VARIABLE_LABELS).map(([key, label]) => (
|
|
103
|
+
<div key={key} className="theme-editor-field">
|
|
104
|
+
<label>{label}</label>
|
|
105
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
106
|
+
<input
|
|
107
|
+
type="color"
|
|
108
|
+
value={variables[key] || DEFAULT_VARIABLES[key]}
|
|
109
|
+
onChange={e => updateVariable(key, e.target.value)}
|
|
110
|
+
style={{ width: 40, height: 36, padding: 0, border: 'none', cursor: 'pointer' }}
|
|
111
|
+
/>
|
|
112
|
+
<input
|
|
113
|
+
value={variables[key] || ''}
|
|
114
|
+
onChange={e => updateVariable(key, e.target.value)}
|
|
115
|
+
style={{ flex: 1, fontFamily: 'monospace', fontSize: 13 }}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
|
122
|
+
<button className="primary" onClick={handleSave}>Salvar</button>
|
|
123
|
+
<button onClick={() => setEditing(null)}>Cancelar</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useNavigate } from 'react-router-dom';
|
|
2
|
+
import { useTheme } from '../hooks/ThemeContext';
|
|
3
|
+
import { BUILT_IN_THEMES } from '../api/themes';
|
|
4
|
+
|
|
5
|
+
export default function ThemeSelector() {
|
|
6
|
+
const { themeName, selectTheme, customThemes } = useTheme();
|
|
7
|
+
const allThemes = [...BUILT_IN_THEMES, ...customThemes];
|
|
8
|
+
const navigate = useNavigate();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div style={{ padding: '8px', borderTop: '1px solid var(--border)' }}>
|
|
12
|
+
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 4, padding: '0 8px' }}>
|
|
13
|
+
TEMA
|
|
14
|
+
</div>
|
|
15
|
+
{allThemes.map(theme => (
|
|
16
|
+
<div
|
|
17
|
+
key={theme.name}
|
|
18
|
+
onClick={() => selectTheme(theme.name)}
|
|
19
|
+
className={`nav-item ${themeName === theme.name ? 'active' : ''}`}
|
|
20
|
+
style={{ fontSize: 13, padding: '6px 8px', display: 'flex', alignItems: 'center', gap: 8 }}
|
|
21
|
+
>
|
|
22
|
+
<span style={{
|
|
23
|
+
display: 'inline-block',
|
|
24
|
+
width: 10,
|
|
25
|
+
height: 10,
|
|
26
|
+
borderRadius: '50%',
|
|
27
|
+
background: theme.variables['--accent'],
|
|
28
|
+
}} />
|
|
29
|
+
{theme.name}
|
|
30
|
+
</div>
|
|
31
|
+
))}
|
|
32
|
+
<div
|
|
33
|
+
onClick={() => navigate('/themes')}
|
|
34
|
+
className="nav-item"
|
|
35
|
+
style={{ fontSize: 13, padding: '6px 8px' }}
|
|
36
|
+
>
|
|
37
|
+
✏️ Gerenciar temas
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|