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.
Files changed (137) hide show
  1. package/.eslintrc.json +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +35 -0
  4. package/.github/ISSUE_TEMPLATE/question.md +23 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +45 -0
  6. package/.github/README.md +5 -0
  7. package/.github/workflows/cd.yml +44 -0
  8. package/.github/workflows/ci.yml +128 -0
  9. package/.github/workflows/qa.yml +45 -0
  10. package/.planning/PROJECT.md +96 -0
  11. package/.planning/REQUIREMENTS.md +66 -0
  12. package/.planning/ROADMAP.md +129 -0
  13. package/.planning/STATE.md +47 -0
  14. package/.planning/config.json +14 -0
  15. package/CONTRIBUTING.md +232 -0
  16. package/LICENSE +21 -0
  17. package/README.md +244 -0
  18. package/dist/api/auth.d.ts +5 -0
  19. package/dist/api/auth.d.ts.map +1 -0
  20. package/dist/api/auth.js +112 -0
  21. package/dist/api/auth.js.map +1 -0
  22. package/dist/api/routes.d.ts +3 -0
  23. package/dist/api/routes.d.ts.map +1 -0
  24. package/dist/api/routes.js +119 -0
  25. package/dist/api/routes.js.map +1 -0
  26. package/dist/api/themes.d.ts +3 -0
  27. package/dist/api/themes.d.ts.map +1 -0
  28. package/dist/api/themes.js +48 -0
  29. package/dist/api/themes.js.map +1 -0
  30. package/dist/core/graph.d.ts +16 -0
  31. package/dist/core/graph.d.ts.map +1 -0
  32. package/dist/core/graph.js +115 -0
  33. package/dist/core/graph.js.map +1 -0
  34. package/dist/core/markdown.d.ts +21 -0
  35. package/dist/core/markdown.d.ts.map +1 -0
  36. package/dist/core/markdown.js +77 -0
  37. package/dist/core/markdown.js.map +1 -0
  38. package/dist/core/search.d.ts +34 -0
  39. package/dist/core/search.d.ts.map +1 -0
  40. package/dist/core/search.js +159 -0
  41. package/dist/core/search.js.map +1 -0
  42. package/dist/core/sync.d.ts +30 -0
  43. package/dist/core/sync.d.ts.map +1 -0
  44. package/dist/core/sync.js +121 -0
  45. package/dist/core/sync.js.map +1 -0
  46. package/dist/core/vault.d.ts +28 -0
  47. package/dist/core/vault.d.ts.map +1 -0
  48. package/dist/core/vault.js +235 -0
  49. package/dist/core/vault.js.map +1 -0
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +32 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/mcp/cli.d.ts +3 -0
  55. package/dist/mcp/cli.d.ts.map +1 -0
  56. package/dist/mcp/cli.js +7 -0
  57. package/dist/mcp/cli.js.map +1 -0
  58. package/dist/mcp/server.d.ts +18 -0
  59. package/dist/mcp/server.d.ts.map +1 -0
  60. package/dist/mcp/server.js +272 -0
  61. package/dist/mcp/server.js.map +1 -0
  62. package/dist/plugins/host.d.ts +23 -0
  63. package/dist/plugins/host.d.ts.map +1 -0
  64. package/dist/plugins/host.js +104 -0
  65. package/dist/plugins/host.js.map +1 -0
  66. package/dist/plugins/sample-plugin.d.ts +10 -0
  67. package/dist/plugins/sample-plugin.d.ts.map +1 -0
  68. package/dist/plugins/sample-plugin.js +23 -0
  69. package/dist/plugins/sample-plugin.js.map +1 -0
  70. package/dist/server.d.ts +15 -0
  71. package/dist/server.d.ts.map +1 -0
  72. package/dist/server.js +77 -0
  73. package/dist/server.js.map +1 -0
  74. package/dist/shared/types.d.ts +86 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/shared/types.js +2 -0
  77. package/dist/shared/types.js.map +1 -0
  78. package/docker/Dockerfile +37 -0
  79. package/docker/docker-compose.yml +46 -0
  80. package/docs/ARCHITECTURE.md +321 -0
  81. package/futuras_implementacoes.md +0 -0
  82. package/package.json +65 -0
  83. package/scripts/fix-gitignore.ps1 +5 -0
  84. package/scripts/seed-notes.mjs +60 -0
  85. package/src/api/auth.ts +130 -0
  86. package/src/api/routes.ts +133 -0
  87. package/src/api/themes.ts +60 -0
  88. package/src/core/graph.ts +145 -0
  89. package/src/core/markdown.ts +92 -0
  90. package/src/core/search.ts +208 -0
  91. package/src/core/sync.ts +157 -0
  92. package/src/core/vault.ts +286 -0
  93. package/src/index.ts +37 -0
  94. package/src/mcp/cli.ts +7 -0
  95. package/src/mcp/server.ts +296 -0
  96. package/src/plugins/host.ts +120 -0
  97. package/src/plugins/sample-plugin.ts +29 -0
  98. package/src/server.ts +90 -0
  99. package/src/shared/types.ts +92 -0
  100. package/tests/api/routes.test.ts +167 -0
  101. package/tests/core/graph.test.ts +236 -0
  102. package/tests/core/markdown.test.ts +157 -0
  103. package/tests/core/search.test.ts +132 -0
  104. package/tests/core/sync.test.ts +62 -0
  105. package/tests/core/vault.test.ts +162 -0
  106. package/tests/mcp/server.test.ts +118 -0
  107. package/tests/plugins/host.test.ts +165 -0
  108. package/tests/plugins/sample-plugin.test.ts +35 -0
  109. package/tests/server.test.ts +76 -0
  110. package/tsconfig.json +27 -0
  111. package/vite.config.ts +27 -0
  112. package/vitest.config.ts +33 -0
  113. package/web/index.html +13 -0
  114. package/web/package.json +26 -0
  115. package/web/public/favicon.svg +4 -0
  116. package/web/src/App.tsx +63 -0
  117. package/web/src/api/auth.ts +65 -0
  118. package/web/src/api/client.ts +117 -0
  119. package/web/src/api/themes.ts +78 -0
  120. package/web/src/components/GraphView.tsx +139 -0
  121. package/web/src/components/Layout.tsx +74 -0
  122. package/web/src/components/LoginPage.tsx +52 -0
  123. package/web/src/components/NoteEditor.tsx +114 -0
  124. package/web/src/components/NoteList.tsx +95 -0
  125. package/web/src/components/RegisterPage.tsx +58 -0
  126. package/web/src/components/SearchBar.tsx +71 -0
  127. package/web/src/components/SearchPanel.tsx +152 -0
  128. package/web/src/components/ThemeEditor.tsx +129 -0
  129. package/web/src/components/ThemeSelector.tsx +41 -0
  130. package/web/src/components/VaultList.tsx +89 -0
  131. package/web/src/hooks/AuthContext.tsx +57 -0
  132. package/web/src/hooks/ThemeContext.tsx +77 -0
  133. package/web/src/hooks/useWebSocket.ts +34 -0
  134. package/web/src/main.tsx +10 -0
  135. package/web/src/styles/global.css +449 -0
  136. package/web/tsconfig.json +21 -0
  137. 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
+ }