Merge pull request #21 from stevedylandev/feat/jotts-tui 2035f712
feat/jotts tui
Steve Simkins · 2026-04-13 22:50 20 file(s) · +2390 −88
.github/workflows/docker-test.yml (added) +68 −0
1 +
name: Docker Test
2 +
3 +
on:
4 +
  pull_request:
5 +
    branches:
6 +
      - main
7 +
8 +
jobs:
9 +
  changes:
10 +
    name: Detect changes
11 +
    runs-on: ubuntu-latest
12 +
    outputs:
13 +
      apps: ${{ steps.filter.outputs.apps }}
14 +
    steps:
15 +
      - uses: actions/checkout@v4
16 +
        with:
17 +
          fetch-depth: 0
18 +
19 +
      - name: Determine which apps to build
20 +
        id: filter
21 +
        run: |
22 +
          ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts"]'
23 +
24 +
          changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
25 +
26 +
          if echo "$changed" | grep -qE '^(Cargo\.(toml|lock)|crates/|\.github/workflows/docker(-test)?\.yml)'; then
27 +
            echo "apps=${ALL}" >> "$GITHUB_OUTPUT"
28 +
            exit 0
29 +
          fi
30 +
31 +
          apps=()
32 +
          for app in cellar sipp feeds parcels jotts og shrink backup posts; do
33 +
            if echo "$changed" | grep -q "^apps/${app}/"; then
34 +
              apps+=("\"${app}\"")
35 +
            fi
36 +
          done
37 +
38 +
          if [ ${#apps[@]} -eq 0 ]; then
39 +
            echo 'apps=[]' >> "$GITHUB_OUTPUT"
40 +
          else
41 +
            echo "apps=[$(IFS=,; echo "${apps[*]}")]" >> "$GITHUB_OUTPUT"
42 +
          fi
43 +
44 +
  build:
45 +
    name: build (${{ matrix.app }})
46 +
    needs: changes
47 +
    if: needs.changes.outputs.apps != '[]'
48 +
    runs-on: ubuntu-latest
49 +
    strategy:
50 +
      fail-fast: false
51 +
      matrix:
52 +
        app: ${{ fromJson(needs.changes.outputs.apps) }}
53 +
    steps:
54 +
      - uses: actions/checkout@v4
55 +
56 +
      - name: Set up Docker Buildx
57 +
        uses: docker/setup-buildx-action@v3
58 +
59 +
      - name: Build
60 +
        uses: docker/build-push-action@v6
61 +
        with:
62 +
          context: .
63 +
          file: apps/${{ matrix.app }}/Dockerfile
64 +
          push: false
65 +
          load: true
66 +
          tags: ${{ matrix.app }}:test
67 +
          cache-from: type=gha,scope=${{ matrix.app }}
68 +
          cache-to: type=gha,mode=max,scope=${{ matrix.app }}
Cargo.lock +9 −0
2194 2194
version = "0.1.2"
2195 2195
dependencies = [
2196 2196
 "andromeda-auth",
2197 +
 "arboard",
2197 2198
 "askama 0.15.6",
2198 2199
 "askama_web",
2199 2200
 "axum",
2201 +
 "clap",
2202 +
 "crossterm",
2200 2203
 "dotenvy",
2201 2204
 "nanoid",
2205 +
 "open",
2202 2206
 "pulldown-cmark",
2203 2207
 "rand 0.8.5",
2208 +
 "ratatui",
2209 +
 "reqwest 0.13.2",
2210 +
 "rpassword",
2204 2211
 "rusqlite",
2205 2212
 "rust-embed",
2206 2213
 "serde",
2207 2214
 "serde_json",
2208 2215
 "subtle",
2216 +
 "syntect",
2209 2217
 "tokio",
2218 +
 "toml",
2210 2219
 "tracing",
2211 2220
 "tracing-subscriber",
2212 2221
]
apps/jotts/.env.example +3 −0
3 3
COOKIE_SECURE=false
4 4
HOST=127.0.0.1
5 5
PORT=3000
6 +
# Optional. When set, enables the JSON API at /api/notes gated by x-api-key header.
7 +
# Leave unset to disable the API (returns 403).
8 +
JOTTS_API_KEY=
apps/jotts/Cargo.toml +13 −0
24 24
askama = "0.15"
25 25
askama_web = { version = "0.15", features = ["axum-0.8"] }
26 26
pulldown-cmark = "0.12"
27 +
ratatui = "0.30"
28 +
crossterm = "0.29"
29 +
arboard = "3"
30 +
syntect = "5"
31 +
reqwest = { version = "0.13", features = ["json", "blocking"] }
32 +
clap = { version = "4", features = ["derive", "env"] }
33 +
toml = "1.0"
34 +
rpassword = "7"
35 +
open = "5.3.3"
36 +
37 +
[[bin]]
38 +
name = "jotts"
39 +
path = "src/main.rs"
apps/jotts/Dockerfile +1 −3
35 35
COPY --from=builder /app/target/release/jotts /usr/local/bin/jotts
36 36
WORKDIR /data
37 37
EXPOSE 3000
38 -
ENV HOST=0.0.0.0
39 -
ENV PORT=3000
40 -
CMD ["jotts"]
38 +
CMD ["jotts", "server", "--port", "3000", "--host", "0.0.0.0"]
apps/jotts/README.md +16 −0
24 24
| `HOST` | Server bind address | `127.0.0.1` |
25 25
| `PORT` | Server port | `3000` |
26 26
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
27 +
| `JOTTS_API_KEY` | API key for `/api/notes` JSON endpoints (unset = API disabled) | _(unset)_ |
27 28
28 29
## Overview
29 30
56 57
├── Dockerfile         # Multi-stage build (Rust + Debian slim)
57 58
└── docker-compose.yml
58 59
```
60 +
61 +
## CLI / TUI
62 +
63 +
The `jotts` binary also ships an interactive terminal UI and a minimal CLI.
64 +
65 +
```bash
66 +
jotts               # Launch TUI (local SQLite by default, remote if configured)
67 +
jotts server        # Run the web server
68 +
jotts tui           # Launch TUI explicitly
69 +
jotts auth          # Prompt for remote URL + API key, save to ~/.config/jotts/config.toml
70 +
```
71 +
72 +
Flags: `--remote <URL>` (env `JOTTS_REMOTE_URL`), `--api-key <KEY>` (env `JOTTS_API_KEY`).
73 +
74 +
Against a remote server, set `JOTTS_API_KEY` on the server and pass the same key from the CLI. Requests go to `/api/notes` with the `x-api-key` header. The TUI renders note content with syntect Markdown syntax highlighting.
59 75
60 76
## Deployment
61 77
apps/jotts/docker-compose.yml +1 −3
4 4
      context: ../..
5 5
      dockerfile: apps/jotts/Dockerfile
6 6
    ports:
7 -
      - "${PORT:-3000}:${PORT:-3000}"
7 +
      - "3000:3000"
8 8
    environment:
9 9
      - JOTTS_PASSWORD=${JOTTS_PASSWORD:-changeme}
10 10
      - JOTTS_DB_PATH=/data/jotts.sqlite
11 11
      - COOKIE_SECURE=false
12 -
      - HOST=0.0.0.0
13 -
      - PORT=${PORT:-3000}
14 12
    volumes:
15 13
      - jotts-data:/data
16 14
    restart: unless-stopped
apps/jotts/src/ansi.tmTheme (added) +430 −0
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +
<plist version="1.0">
4 +
    <dict>
5 +
        <!--
6 +
        The colors in this theme are encoded as #RRGGBBAA where:
7 +
        * If AA is 00, then RR is an ANSI palette number from 00 to 07.
8 +
        * If AA is 01, the terminal's default fg/bg color is used.
9 +
        -->
10 +
        <key>author</key>
11 +
        <string>Template: Chris Kempson, Scheme: Mitchell Kember</string>
12 +
        <key>name</key>
13 +
        <string>ANSI</string>
14 +
        <key>colorSpaceName</key>
15 +
        <string>sRGB</string>
16 +
        <key>settings</key>
17 +
        <array>
18 +
            <dict>
19 +
                <key>settings</key>
20 +
                <dict>
21 +
                    <key>background</key>
22 +
                    <string>#00000001</string>
23 +
                    <key>foreground</key>
24 +
                    <string>#00000001</string>
25 +
                    <!--
26 +
                    Explicitly set the gutter color since bat falls back to a
27 +
                    hardcoded DEFAULT_GUTTER_COLOR otherwise.
28 +
                    -->
29 +
                    <key>gutter</key>
30 +
                    <string>#00000001</string>
31 +
                    <key>gutterForeground</key>
32 +
                    <string>#00000001</string>
33 +
                </dict>
34 +
            </dict>
35 +
            <dict>
36 +
                <key>name</key>
37 +
                <string>Comments</string>
38 +
                <key>scope</key>
39 +
                <string>comment, punctuation.definition.comment</string>
40 +
                <key>settings</key>
41 +
                <dict>
42 +
                    <key>foreground</key>
43 +
                    <string>#02000000</string>
44 +
                </dict>
45 +
            </dict>
46 +
            <dict>
47 +
                <key>name</key>
48 +
                <string>Keywords</string>
49 +
                <key>scope</key>
50 +
                <string>keyword</string>
51 +
                <key>settings</key>
52 +
                <dict>
53 +
                    <key>foreground</key>
54 +
                    <string>#05000000</string>
55 +
                </dict>
56 +
            </dict>
57 +
            <dict>
58 +
                <key>name</key>
59 +
                <string>Functions</string>
60 +
                <key>scope</key>
61 +
                <string>entity.name.function, meta.require, support.function.any-method</string>
62 +
                <key>settings</key>
63 +
                <dict>
64 +
                    <key>foreground</key>
65 +
                    <string>#04000000</string>
66 +
                </dict>
67 +
            </dict>
68 +
            <dict>
69 +
                <key>name</key>
70 +
                <string>Labels</string>
71 +
                <key>scope</key>
72 +
                <string>entity.name.label, variable.parameter</string>
73 +
                <key>settings</key>
74 +
                <dict>
75 +
                    <key>foreground</key>
76 +
                    <string>#06000000</string>
77 +
                </dict>
78 +
            </dict>
79 +
            <dict>
80 +
                <key>name</key>
81 +
                <string>Classes</string>
82 +
                <key>scope</key>
83 +
                <string>support.class, entity.name.class, entity.name.type.class, entity.name</string>
84 +
                <key>settings</key>
85 +
                <dict>
86 +
                    <key>foreground</key>
87 +
                    <string>#03000000</string>
88 +
                </dict>
89 +
            </dict>
90 +
            <dict>
91 +
                <key>name</key>
92 +
                <string>Methods</string>
93 +
                <key>scope</key>
94 +
                <string>keyword.other.special-method</string>
95 +
                <key>settings</key>
96 +
                <dict>
97 +
                    <key>foreground</key>
98 +
                    <string>#04000000</string>
99 +
                </dict>
100 +
            </dict>
101 +
            <dict>
102 +
                <key>name</key>
103 +
                <string>Storage</string>
104 +
                <key>scope</key>
105 +
                <string>storage</string>
106 +
                <key>settings</key>
107 +
                <dict>
108 +
                    <key>foreground</key>
109 +
                    <string>#05000000</string>
110 +
                </dict>
111 +
            </dict>
112 +
            <dict>
113 +
                <key>name</key>
114 +
                <string>Support</string>
115 +
                <key>scope</key>
116 +
                <string>support.function</string>
117 +
                <key>settings</key>
118 +
                <dict>
119 +
                    <key>foreground</key>
120 +
                    <string>#06000000</string>
121 +
                </dict>
122 +
            </dict>
123 +
            <dict>
124 +
                <key>name</key>
125 +
                <string>Strings, Inherited Class</string>
126 +
                <key>scope</key>
127 +
                <string>string, constant.other.symbol, entity.other.inherited-class</string>
128 +
                <key>settings</key>
129 +
                <dict>
130 +
                    <key>foreground</key>
131 +
                    <string>#02000000</string>
132 +
                </dict>
133 +
            </dict>
134 +
            <dict>
135 +
                <key>name</key>
136 +
                <string>Integers</string>
137 +
                <key>scope</key>
138 +
                <string>constant.numeric</string>
139 +
                <key>settings</key>
140 +
                <dict>
141 +
                    <key>foreground</key>
142 +
                    <string>#03000000</string>
143 +
                </dict>
144 +
            </dict>
145 +
            <dict>
146 +
                <key>name</key>
147 +
                <string>Floats</string>
148 +
                <key>scope</key>
149 +
                <string>none</string>
150 +
                <key>settings</key>
151 +
                <dict>
152 +
                    <key>foreground</key>
153 +
                    <string>#03000000</string>
154 +
                </dict>
155 +
            </dict>
156 +
            <dict>
157 +
                <key>name</key>
158 +
                <string>Boolean</string>
159 +
                <key>scope</key>
160 +
                <string>none</string>
161 +
                <key>settings</key>
162 +
                <dict>
163 +
                    <key>foreground</key>
164 +
                    <string>#03000000</string>
165 +
                </dict>
166 +
            </dict>
167 +
            <dict>
168 +
                <key>name</key>
169 +
                <string>Constants</string>
170 +
                <key>scope</key>
171 +
                <string>constant</string>
172 +
                <key>settings</key>
173 +
                <dict>
174 +
                    <key>foreground</key>
175 +
                    <string>#03000000</string>
176 +
                </dict>
177 +
            </dict>
178 +
            <dict>
179 +
                <key>name</key>
180 +
                <string>Tags</string>
181 +
                <key>scope</key>
182 +
                <string>entity.name.tag</string>
183 +
                <key>settings</key>
184 +
                <dict>
185 +
                    <key>foreground</key>
186 +
                    <string>#01000000</string>
187 +
                </dict>
188 +
            </dict>
189 +
            <dict>
190 +
                <key>name</key>
191 +
                <string>Attributes</string>
192 +
                <key>scope</key>
193 +
                <string>entity.other.attribute-name</string>
194 +
                <key>settings</key>
195 +
                <dict>
196 +
                    <key>foreground</key>
197 +
                    <string>#03000000</string>
198 +
                </dict>
199 +
            </dict>
200 +
            <dict>
201 +
                <key>name</key>
202 +
                <string>Attribute IDs</string>
203 +
                <key>scope</key>
204 +
                <string>entity.other.attribute-name.id, punctuation.definition.entity</string>
205 +
                <key>settings</key>
206 +
                <dict>
207 +
                    <key>foreground</key>
208 +
                    <string>#04000000</string>
209 +
                </dict>
210 +
            </dict>
211 +
            <dict>
212 +
                <key>name</key>
213 +
                <string>Selector</string>
214 +
                <key>scope</key>
215 +
                <string>meta.selector</string>
216 +
                <key>settings</key>
217 +
                <dict>
218 +
                    <key>foreground</key>
219 +
                    <string>#05000000</string>
220 +
                </dict>
221 +
            </dict>
222 +
            <dict>
223 +
                <key>name</key>
224 +
                <string>Values</string>
225 +
                <key>scope</key>
226 +
                <string>none</string>
227 +
                <key>settings</key>
228 +
                <dict>
229 +
                    <key>foreground</key>
230 +
                    <string>#03000000</string>
231 +
                </dict>
232 +
            </dict>
233 +
            <dict>
234 +
                <key>name</key>
235 +
                <string>Headings</string>
236 +
                <key>scope</key>
237 +
                <string>markup.heading punctuation.definition.heading, entity.name.section, markup.heading - text.html.markdown, meta.mapping.key string.quoted.double</string>
238 +
                <key>settings</key>
239 +
                <dict>
240 +
                    <key>fontStyle</key>
241 +
                    <string></string>
242 +
                    <key>foreground</key>
243 +
                    <string>#04000000</string>
244 +
                </dict>
245 +
            </dict>
246 +
            <dict>
247 +
                <key>name</key>
248 +
                <string>Units</string>
249 +
                <key>scope</key>
250 +
                <string>keyword.other.unit</string>
251 +
                <key>settings</key>
252 +
                <dict>
253 +
                    <key>foreground</key>
254 +
                    <string>#03000000</string>
255 +
                </dict>
256 +
            </dict>
257 +
            <dict>
258 +
                <key>name</key>
259 +
                <string>Bold</string>
260 +
                <key>scope</key>
261 +
                <string>markup.bold, punctuation.definition.bold</string>
262 +
                <key>settings</key>
263 +
                <dict>
264 +
                    <key>fontStyle</key>
265 +
                    <string>bold</string>
266 +
                    <key>foreground</key>
267 +
                    <string>#03000000</string>
268 +
                </dict>
269 +
            </dict>
270 +
            <dict>
271 +
                <key>name</key>
272 +
                <string>Italic</string>
273 +
                <key>scope</key>
274 +
                <string>markup.italic, punctuation.definition.italic</string>
275 +
                <key>settings</key>
276 +
                <dict>
277 +
                    <key>fontStyle</key>
278 +
                    <string>italic</string>
279 +
                    <key>foreground</key>
280 +
                    <string>#05000000</string>
281 +
                </dict>
282 +
            </dict>
283 +
            <dict>
284 +
                <key>name</key>
285 +
                <string>Code</string>
286 +
                <key>scope</key>
287 +
                <string>markup.raw.inline</string>
288 +
                <key>settings</key>
289 +
                <dict>
290 +
                    <key>foreground</key>
291 +
                    <string>#02000000</string>
292 +
                </dict>
293 +
            </dict>
294 +
            <dict>
295 +
                <key>name</key>
296 +
                <string>Link Text</string>
297 +
                <key>scope</key>
298 +
                <string>string.other.link, punctuation.definition.string.end.markdown, punctuation.definition.string.begin.markdown</string>
299 +
                <key>settings</key>
300 +
                <dict>
301 +
                    <key>foreground</key>
302 +
                    <string>#01000000</string>
303 +
                </dict>
304 +
            </dict>
305 +
            <dict>
306 +
                <key>name</key>
307 +
                <string>Link Url</string>
308 +
                <key>scope</key>
309 +
                <string>meta.link</string>
310 +
                <key>settings</key>
311 +
                <dict>
312 +
                    <key>foreground</key>
313 +
                    <string>#03000000</string>
314 +
                </dict>
315 +
            </dict>
316 +
            <dict>
317 +
                <key>name</key>
318 +
                <string>Quotes</string>
319 +
                <key>scope</key>
320 +
                <string>markup.quote</string>
321 +
                <key>settings</key>
322 +
                <dict>
323 +
                    <key>foreground</key>
324 +
                    <string>#03000000</string>
325 +
                </dict>
326 +
            </dict>
327 +
            <dict>
328 +
                <key>name</key>
329 +
                <string>Inserted</string>
330 +
                <key>scope</key>
331 +
                <string>markup.inserted</string>
332 +
                <key>settings</key>
333 +
                <dict>
334 +
                    <key>foreground</key>
335 +
                    <string>#02000000</string>
336 +
                </dict>
337 +
            </dict>
338 +
            <dict>
339 +
                <key>name</key>
340 +
                <string>Deleted</string>
341 +
                <key>scope</key>
342 +
                <string>markup.deleted</string>
343 +
                <key>settings</key>
344 +
                <dict>
345 +
                    <key>foreground</key>
346 +
                    <string>#01000000</string>
347 +
                </dict>
348 +
            </dict>
349 +
            <dict>
350 +
                <key>name</key>
351 +
                <string>Changed</string>
352 +
                <key>scope</key>
353 +
                <string>markup.changed</string>
354 +
                <key>settings</key>
355 +
                <dict>
356 +
                    <key>foreground</key>
357 +
                    <string>#05000000</string>
358 +
                </dict>
359 +
            </dict>
360 +
            <dict>
361 +
                <key>name</key>
362 +
                <string>Colors</string>
363 +
                <key>scope</key>
364 +
                <string>constant.other.color</string>
365 +
                <key>settings</key>
366 +
                <dict>
367 +
                    <key>foreground</key>
368 +
                    <string>#06000000</string>
369 +
                </dict>
370 +
            </dict>
371 +
            <dict>
372 +
                <key>name</key>
373 +
                <string>Regular Expressions</string>
374 +
                <key>scope</key>
375 +
                <string>string.regexp</string>
376 +
                <key>settings</key>
377 +
                <dict>
378 +
                    <key>foreground</key>
379 +
                    <string>#06000000</string>
380 +
                </dict>
381 +
            </dict>
382 +
            <dict>
383 +
                <key>name</key>
384 +
                <string>Escape Characters</string>
385 +
                <key>scope</key>
386 +
                <string>constant.character.escape</string>
387 +
                <key>settings</key>
388 +
                <dict>
389 +
                    <key>foreground</key>
390 +
                    <string>#06000000</string>
391 +
                </dict>
392 +
            </dict>
393 +
            <dict>
394 +
                <key>name</key>
395 +
                <string>Embedded</string>
396 +
                <key>scope</key>
397 +
                <string>punctuation.section.embedded, variable.interpolation</string>
398 +
                <key>settings</key>
399 +
                <dict>
400 +
                    <key>foreground</key>
401 +
                    <string>#05000000</string>
402 +
                </dict>
403 +
            </dict>
404 +
            <dict>
405 +
                <key>name</key>
406 +
                <string>Illegal</string>
407 +
                <key>scope</key>
408 +
                <string>invalid.illegal</string>
409 +
                <key>settings</key>
410 +
                <dict>
411 +
                    <key>background</key>
412 +
                    <string>#01000000</string>
413 +
                </dict>
414 +
            </dict>
415 +
            <dict>
416 +
                <key>name</key>
417 +
                <string>Broken</string>
418 +
                <key>scope</key>
419 +
                <string>invalid.broken</string>
420 +
                <key>settings</key>
421 +
                <dict>
422 +
                    <key>background</key>
423 +
                    <string>#03000000</string>
424 +
                </dict>
425 +
            </dict>
426 +
        </array>
427 +
        <key>uuid</key>
428 +
        <string>uuid</string>
429 +
    </dict>
430 +
</plist>
apps/jotts/src/backend.rs (added) +182 −0
1 +
use crate::db::{self, Db, Note};
2 +
use std::fmt;
3 +
4 +
#[derive(Debug)]
5 +
pub enum BackendError {
6 +
    #[allow(dead_code)]
7 +
    NotFound,
8 +
    Unauthorized(String),
9 +
    Network(String),
10 +
    Database(String),
11 +
}
12 +
13 +
impl fmt::Display for BackendError {
14 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 +
        match self {
16 +
            BackendError::NotFound => write!(f, "Not found"),
17 +
            BackendError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
18 +
            BackendError::Network(msg) => write!(f, "Network error: {}", msg),
19 +
            BackendError::Database(msg) => write!(f, "Database error: {}", msg),
20 +
        }
21 +
    }
22 +
}
23 +
24 +
impl std::error::Error for BackendError {}
25 +
26 +
impl From<db::DbError> for BackendError {
27 +
    fn from(e: db::DbError) -> Self {
28 +
        BackendError::Database(e.to_string())
29 +
    }
30 +
}
31 +
32 +
pub enum Backend {
33 +
    Local {
34 +
        db: Db,
35 +
    },
36 +
    Remote {
37 +
        base_url: String,
38 +
        api_key: Option<String>,
39 +
        client: reqwest::blocking::Client,
40 +
    },
41 +
}
42 +
43 +
impl Backend {
44 +
    pub fn local() -> Self {
45 +
        Backend::Local { db: db::init_db() }
46 +
    }
47 +
48 +
    pub fn remote(base_url: String, api_key: Option<String>) -> Self {
49 +
        Backend::Remote {
50 +
            base_url,
51 +
            api_key,
52 +
            client: reqwest::blocking::Client::new(),
53 +
        }
54 +
    }
55 +
56 +
    pub fn list_notes(&self) -> Result<Vec<Note>, BackendError> {
57 +
        match self {
58 +
            Backend::Local { db } => Ok(db::get_all_notes(db)?),
59 +
            Backend::Remote {
60 +
                base_url,
61 +
                api_key,
62 +
                client,
63 +
            } => {
64 +
                let mut req = client.get(format!("{}/api/notes", base_url));
65 +
                if let Some(key) = api_key {
66 +
                    req = req.header("x-api-key", key);
67 +
                }
68 +
                let resp = req
69 +
                    .send()
70 +
                    .map_err(|e| BackendError::Network(e.to_string()))?;
71 +
                match resp.status().as_u16() {
72 +
                    200 => resp
73 +
                        .json::<Vec<Note>>()
74 +
                        .map_err(|e| BackendError::Network(e.to_string())),
75 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
76 +
                    403 => Err(BackendError::Unauthorized(
77 +
                        "No API key configured on server".into(),
78 +
                    )),
79 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
80 +
                }
81 +
            }
82 +
        }
83 +
    }
84 +
85 +
    pub fn create_note(&self, title: &str, content: &str) -> Result<Note, BackendError> {
86 +
        match self {
87 +
            Backend::Local { db } => Ok(db::create_note(db, title, content)?),
88 +
            Backend::Remote {
89 +
                base_url,
90 +
                api_key,
91 +
                client,
92 +
            } => {
93 +
                let mut req = client
94 +
                    .post(format!("{}/api/notes", base_url))
95 +
                    .json(&serde_json::json!({"title": title, "content": content}));
96 +
                if let Some(key) = api_key {
97 +
                    req = req.header("x-api-key", key);
98 +
                }
99 +
                let resp = req
100 +
                    .send()
101 +
                    .map_err(|e| BackendError::Network(e.to_string()))?;
102 +
                match resp.status().as_u16() {
103 +
                    201 => resp
104 +
                        .json::<Note>()
105 +
                        .map_err(|e| BackendError::Network(e.to_string())),
106 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
107 +
                    403 => Err(BackendError::Unauthorized(
108 +
                        "No API key configured on server".into(),
109 +
                    )),
110 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
111 +
                }
112 +
            }
113 +
        }
114 +
    }
115 +
116 +
    pub fn update_note(
117 +
        &self,
118 +
        short_id: &str,
119 +
        title: &str,
120 +
        content: &str,
121 +
    ) -> Result<Option<Note>, BackendError> {
122 +
        match self {
123 +
            Backend::Local { db } => Ok(db::update_note_by_short_id(db, short_id, title, content)?),
124 +
            Backend::Remote {
125 +
                base_url,
126 +
                api_key,
127 +
                client,
128 +
            } => {
129 +
                let mut req = client
130 +
                    .put(format!("{}/api/notes/{}", base_url, short_id))
131 +
                    .json(&serde_json::json!({"title": title, "content": content}));
132 +
                if let Some(key) = api_key {
133 +
                    req = req.header("x-api-key", key);
134 +
                }
135 +
                let resp = req
136 +
                    .send()
137 +
                    .map_err(|e| BackendError::Network(e.to_string()))?;
138 +
                match resp.status().as_u16() {
139 +
                    200 => resp
140 +
                        .json::<Note>()
141 +
                        .map(Some)
142 +
                        .map_err(|e| BackendError::Network(e.to_string())),
143 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
144 +
                    403 => Err(BackendError::Unauthorized(
145 +
                        "No API key configured on server".into(),
146 +
                    )),
147 +
                    404 => Ok(None),
148 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
149 +
                }
150 +
            }
151 +
        }
152 +
    }
153 +
154 +
    pub fn delete_note(&self, short_id: &str) -> Result<bool, BackendError> {
155 +
        match self {
156 +
            Backend::Local { db } => Ok(db::delete_note_by_short_id(db, short_id)?),
157 +
            Backend::Remote {
158 +
                base_url,
159 +
                api_key,
160 +
                client,
161 +
            } => {
162 +
                let mut req = client.delete(format!("{}/api/notes/{}", base_url, short_id));
163 +
                if let Some(key) = api_key {
164 +
                    req = req.header("x-api-key", key);
165 +
                }
166 +
                let resp = req
167 +
                    .send()
168 +
                    .map_err(|e| BackendError::Network(e.to_string()))?;
169 +
                match resp.status().as_u16() {
170 +
                    200 | 204 => Ok(true),
171 +
                    401 => Err(BackendError::Unauthorized("Invalid API key".into())),
172 +
                    403 => Err(BackendError::Unauthorized(
173 +
                        "No API key configured on server".into(),
174 +
                    )),
175 +
                    404 => Ok(false),
176 +
                    _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))),
177 +
                }
178 +
            }
179 +
        }
180 +
    }
181 +
}
182 +
apps/jotts/src/config.rs (added) +31 −0
1 +
use serde::{Deserialize, Serialize};
2 +
use std::path::PathBuf;
3 +
4 +
#[derive(Debug, Default, Serialize, Deserialize)]
5 +
pub struct Config {
6 +
    pub remote_url: Option<String>,
7 +
    pub api_key: Option<String>,
8 +
}
9 +
10 +
pub fn config_path() -> PathBuf {
11 +
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
12 +
    PathBuf::from(home).join(".config/jotts/config.toml")
13 +
}
14 +
15 +
pub fn load_config() -> Config {
16 +
    let path = config_path();
17 +
    match std::fs::read_to_string(&path) {
18 +
        Ok(contents) => toml::from_str(&contents).unwrap_or_default(),
19 +
        Err(_) => Config::default(),
20 +
    }
21 +
}
22 +
23 +
pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
24 +
    let path = config_path();
25 +
    if let Some(parent) = path.parent() {
26 +
        std::fs::create_dir_all(parent)?;
27 +
    }
28 +
    let contents = toml::to_string_pretty(config)?;
29 +
    std::fs::write(&path, contents)?;
30 +
    Ok(())
31 +
}
apps/jotts/src/highlight.rs (added) +60 −0
1 +
use ratatui::style::{Color, Style};
2 +
use ratatui::text::{Line, Span, Text};
3 +
use std::io::Cursor;
4 +
use syntect::easy::HighlightLines;
5 +
use syntect::highlighting::Theme;
6 +
use syntect::parsing::SyntaxSet;
7 +
use syntect::util::LinesWithEndings;
8 +
9 +
pub struct Highlighter {
10 +
    syntax_set: SyntaxSet,
11 +
    theme: Theme,
12 +
}
13 +
14 +
impl Highlighter {
15 +
    pub fn new() -> Self {
16 +
        let theme_data = include_bytes!("ansi.tmTheme");
17 +
        let theme =
18 +
            syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..]))
19 +
                .expect("failed to load ansi theme");
20 +
        Self {
21 +
            syntax_set: SyntaxSet::load_defaults_newlines(),
22 +
            theme,
23 +
        }
24 +
    }
25 +
26 +
    pub fn highlight_markdown(&self, content: &str) -> Text<'static> {
27 +
        let syntax = self
28 +
            .syntax_set
29 +
            .find_syntax_by_extension("md")
30 +
            .or_else(|| self.syntax_set.find_syntax_by_name("Markdown"))
31 +
            .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
32 +
        let mut h = HighlightLines::new(syntax, &self.theme);
33 +
34 +
        let lines: Vec<Line<'static>> = LinesWithEndings::from(content)
35 +
            .map(|line| {
36 +
                let ranges = h
37 +
                    .highlight_line(line, &self.syntax_set)
38 +
                    .unwrap_or_default();
39 +
                let spans: Vec<Span<'static>> = ranges
40 +
                    .into_iter()
41 +
                    .map(|(style, text)| {
42 +
                        let color = to_ratatui_color(style.foreground);
43 +
                        Span::styled(text.to_owned(), Style::default().fg(color))
44 +
                    })
45 +
                    .collect();
46 +
                Line::from(spans)
47 +
            })
48 +
            .collect();
49 +
50 +
        Text::from(lines)
51 +
    }
52 +
}
53 +
54 +
fn to_ratatui_color(color: syntect::highlighting::Color) -> Color {
55 +
    if color.a == 0 {
56 +
        Color::Indexed(color.r)
57 +
    } else {
58 +
        Color::Reset
59 +
    }
60 +
}
apps/jotts/src/lib.rs (added) +7 −0
1 +
pub mod auth;
2 +
pub mod backend;
3 +
pub mod config;
4 +
pub mod db;
5 +
pub mod highlight;
6 +
pub mod server;
7 +
pub mod tui;
apps/jotts/src/main.rs +71 −11
1 -
mod auth;
2 -
mod db;
3 -
mod server;
1 +
use clap::{Parser, Subcommand};
2 +
use std::path::PathBuf;
3 +
4 +
#[derive(Parser)]
5 +
#[command(name = "jotts", about = "Markdown notes — TUI, server, and CLI")]
6 +
struct Cli {
7 +
    /// Remote server URL (e.g. http://localhost:3000)
8 +
    #[arg(short, long, env = "JOTTS_REMOTE_URL")]
9 +
    remote: Option<String>,
10 +
11 +
    /// API key for authenticated operations
12 +
    #[arg(short = 'k', long, env = "JOTTS_API_KEY")]
13 +
    api_key: Option<String>,
14 +
15 +
    /// File path to create a note from
16 +
    #[arg(value_name = "FILE")]
17 +
    file: Option<PathBuf>,
18 +
19 +
    #[command(subcommand)]
20 +
    command: Option<Commands>,
21 +
}
4 22
5 -
#[tokio::main]
6 -
async fn main() {
23 +
#[derive(Subcommand)]
24 +
enum Commands {
25 +
    /// Start the web server
26 +
    Server {
27 +
        /// Port to listen on
28 +
        #[arg(short, long, default_value_t = 3000)]
29 +
        port: u16,
30 +
31 +
        /// Host to bind to
32 +
        #[arg(long, default_value = "127.0.0.1")]
33 +
        host: String,
34 +
    },
35 +
    /// Launch the interactive TUI
36 +
    Tui {
37 +
        #[arg(short, long, env = "JOTTS_REMOTE_URL")]
38 +
        remote: Option<String>,
39 +
40 +
        #[arg(short = 'k', long, env = "JOTTS_API_KEY")]
41 +
        api_key: Option<String>,
42 +
    },
43 +
    /// Save remote URL and API key to config file
44 +
    Auth,
45 +
}
46 +
47 +
fn main() -> Result<(), Box<dyn std::error::Error>> {
48 +
    dotenvy::dotenv().ok();
7 49
    tracing_subscriber::fmt::init();
8 -
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
9 -
    let port: u16 = std::env::var("PORT")
10 -
        .ok()
11 -
        .and_then(|v| v.parse().ok())
12 -
        .unwrap_or(3000);
13 -
    server::run(host, port).await;
50 +
51 +
    let cli = Cli::parse();
52 +
53 +
    match cli.command {
54 +
        Some(Commands::Server { port, host }) => {
55 +
            let rt = tokio::runtime::Runtime::new()?;
56 +
            rt.block_on(jotts::server::run(host, port));
57 +
        }
58 +
        Some(Commands::Tui { remote, api_key }) => {
59 +
            jotts::tui::run_interactive(remote, api_key)?;
60 +
        }
61 +
        Some(Commands::Auth) => {
62 +
            jotts::tui::run_auth()?;
63 +
        }
64 +
        None => {
65 +
            if let Some(file) = cli.file {
66 +
                jotts::tui::run_file_upload(cli.remote, cli.api_key, file)?;
67 +
            } else {
68 +
                jotts::tui::run_interactive(cli.remote, cli.api_key)?;
69 +
            }
70 +
        }
71 +
    }
72 +
73 +
    Ok(())
14 74
}
apps/jotts/src/server.rs +135 −2
1 1
use askama::Template;
2 2
use askama_web::WebTemplate;
3 3
use axum::{
4 -
    extract::{Form, Path, Query, State},
4 +
    extract::{Form, Path, Query, Request, State},
5 5
    http::{HeaderValue, StatusCode},
6 +
    middleware::{self, Next},
6 7
    response::{Html, IntoResponse, Redirect, Response},
7 8
    routing::{get, post},
8 -
    Router,
9 +
    Json, Router,
9 10
};
10 11
use pulldown_cmark::{Options, Parser, html}; use rust_embed::Embed;
11 12
use std::sync::Arc;
17 18
pub struct AppState {
18 19
    pub db: Db,
19 20
    pub app_password: String,
21 +
    pub api_key: Option<String>,
20 22
    pub cookie_secure: bool,
21 23
}
22 24
78 80
struct NoteForm {
79 81
    title: String,
80 82
    content: String,
83 +
}
84 +
85 +
#[derive(serde::Deserialize)]
86 +
struct NoteJson {
87 +
    title: String,
88 +
    content: String,
89 +
}
90 +
91 +
// --- API key middleware ---
92 +
93 +
async fn api_key_guard(
94 +
    State(state): State<Arc<AppState>>,
95 +
    req: Request,
96 +
    next: Next,
97 +
) -> Response {
98 +
    let expected = match &state.api_key {
99 +
        Some(k) if !k.is_empty() => k.clone(),
100 +
        _ => {
101 +
            return (StatusCode::FORBIDDEN, "API key not configured on server").into_response();
102 +
        }
103 +
    };
104 +
105 +
    let provided = req
106 +
        .headers()
107 +
        .get("x-api-key")
108 +
        .and_then(|v| v.to_str().ok())
109 +
        .unwrap_or("");
110 +
111 +
    if !andromeda_auth::verify_api_key(provided, &expected) {
112 +
        return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response();
113 +
    }
114 +
115 +
    next.run(req).await
116 +
}
117 +
118 +
// --- JSON API handlers ---
119 +
120 +
async fn api_list_notes(State(state): State<Arc<AppState>>) -> Response {
121 +
    match db::get_all_notes(&state.db) {
122 +
        Ok(notes) => Json(notes).into_response(),
123 +
        Err(e) => {
124 +
            tracing::error!("Failed to list notes: {}", e);
125 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
126 +
        }
127 +
    }
128 +
}
129 +
130 +
async fn api_get_note(
131 +
    State(state): State<Arc<AppState>>,
132 +
    Path(short_id): Path<String>,
133 +
) -> Response {
134 +
    match db::get_note_by_short_id(&state.db, &short_id) {
135 +
        Ok(Some(note)) => Json(note).into_response(),
136 +
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
137 +
        Err(e) => {
138 +
            tracing::error!("Failed to get note: {}", e);
139 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
140 +
        }
141 +
    }
142 +
}
143 +
144 +
async fn api_create_note(
145 +
    State(state): State<Arc<AppState>>,
146 +
    Json(body): Json<NoteJson>,
147 +
) -> Response {
148 +
    let title = body.title.trim();
149 +
    if title.is_empty() {
150 +
        return (StatusCode::BAD_REQUEST, "title required").into_response();
151 +
    }
152 +
    match db::create_note(&state.db, title, &body.content) {
153 +
        Ok(note) => (StatusCode::CREATED, Json(note)).into_response(),
154 +
        Err(e) => {
155 +
            tracing::error!("Failed to create note: {}", e);
156 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
157 +
        }
158 +
    }
159 +
}
160 +
161 +
async fn api_update_note(
162 +
    State(state): State<Arc<AppState>>,
163 +
    Path(short_id): Path<String>,
164 +
    Json(body): Json<NoteJson>,
165 +
) -> Response {
166 +
    let title = body.title.trim();
167 +
    if title.is_empty() {
168 +
        return (StatusCode::BAD_REQUEST, "title required").into_response();
169 +
    }
170 +
    match db::update_note_by_short_id(&state.db, &short_id, title, &body.content) {
171 +
        Ok(Some(note)) => Json(note).into_response(),
172 +
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
173 +
        Err(e) => {
174 +
            tracing::error!("Failed to update note: {}", e);
175 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
176 +
        }
177 +
    }
178 +
}
179 +
180 +
async fn api_delete_note(
181 +
    State(state): State<Arc<AppState>>,
182 +
    Path(short_id): Path<String>,
183 +
) -> Response {
184 +
    match db::delete_note_by_short_id(&state.db, &short_id) {
185 +
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
186 +
        Ok(false) => StatusCode::NOT_FOUND.into_response(),
187 +
        Err(e) => {
188 +
            tracing::error!("Failed to delete note: {}", e);
189 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response()
190 +
        }
191 +
    }
81 192
}
82 193
83 194
// --- Static file handlers ---
359 470
        .map(|v| v == "true")
360 471
        .unwrap_or(false);
361 472
473 +
    let api_key = std::env::var("JOTTS_API_KEY")
474 +
        .ok()
475 +
        .filter(|k| !k.is_empty());
476 +
    if api_key.is_none() {
477 +
        tracing::info!("JOTTS_API_KEY not set, /api/* will return 403");
478 +
    }
479 +
362 480
    let state = Arc::new(AppState {
363 481
        db,
364 482
        app_password,
483 +
        api_key,
365 484
        cookie_secure,
366 485
    });
367 486
487 +
    let api_router = Router::new()
488 +
        .route("/api/notes", get(api_list_notes).post(api_create_note))
489 +
        .route(
490 +
            "/api/notes/{short_id}",
491 +
            get(api_get_note)
492 +
                .put(api_update_note)
493 +
                .delete(api_delete_note),
494 +
        )
495 +
        .route_layer(middleware::from_fn_with_state(
496 +
            state.clone(),
497 +
            api_key_guard,
498 +
        ));
499 +
368 500
    let app = Router::new()
369 501
        // Public routes
370 502
        .route("/login", get(get_login).post(post_login))
379 511
        .route("/notes/{short_id}/delete", post(post_delete_note))
380 512
        // Static assets
381 513
        .route("/static/{*path}", get(serve_static))
514 +
        .merge(api_router)
382 515
        .with_state(state);
383 516
384 517
    let addr = format!("{}:{}", host, port);
apps/jotts/src/tui.rs (added) +1171 −0
1 +
use crate::backend::Backend;
2 +
use crate::config;
3 +
use crate::db::Note;
4 +
use crate::highlight::Highlighter;
5 +
use arboard::Clipboard;
6 +
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
7 +
use ratatui::{
8 +
    DefaultTerminal,
9 +
    layout::{Alignment, Constraint, Layout},
10 +
    style::{Color, Modifier, Style},
11 +
    text::{Line, Span, Text},
12 +
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Widget, Wrap},
13 +
};
14 +
use std::path::PathBuf;
15 +
use std::time::{Duration, Instant};
16 +
17 +
fn edit_in_external_editor(
18 +
    terminal: &mut DefaultTerminal,
19 +
    app: &mut App,
20 +
    backend: &Backend,
21 +
) -> Result<(), Box<dyn std::error::Error>> {
22 +
    let (short_id, title, content) = match app.selected_note() {
23 +
        Some(n) => (n.short_id.clone(), n.title.clone(), n.content.clone()),
24 +
        None => return Ok(()),
25 +
    };
26 +
27 +
    let editor = match std::env::var("EDITOR") {
28 +
        Ok(e) if !e.trim().is_empty() => e,
29 +
        _ => {
30 +
            app.status_message = Some(("EDITOR env not set".to_string(), Instant::now()));
31 +
            return Ok(());
32 +
        }
33 +
    };
34 +
35 +
    let mut path = std::env::temp_dir();
36 +
    path.push(format!("jotts-{}.md", short_id));
37 +
    std::fs::write(&path, &content)?;
38 +
39 +
    ratatui::restore();
40 +
41 +
    let status = std::process::Command::new(&editor).arg(&path).status();
42 +
43 +
    *terminal = ratatui::init();
44 +
    terminal.clear()?;
45 +
46 +
    match status {
47 +
        Ok(s) if s.success() => {
48 +
            let new_content = std::fs::read_to_string(&path)?;
49 +
            let _ = std::fs::remove_file(&path);
50 +
            if new_content == content {
51 +
                app.status_message = Some(("No changes".to_string(), Instant::now()));
52 +
                return Ok(());
53 +
            }
54 +
            match backend.update_note(&short_id, &title, &new_content) {
55 +
                Ok(Some(updated)) => {
56 +
                    if let Some(pos) = app.notes.iter().position(|n| n.short_id == short_id) {
57 +
                        app.notes[pos] = updated;
58 +
                    }
59 +
                    app.status_message = Some(("Updated!".to_string(), Instant::now()));
60 +
                }
61 +
                Ok(None) => {
62 +
                    app.status_message = Some(("Note not found".to_string(), Instant::now()));
63 +
                }
64 +
                Err(e) => {
65 +
                    app.status_message = Some((e.to_string(), Instant::now()));
66 +
                }
67 +
            }
68 +
        }
69 +
        Ok(_) => {
70 +
            let _ = std::fs::remove_file(&path);
71 +
            app.status_message = Some(("Editor exited non-zero".to_string(), Instant::now()));
72 +
        }
73 +
        Err(e) => {
74 +
            let _ = std::fs::remove_file(&path);
75 +
            app.status_message =
76 +
                Some((format!("Failed to launch editor: {}", e), Instant::now()));
77 +
        }
78 +
    }
79 +
    Ok(())
80 +
}
81 +
82 +
enum Focus {
83 +
    List,
84 +
    Content,
85 +
    CreateTitle,
86 +
    CreateContent,
87 +
    EditTitle,
88 +
    EditContent,
89 +
    Search,
90 +
}
91 +
92 +
struct App {
93 +
    notes: Vec<Note>,
94 +
    list_state: ListState,
95 +
    should_quit: bool,
96 +
    status_message: Option<(String, Instant)>,
97 +
    focus: Focus,
98 +
    content_scroll: u16,
99 +
    show_help: bool,
100 +
    confirm_delete: bool,
101 +
    highlighter: Highlighter,
102 +
    edit_title: String,
103 +
    edit_content: String,
104 +
    edit_short_id: Option<String>,
105 +
    search_query: String,
106 +
    filtered_indices: Option<Vec<usize>>,
107 +
    is_remote: bool,
108 +
    remote_url: Option<String>,
109 +
    wrap_content: bool,
110 +
    edit_scroll: u16,
111 +
}
112 +
113 +
impl App {
114 +
    fn new(notes: Vec<Note>, is_remote: bool, remote_url: Option<String>) -> Self {
115 +
        let mut list_state = ListState::default();
116 +
        if !notes.is_empty() {
117 +
            list_state.select(Some(0));
118 +
        }
119 +
        Self {
120 +
            notes,
121 +
            list_state,
122 +
            should_quit: false,
123 +
            status_message: None,
124 +
            focus: Focus::List,
125 +
            content_scroll: 0,
126 +
            show_help: false,
127 +
            confirm_delete: false,
128 +
            highlighter: Highlighter::new(),
129 +
            edit_title: String::new(),
130 +
            edit_content: String::new(),
131 +
            edit_short_id: None,
132 +
            search_query: String::new(),
133 +
            filtered_indices: None,
134 +
            is_remote,
135 +
            remote_url,
136 +
            wrap_content: true,
137 +
            edit_scroll: 0,
138 +
        }
139 +
    }
140 +
141 +
    fn selected_note(&self) -> Option<&Note> {
142 +
        self.list_state.selected().and_then(|i| {
143 +
            if let Some(indices) = &self.filtered_indices {
144 +
                indices.get(i).and_then(|&real| self.notes.get(real))
145 +
            } else {
146 +
                self.notes.get(i)
147 +
            }
148 +
        })
149 +
    }
150 +
151 +
    fn visible_count(&self) -> usize {
152 +
        match &self.filtered_indices {
153 +
            Some(indices) => indices.len(),
154 +
            None => self.notes.len(),
155 +
        }
156 +
    }
157 +
158 +
    fn move_up(&mut self) {
159 +
        let count = self.visible_count();
160 +
        if count == 0 {
161 +
            return;
162 +
        }
163 +
        let i = match self.list_state.selected() {
164 +
            Some(i) if i > 0 => i - 1,
165 +
            Some(_) => count - 1,
166 +
            None => 0,
167 +
        };
168 +
        self.list_state.select(Some(i));
169 +
        self.content_scroll = 0;
170 +
    }
171 +
172 +
    fn move_down(&mut self) {
173 +
        let count = self.visible_count();
174 +
        if count == 0 {
175 +
            return;
176 +
        }
177 +
        let i = match self.list_state.selected() {
178 +
            Some(i) if i < count - 1 => i + 1,
179 +
            Some(_) => 0,
180 +
            None => 0,
181 +
        };
182 +
        self.list_state.select(Some(i));
183 +
        self.content_scroll = 0;
184 +
    }
185 +
186 +
    fn scroll_up(&mut self) {
187 +
        self.content_scroll = self.content_scroll.saturating_sub(1);
188 +
    }
189 +
190 +
    fn scroll_down(&mut self, max_lines: u16) {
191 +
        if self.content_scroll < max_lines {
192 +
            self.content_scroll += 1;
193 +
        }
194 +
    }
195 +
196 +
    fn copy_selected(&mut self) {
197 +
        if let Some(note) = self.selected_note() {
198 +
            if let Ok(mut clipboard) = Clipboard::new() {
199 +
                let _ = clipboard.set_text(&note.content);
200 +
                self.status_message = Some(("Copied!".to_string(), Instant::now()));
201 +
            }
202 +
        }
203 +
    }
204 +
205 +
    fn copy_link(&mut self) {
206 +
        match &self.remote_url {
207 +
            Some(url) => {
208 +
                if let Some(note) = self.selected_note() {
209 +
                    let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id);
210 +
                    if let Ok(mut clipboard) = Clipboard::new() {
211 +
                        let _ = clipboard.set_text(&link);
212 +
                        self.status_message =
213 +
                            Some(("Link copied!".to_string(), Instant::now()));
214 +
                    }
215 +
                }
216 +
            }
217 +
            None => {
218 +
                self.status_message =
219 +
                    Some(("No remote URL configured".to_string(), Instant::now()));
220 +
            }
221 +
        }
222 +
    }
223 +
224 +
    fn open_in_browser(&mut self) {
225 +
        match &self.remote_url {
226 +
            Some(url) => {
227 +
                if let Some(note) = self.selected_note() {
228 +
                    let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id);
229 +
                    if let Err(e) = open::that(&link) {
230 +
                        self.status_message =
231 +
                            Some((format!("Failed to open browser: {}", e), Instant::now()));
232 +
                    } else {
233 +
                        self.status_message =
234 +
                            Some(("Opened in browser!".to_string(), Instant::now()));
235 +
                    }
236 +
                }
237 +
            }
238 +
            None => {
239 +
                self.status_message =
240 +
                    Some(("No remote URL configured".to_string(), Instant::now()));
241 +
            }
242 +
        }
243 +
    }
244 +
245 +
    fn delete_selected(&mut self, backend: &Backend) {
246 +
        if let Some(selected_index) = self.list_state.selected() {
247 +
            let real_index = if let Some(indices) = &self.filtered_indices {
248 +
                match indices.get(selected_index) {
249 +
                    Some(&ri) => ri,
250 +
                    None => return,
251 +
                }
252 +
            } else {
253 +
                selected_index
254 +
            };
255 +
            if let Some(note) = self.notes.get(real_index) {
256 +
                let short_id = note.short_id.clone();
257 +
                match backend.delete_note(&short_id) {
258 +
                    Ok(true) => {
259 +
                        self.notes.remove(real_index);
260 +
                        if self.filtered_indices.is_some() {
261 +
                            self.update_search_filter();
262 +
                        }
263 +
                        let count = self.visible_count();
264 +
                        if count == 0 {
265 +
                            self.list_state.select(None);
266 +
                        } else if selected_index >= count {
267 +
                            self.list_state.select(Some(count - 1));
268 +
                        } else {
269 +
                            self.list_state.select(Some(selected_index));
270 +
                        }
271 +
                        self.status_message = Some(("Deleted!".to_string(), Instant::now()));
272 +
                    }
273 +
                    Ok(false) => {
274 +
                        self.status_message =
275 +
                            Some(("Note not found".to_string(), Instant::now()));
276 +
                    }
277 +
                    Err(e) => {
278 +
                        self.status_message = Some((e.to_string(), Instant::now()));
279 +
                    }
280 +
                }
281 +
            }
282 +
        }
283 +
    }
284 +
285 +
    fn refresh(&mut self, backend: &Backend) {
286 +
        match backend.list_notes() {
287 +
            Ok(notes) => {
288 +
                self.notes = notes;
289 +
                self.filtered_indices = None;
290 +
                self.search_query.clear();
291 +
                if self.notes.is_empty() {
292 +
                    self.list_state.select(None);
293 +
                } else {
294 +
                    let idx = self.list_state.selected().unwrap_or(0);
295 +
                    if idx >= self.notes.len() {
296 +
                        self.list_state.select(Some(self.notes.len() - 1));
297 +
                    }
298 +
                }
299 +
                self.status_message = Some(("Refreshed!".to_string(), Instant::now()));
300 +
            }
301 +
            Err(e) => {
302 +
                self.status_message = Some((e.to_string(), Instant::now()));
303 +
            }
304 +
        }
305 +
    }
306 +
307 +
    fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) {
308 +
        let w = width as usize;
309 +
        if w == 0 {
310 +
            return (0, 0);
311 +
        }
312 +
        let text = &self.edit_content;
313 +
        let mut visual_row: usize = 0;
314 +
        let lines: Vec<&str> = if text.is_empty() {
315 +
            vec![""]
316 +
        } else {
317 +
            text.split('\n').collect()
318 +
        };
319 +
        let last_idx = lines.len() - 1;
320 +
        for (i, line) in lines.iter().enumerate() {
321 +
            let line_len = line.len();
322 +
            let wrapped_lines = if line_len == 0 {
323 +
                1
324 +
            } else {
325 +
                (line_len + w - 1) / w
326 +
            };
327 +
            if i < last_idx {
328 +
                visual_row += wrapped_lines;
329 +
            } else {
330 +
                let cursor_col = if text.ends_with('\n') { 0 } else { line_len };
331 +
                let extra_rows = cursor_col / w;
332 +
                let col = cursor_col % w;
333 +
                visual_row += extra_rows;
334 +
                return (col as u16, visual_row as u16);
335 +
            }
336 +
        }
337 +
        (0, visual_row as u16)
338 +
    }
339 +
340 +
    fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) {
341 +
        if visible_height == 0 {
342 +
            return;
343 +
        }
344 +
        if cursor_visual_row < self.edit_scroll {
345 +
            self.edit_scroll = cursor_visual_row;
346 +
        } else if cursor_visual_row >= self.edit_scroll + visible_height {
347 +
            self.edit_scroll = cursor_visual_row - visible_height + 1;
348 +
        }
349 +
    }
350 +
351 +
    fn start_create(&mut self) {
352 +
        self.edit_title.clear();
353 +
        self.edit_content.clear();
354 +
        self.edit_scroll = 0;
355 +
        self.focus = Focus::CreateTitle;
356 +
    }
357 +
358 +
    fn save_create(&mut self, backend: &Backend) {
359 +
        if self.edit_title.trim().is_empty() {
360 +
            self.status_message = Some(("Title cannot be empty".to_string(), Instant::now()));
361 +
            return;
362 +
        }
363 +
        match backend.create_note(&self.edit_title, &self.edit_content) {
364 +
            Ok(note) => {
365 +
                self.notes.insert(0, note);
366 +
                self.list_state.select(Some(0));
367 +
                self.filtered_indices = None;
368 +
                self.search_query.clear();
369 +
                self.status_message = Some(("Created!".to_string(), Instant::now()));
370 +
                self.focus = Focus::List;
371 +
                self.edit_title.clear();
372 +
                self.edit_content.clear();
373 +
            }
374 +
            Err(e) => {
375 +
                self.status_message = Some((e.to_string(), Instant::now()));
376 +
            }
377 +
        }
378 +
    }
379 +
380 +
    fn cancel_create(&mut self) {
381 +
        self.edit_title.clear();
382 +
        self.edit_content.clear();
383 +
        self.focus = Focus::List;
384 +
    }
385 +
386 +
    fn start_edit(&mut self) {
387 +
        let data = self
388 +
            .selected_note()
389 +
            .map(|n| (n.title.clone(), n.content.clone(), n.short_id.clone()));
390 +
        if let Some((title, content, short_id)) = data {
391 +
            self.edit_title = title;
392 +
            self.edit_content = content;
393 +
            self.edit_short_id = Some(short_id);
394 +
            self.edit_scroll = 0;
395 +
            self.focus = Focus::EditTitle;
396 +
        }
397 +
    }
398 +
399 +
    fn save_edit(&mut self, backend: &Backend) {
400 +
        if self.edit_title.trim().is_empty() {
401 +
            self.status_message = Some(("Title cannot be empty".to_string(), Instant::now()));
402 +
            return;
403 +
        }
404 +
        let short_id = match &self.edit_short_id {
405 +
            Some(id) => id.clone(),
406 +
            None => return,
407 +
        };
408 +
        match backend.update_note(&short_id, &self.edit_title, &self.edit_content) {
409 +
            Ok(Some(updated)) => {
410 +
                if let Some(pos) = self.notes.iter().position(|n| n.short_id == short_id) {
411 +
                    self.notes[pos] = updated;
412 +
                }
413 +
                self.status_message = Some(("Updated!".to_string(), Instant::now()));
414 +
                self.focus = Focus::List;
415 +
                self.edit_title.clear();
416 +
                self.edit_content.clear();
417 +
                self.edit_short_id = None;
418 +
            }
419 +
            Ok(None) => {
420 +
                self.status_message = Some(("Note not found".to_string(), Instant::now()));
421 +
            }
422 +
            Err(e) => {
423 +
                self.status_message = Some((e.to_string(), Instant::now()));
424 +
            }
425 +
        }
426 +
    }
427 +
428 +
    fn cancel_edit(&mut self) {
429 +
        self.edit_title.clear();
430 +
        self.edit_content.clear();
431 +
        self.edit_short_id = None;
432 +
        self.focus = Focus::List;
433 +
    }
434 +
435 +
    fn start_search(&mut self) {
436 +
        self.search_query.clear();
437 +
        self.filtered_indices = Some((0..self.notes.len()).collect());
438 +
        self.focus = Focus::Search;
439 +
        self.list_state
440 +
            .select(if self.notes.is_empty() { None } else { Some(0) });
441 +
    }
442 +
443 +
    fn update_search_filter(&mut self) {
444 +
        let query = self.search_query.to_lowercase();
445 +
        let indices: Vec<usize> = self
446 +
            .notes
447 +
            .iter()
448 +
            .enumerate()
449 +
            .filter(|(_, n)| n.title.to_lowercase().contains(&query))
450 +
            .map(|(i, _)| i)
451 +
            .collect();
452 +
        self.filtered_indices = Some(indices);
453 +
        if self.visible_count() == 0 {
454 +
            self.list_state.select(None);
455 +
        } else {
456 +
            self.list_state.select(Some(0));
457 +
        }
458 +
    }
459 +
460 +
    fn cancel_search(&mut self) {
461 +
        self.filtered_indices = None;
462 +
        self.search_query.clear();
463 +
        self.focus = Focus::List;
464 +
    }
465 +
466 +
    fn confirm_search(&mut self) {
467 +
        let real_index = self.list_state.selected().and_then(|i| {
468 +
            self.filtered_indices
469 +
                .as_ref()
470 +
                .and_then(|indices| indices.get(i).copied())
471 +
        });
472 +
        self.filtered_indices = None;
473 +
        self.search_query.clear();
474 +
        self.focus = Focus::List;
475 +
        if let Some(ri) = real_index {
476 +
            self.list_state.select(Some(ri));
477 +
        }
478 +
    }
479 +
480 +
    fn clear_expired_status(&mut self) {
481 +
        if let Some((_, time)) = &self.status_message {
482 +
            if time.elapsed() > Duration::from_secs(2) {
483 +
                self.status_message = None;
484 +
            }
485 +
        }
486 +
    }
487 +
}
488 +
489 +
fn db_path() -> String {
490 +
    std::env::var("JOTTS_DB_PATH").unwrap_or_else(|_| "jotts.sqlite".to_string())
491 +
}
492 +
493 +
fn resolve_backend(
494 +
    remote: Option<String>,
495 +
    api_key: Option<String>,
496 +
) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> {
497 +
    if let Some(url) = remote {
498 +
        return Ok((Backend::remote(url.clone(), api_key), true, Some(url)));
499 +
    }
500 +
501 +
    if !std::path::Path::new(&db_path()).exists() {
502 +
        let cfg = config::load_config();
503 +
        let url = cfg
504 +
            .remote_url
505 +
            .unwrap_or_else(|| "http://localhost:3000".to_string());
506 +
        let api_key = api_key.or(cfg.api_key);
507 +
        return Ok((Backend::remote(url.clone(), api_key), true, Some(url)));
508 +
    }
509 +
510 +
    Ok((
511 +
        Backend::local(),
512 +
        false,
513 +
        Some("http://localhost:3000".to_string()),
514 +
    ))
515 +
}
516 +
517 +
pub fn run_file_upload(
518 +
    remote: Option<String>,
519 +
    api_key: Option<String>,
520 +
    file: PathBuf,
521 +
) -> Result<(), Box<dyn std::error::Error>> {
522 +
    let (backend, _, remote_url) = resolve_backend(remote, api_key)?;
523 +
524 +
    let title = file
525 +
        .file_stem()
526 +
        .ok_or("Invalid file path")?
527 +
        .to_string_lossy()
528 +
        .to_string();
529 +
    let content = std::fs::read_to_string(&file)
530 +
        .map_err(|e| format!("Failed to read file: {}", e))?;
531 +
    let note = backend
532 +
        .create_note(&title, &content)
533 +
        .map_err(|e| format!("{}", e))?;
534 +
    let link = match &remote_url {
535 +
        Some(url) => format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id),
536 +
        None => note.short_id.clone(),
537 +
    };
538 +
    println!("{}", link);
539 +
    if let Ok(mut clipboard) = Clipboard::new() {
540 +
        let _ = clipboard.set_text(&link);
541 +
        println!("\u{2714} Copied to clipboard!");
542 +
    }
543 +
    Ok(())
544 +
}
545 +
546 +
pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> {
547 +
    use std::io::{self, Write};
548 +
549 +
    print!("Remote URL: ");
550 +
    io::stdout().flush()?;
551 +
    let mut remote_url = String::new();
552 +
    io::stdin().read_line(&mut remote_url)?;
553 +
    let remote_url = remote_url.trim().to_string();
554 +
555 +
    print!("API Key: ");
556 +
    io::stdout().flush()?;
557 +
    let api_key = rpassword::read_password()?;
558 +
    let api_key = api_key.trim().to_string();
559 +
560 +
    let cfg = config::Config {
561 +
        remote_url: if remote_url.is_empty() {
562 +
            None
563 +
        } else {
564 +
            Some(remote_url)
565 +
        },
566 +
        api_key: if api_key.is_empty() {
567 +
            None
568 +
        } else {
569 +
            Some(api_key)
570 +
        },
571 +
    };
572 +
573 +
    config::save_config(&cfg)?;
574 +
    println!("Config saved to {}", config::config_path().display());
575 +
    Ok(())
576 +
}
577 +
578 +
pub fn run_interactive(
579 +
    remote: Option<String>,
580 +
    api_key: Option<String>,
581 +
) -> Result<(), Box<dyn std::error::Error>> {
582 +
    let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?;
583 +
584 +
    let notes = match backend.list_notes() {
585 +
        Ok(n) => n,
586 +
        Err(e) => {
587 +
            eprintln!("Failed to load notes: {}", e);
588 +
            Vec::new()
589 +
        }
590 +
    };
591 +
592 +
    ratatui::run(|terminal| run_app(terminal, App::new(notes, is_remote, remote_url), &backend))
593 +
}
594 +
595 +
fn run_app(
596 +
    terminal: &mut DefaultTerminal,
597 +
    mut app: App,
598 +
    backend: &Backend,
599 +
) -> Result<(), Box<dyn std::error::Error>> {
600 +
    while !app.should_quit {
601 +
        app.clear_expired_status();
602 +
603 +
        let content_line_count = app
604 +
            .selected_note()
605 +
            .map(|n| n.content.lines().count() as u16)
606 +
            .unwrap_or(0);
607 +
608 +
        terminal.draw(|frame| {
609 +
            let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)])
610 +
                .split(frame.area());
611 +
612 +
            let chunks = Layout::horizontal([
613 +
                Constraint::Percentage(30),
614 +
                Constraint::Percentage(70),
615 +
            ])
616 +
            .split(outer[0]);
617 +
618 +
            let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
619 +
                indices
620 +
                    .iter()
621 +
                    .filter_map(|&i| app.notes.get(i))
622 +
                    .map(|n| ListItem::new(n.title.as_str()))
623 +
                    .collect()
624 +
            } else {
625 +
                app.notes
626 +
                    .iter()
627 +
                    .map(|n| ListItem::new(n.title.as_str()))
628 +
                    .collect()
629 +
            };
630 +
631 +
            let list_border_style = match app.focus {
632 +
                Focus::List | Focus::Search => Style::default().fg(Color::Yellow),
633 +
                _ => Style::default().fg(Color::DarkGray),
634 +
            };
635 +
            let content_border_style = match app.focus {
636 +
                Focus::Content => Style::default().fg(Color::Yellow),
637 +
                _ => Style::default().fg(Color::DarkGray),
638 +
            };
639 +
640 +
            let list = List::new(items)
641 +
                .block(
642 +
                    Block::default()
643 +
                        .title(" Notes ")
644 +
                        .borders(Borders::ALL)
645 +
                        .border_style(list_border_style),
646 +
                )
647 +
                .highlight_style(
648 +
                    Style::default()
649 +
                        .fg(Color::Yellow)
650 +
                        .add_modifier(Modifier::BOLD),
651 +
                )
652 +
                .highlight_symbol("▶ ");
653 +
654 +
            if matches!(app.focus, Focus::Search) {
655 +
                let search_split =
656 +
                    Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(chunks[0]);
657 +
658 +
                let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices {
659 +
                    indices
660 +
                        .iter()
661 +
                        .filter_map(|&i| app.notes.get(i))
662 +
                        .map(|n| ListItem::new(n.title.as_str()))
663 +
                        .collect()
664 +
                } else {
665 +
                    app.notes
666 +
                        .iter()
667 +
                        .map(|n| ListItem::new(n.title.as_str()))
668 +
                        .collect()
669 +
                };
670 +
                let search_list = List::new(search_items)
671 +
                    .block(
672 +
                        Block::default()
673 +
                            .title(" Notes ")
674 +
                            .borders(Borders::ALL)
675 +
                            .border_style(list_border_style),
676 +
                    )
677 +
                    .highlight_style(
678 +
                        Style::default()
679 +
                            .fg(Color::Yellow)
680 +
                            .add_modifier(Modifier::BOLD),
681 +
                    )
682 +
                    .highlight_symbol("▶ ");
683 +
                frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state);
684 +
685 +
                let search_input = Paragraph::new(app.search_query.as_str()).block(
686 +
                    Block::default()
687 +
                        .title(" Search ")
688 +
                        .borders(Borders::ALL)
689 +
                        .border_style(Style::default().fg(Color::Yellow)),
690 +
                );
691 +
                frame.render_widget(search_input, search_split[1]);
692 +
693 +
                let x = search_split[1].x + 1 + app.search_query.len() as u16;
694 +
                let y = search_split[1].y + 1;
695 +
                frame.set_cursor_position((x, y));
696 +
            } else {
697 +
                frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
698 +
            }
699 +
700 +
            match app.focus {
701 +
                Focus::CreateTitle
702 +
                | Focus::CreateContent
703 +
                | Focus::EditTitle
704 +
                | Focus::EditContent => {
705 +
                    let form_title = match app.focus {
706 +
                        Focus::EditTitle | Focus::EditContent => " Edit Note ",
707 +
                        _ => " New Note ",
708 +
                    };
709 +
                    let create_block = Block::default()
710 +
                        .title(form_title)
711 +
                        .borders(Borders::ALL)
712 +
                        .border_style(Style::default().fg(Color::Yellow));
713 +
714 +
                    let inner = create_block.inner(chunks[1]);
715 +
                    frame.render_widget(create_block, chunks[1]);
716 +
717 +
                    let form_layout =
718 +
                        Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(inner);
719 +
720 +
                    let title_style = match app.focus {
721 +
                        Focus::CreateTitle | Focus::EditTitle => Style::default().fg(Color::Yellow),
722 +
                        _ => Style::default().fg(Color::DarkGray),
723 +
                    };
724 +
                    let title_input = Paragraph::new(app.edit_title.as_str()).block(
725 +
                        Block::default()
726 +
                            .title(" Title ")
727 +
                            .borders(Borders::ALL)
728 +
                            .border_style(title_style),
729 +
                    );
730 +
                    frame.render_widget(title_input, form_layout[0]);
731 +
732 +
                    let content_style = match app.focus {
733 +
                        Focus::CreateContent | Focus::EditContent => {
734 +
                            Style::default().fg(Color::Yellow)
735 +
                        }
736 +
                        _ => Style::default().fg(Color::DarkGray),
737 +
                    };
738 +
                    let mut content_input = Paragraph::new(app.edit_content.as_str()).block(
739 +
                        Block::default()
740 +
                            .title(" Content ")
741 +
                            .borders(Borders::ALL)
742 +
                            .border_style(content_style),
743 +
                    );
744 +
                    if app.wrap_content {
745 +
                        content_input = content_input.wrap(Wrap { trim: false });
746 +
                    }
747 +
                    content_input = content_input.scroll((app.edit_scroll, 0));
748 +
                    frame.render_widget(content_input, form_layout[1]);
749 +
750 +
                    let content_inner =
751 +
                        Block::default().borders(Borders::ALL).inner(form_layout[1]);
752 +
                    let inner_width = content_inner.width;
753 +
                    let inner_height = content_inner.height;
754 +
755 +
                    match app.focus {
756 +
                        Focus::CreateTitle | Focus::EditTitle => {
757 +
                            let x = form_layout[0].x + 1 + app.edit_title.len() as u16;
758 +
                            let y = form_layout[0].y + 1;
759 +
                            frame.set_cursor_position((x, y));
760 +
                        }
761 +
                        Focus::CreateContent | Focus::EditContent => {
762 +
                            let (cx, cy) = if app.wrap_content {
763 +
                                app.cursor_position_wrapped(inner_width)
764 +
                            } else {
765 +
                                let last_line = app.edit_content.lines().last().unwrap_or("");
766 +
                                let line_count = app.edit_content.lines().count()
767 +
                                    + if app.edit_content.ends_with('\n') { 1 } else { 0 };
768 +
                                let y_offset = if line_count == 0 { 0 } else { line_count - 1 };
769 +
                                let col = if app.edit_content.ends_with('\n') {
770 +
                                    0
771 +
                                } else {
772 +
                                    last_line.len() as u16
773 +
                                };
774 +
                                (col, y_offset as u16)
775 +
                            };
776 +
                            app.auto_scroll_edit(cy, inner_height);
777 +
                            let screen_y = cy.saturating_sub(app.edit_scroll);
778 +
                            let x = content_inner.x + cx;
779 +
                            let y = content_inner.y + screen_y;
780 +
                            frame.set_cursor_position((x, y));
781 +
                        }
782 +
                        _ => {}
783 +
                    }
784 +
                }
785 +
                _ => {
786 +
                    let highlighted = match app.selected_note() {
787 +
                        Some(n) => app.highlighter.highlight_markdown(&n.content),
788 +
                        None => Text::raw(""),
789 +
                    };
790 +
791 +
                    let paragraph = Paragraph::new(highlighted)
792 +
                        .block(
793 +
                            Block::default()
794 +
                                .title(" Content ")
795 +
                                .borders(Borders::ALL)
796 +
                                .border_style(content_border_style),
797 +
                        )
798 +
                        .scroll((app.content_scroll, 0));
799 +
800 +
                    frame.render_widget(paragraph, chunks[1]);
801 +
                }
802 +
            }
803 +
804 +
            let hints = match app.focus {
805 +
                Focus::List => Line::from(vec![
806 +
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
807 +
                    Span::raw(": Navigate  "),
808 +
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
809 +
                    Span::raw(": View  "),
810 +
                    Span::styled("y", Style::default().fg(Color::Yellow)),
811 +
                    Span::raw(": Copy  "),
812 +
                    Span::styled("e", Style::default().fg(Color::Yellow)),
813 +
                    Span::raw(": Edit  "),
814 +
                    Span::styled("d", Style::default().fg(Color::Yellow)),
815 +
                    Span::raw(": Delete  "),
816 +
                    Span::styled("c", Style::default().fg(Color::Yellow)),
817 +
                    Span::raw(": Create  "),
818 +
                    Span::styled("/", Style::default().fg(Color::Yellow)),
819 +
                    Span::raw(": Search  "),
820 +
                    Span::styled("?", Style::default().fg(Color::Yellow)),
821 +
                    Span::raw(": Help  "),
822 +
                    Span::styled("q", Style::default().fg(Color::Yellow)),
823 +
                    Span::raw(": Quit"),
824 +
                ]),
825 +
                Focus::Content => Line::from(vec![
826 +
                    Span::styled("j/k", Style::default().fg(Color::Yellow)),
827 +
                    Span::raw(": Scroll  "),
828 +
                    Span::styled("y", Style::default().fg(Color::Yellow)),
829 +
                    Span::raw(": Copy  "),
830 +
                    Span::styled("e", Style::default().fg(Color::Yellow)),
831 +
                    Span::raw(": Edit  "),
832 +
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
833 +
                    Span::raw(": Back  "),
834 +
                    Span::styled("?", Style::default().fg(Color::Yellow)),
835 +
                    Span::raw(": Help"),
836 +
                ]),
837 +
                Focus::CreateTitle
838 +
                | Focus::CreateContent
839 +
                | Focus::EditTitle
840 +
                | Focus::EditContent => Line::from(vec![
841 +
                    Span::styled("Tab", Style::default().fg(Color::Yellow)),
842 +
                    Span::raw(": Switch field  "),
843 +
                    Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
844 +
                    Span::raw(": Save  "),
845 +
                    Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)),
846 +
                    Span::raw(": Wrap  "),
847 +
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
848 +
                    Span::raw(": Cancel"),
849 +
                ]),
850 +
                Focus::Search => Line::from(vec![
851 +
                    Span::styled("Type", Style::default().fg(Color::Yellow)),
852 +
                    Span::raw(": Filter  "),
853 +
                    Span::styled("Enter", Style::default().fg(Color::Yellow)),
854 +
                    Span::raw(": Select  "),
855 +
                    Span::styled("Esc", Style::default().fg(Color::Yellow)),
856 +
                    Span::raw(": Cancel"),
857 +
                ]),
858 +
            };
859 +
            frame.render_widget(Paragraph::new(hints), outer[1]);
860 +
861 +
            if let Some((msg, _)) = &app.status_message {
862 +
                let area = frame.area();
863 +
                let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4));
864 +
                let popup_area = ratatui::layout::Rect {
865 +
                    x: (area.width.saturating_sub(msg_width)) / 2,
866 +
                    y: (area.height.saturating_sub(3)) / 2,
867 +
                    width: msg_width,
868 +
                    height: 3,
869 +
                };
870 +
                Clear.render(popup_area, frame.buffer_mut());
871 +
                let status_popup = Paragraph::new(Line::from(msg.as_str()))
872 +
                    .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))
873 +
                    .alignment(Alignment::Center)
874 +
                    .block(
875 +
                        Block::default()
876 +
                            .borders(Borders::ALL)
877 +
                            .border_style(Style::default().fg(Color::Green)),
878 +
                    );
879 +
                frame.render_widget(status_popup, popup_area);
880 +
            }
881 +
882 +
            if app.confirm_delete {
883 +
                let delete_msg = match app.selected_note() {
884 +
                    Some(n) => format!("Delete {}? (y/n)", n.title),
885 +
                    None => "Delete note? (y/n)".to_string(),
886 +
                };
887 +
                let area = frame.area();
888 +
                let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4));
889 +
                let popup_area = ratatui::layout::Rect {
890 +
                    x: (area.width.saturating_sub(msg_width)) / 2,
891 +
                    y: (area.height.saturating_sub(3)) / 2,
892 +
                    width: msg_width,
893 +
                    height: 3,
894 +
                };
895 +
                Clear.render(popup_area, frame.buffer_mut());
896 +
                let confirm_popup = Paragraph::new(Line::from(delete_msg))
897 +
                    .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
898 +
                    .alignment(Alignment::Center)
899 +
                    .block(
900 +
                        Block::default()
901 +
                            .borders(Borders::ALL)
902 +
                            .border_style(Style::default().fg(Color::Red)),
903 +
                    );
904 +
                frame.render_widget(confirm_popup, popup_area);
905 +
            }
906 +
907 +
            if app.show_help {
908 +
                let area = frame.area();
909 +
                let popup_width = 34u16.min(area.width.saturating_sub(4));
910 +
                let popup_height = 21u16.min(area.height.saturating_sub(4));
911 +
                let popup_area = ratatui::layout::Rect {
912 +
                    x: (area.width.saturating_sub(popup_width)) / 2,
913 +
                    y: (area.height.saturating_sub(popup_height)) / 2,
914 +
                    width: popup_width,
915 +
                    height: popup_height,
916 +
                };
917 +
918 +
                let mut help_lines = vec![
919 +
                    Line::from(""),
920 +
                    Line::from(vec![
921 +
                        Span::styled("  j/↓  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
922 +
                        Span::raw("Move down / Scroll down"),
923 +
                    ]),
924 +
                    Line::from(vec![
925 +
                        Span::styled("  k/↑  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
926 +
                        Span::raw("Move up / Scroll up"),
927 +
                    ]),
928 +
                    Line::from(vec![
929 +
                        Span::styled("  Enter", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
930 +
                        Span::raw("  Focus content pane"),
931 +
                    ]),
932 +
                    Line::from(vec![
933 +
                        Span::styled("  Esc  ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
934 +
                        Span::raw("Back / Quit"),
935 +
                    ]),
936 +
                    Line::from(vec![
937 +
                        Span::styled("  y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
938 +
                        Span::raw("Copy note"),
939 +
                    ]),
940 +
                    Line::from(vec![
941 +
                        Span::styled("  Y    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
942 +
                        Span::raw("Copy link"),
943 +
                    ]),
944 +
                    Line::from(vec![
945 +
                        Span::styled("  o    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
946 +
                        Span::raw("Open in browser"),
947 +
                    ]),
948 +
                    Line::from(vec![
949 +
                        Span::styled("  d    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
950 +
                        Span::raw("Delete note"),
951 +
                    ]),
952 +
                    Line::from(vec![
953 +
                        Span::styled("  c    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
954 +
                        Span::raw("Create note"),
955 +
                    ]),
956 +
                    Line::from(vec![
957 +
                        Span::styled("  e    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
958 +
                        Span::raw("Edit note"),
959 +
                    ]),
960 +
                    Line::from(vec![
961 +
                        Span::styled("  E    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
962 +
                        Span::raw("Edit in $EDITOR"),
963 +
                    ]),
964 +
                    Line::from(vec![
965 +
                        Span::styled("  /    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
966 +
                        Span::raw("Search notes"),
967 +
                    ]),
968 +
                    Line::from(vec![
969 +
                        Span::styled("  ^W   ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
970 +
                        Span::raw("Toggle word wrap (edit)"),
971 +
                    ]),
972 +
                ];
973 +
974 +
                if app.is_remote {
975 +
                    help_lines.push(Line::from(vec![
976 +
                        Span::styled("  r    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
977 +
                        Span::raw("Refresh notes"),
978 +
                    ]));
979 +
                }
980 +
981 +
                help_lines.extend([
982 +
                    Line::from(vec![
983 +
                        Span::styled("  q    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
984 +
                        Span::raw("Quit"),
985 +
                    ]),
986 +
                    Line::from(vec![
987 +
                        Span::styled("  ?    ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
988 +
                        Span::raw("Toggle this help"),
989 +
                    ]),
990 +
                    Line::from(""),
991 +
                    Line::from(Span::styled(
992 +
                        "  Press any key to close",
993 +
                        Style::default().fg(Color::DarkGray),
994 +
                    )),
995 +
                ]);
996 +
997 +
                let help_text = Text::from(help_lines);
998 +
999 +
                Clear.render(popup_area, frame.buffer_mut());
1000 +
                let help = Paragraph::new(help_text).block(
1001 +
                    Block::default()
1002 +
                        .title(" Keybindings ")
1003 +
                        .borders(Borders::ALL)
1004 +
                        .border_style(Style::default().fg(Color::Yellow)),
1005 +
                );
1006 +
                frame.render_widget(help, popup_area);
1007 +
            }
1008 +
        })?;
1009 +
1010 +
        if event::poll(Duration::from_millis(100))? {
1011 +
            if let Event::Key(key) = event::read()? {
1012 +
                if app.show_help {
1013 +
                    app.show_help = false;
1014 +
                } else if app.status_message.is_some() {
1015 +
                    app.status_message = None;
1016 +
                } else if app.confirm_delete {
1017 +
                    if key.code == KeyCode::Char('y') {
1018 +
                        app.delete_selected(backend);
1019 +
                    }
1020 +
                    app.confirm_delete = false;
1021 +
                } else {
1022 +
                    match app.focus {
1023 +
                        Focus::List => match key.code {
1024 +
                            KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
1025 +
                            KeyCode::Char('j') | KeyCode::Down => app.move_down(),
1026 +
                            KeyCode::Char('k') | KeyCode::Up => app.move_up(),
1027 +
                            KeyCode::Char('y') => app.copy_selected(),
1028 +
                            KeyCode::Char('Y') => app.copy_link(),
1029 +
                            KeyCode::Char('d') => app.confirm_delete = true,
1030 +
                            KeyCode::Char('c') => app.start_create(),
1031 +
                            KeyCode::Char('e') => app.start_edit(),
1032 +
                            KeyCode::Char('E') => {
1033 +
                                edit_in_external_editor(terminal, &mut app, backend)?
1034 +
                            }
1035 +
                            KeyCode::Char('/') => app.start_search(),
1036 +
                            KeyCode::Char('o') => app.open_in_browser(),
1037 +
                            KeyCode::Char('r') if app.is_remote => app.refresh(backend),
1038 +
                            KeyCode::Char('?') => app.show_help = true,
1039 +
                            KeyCode::Enter | KeyCode::Char('l') => {
1040 +
                                if app.selected_note().is_some() {
1041 +
                                    app.focus = Focus::Content;
1042 +
                                }
1043 +
                            }
1044 +
                            _ => {}
1045 +
                        },
1046 +
                        Focus::Content => match key.code {
1047 +
                            KeyCode::Char(' ')
1048 +
                            | KeyCode::Esc
1049 +
                            | KeyCode::Char('q')
1050 +
                            | KeyCode::Char('h') => {
1051 +
                                app.focus = Focus::List;
1052 +
                            }
1053 +
                            KeyCode::Char('j') | KeyCode::Down => {
1054 +
                                app.scroll_down(content_line_count);
1055 +
                            }
1056 +
                            KeyCode::Char('k') | KeyCode::Up => app.scroll_up(),
1057 +
                            KeyCode::Char('y') => app.copy_selected(),
1058 +
                            KeyCode::Char('Y') => app.copy_link(),
1059 +
                            KeyCode::Char('e') => app.start_edit(),
1060 +
                            KeyCode::Char('E') => {
1061 +
                                edit_in_external_editor(terminal, &mut app, backend)?
1062 +
                            }
1063 +
                            KeyCode::Char('o') => app.open_in_browser(),
1064 +
                            KeyCode::Char('?') => app.show_help = true,
1065 +
                            _ => {}
1066 +
                        },
1067 +
                        Focus::CreateTitle => {
1068 +
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1069 +
                                && key.code == KeyCode::Char('s')
1070 +
                            {
1071 +
                                app.save_create(backend);
1072 +
                            } else {
1073 +
                                match key.code {
1074 +
                                    KeyCode::Esc => app.cancel_create(),
1075 +
                                    KeyCode::Enter | KeyCode::Tab => {
1076 +
                                        app.focus = Focus::CreateContent
1077 +
                                    }
1078 +
                                    KeyCode::Backspace => {
1079 +
                                        app.edit_title.pop();
1080 +
                                    }
1081 +
                                    KeyCode::Char(c) => app.edit_title.push(c),
1082 +
                                    _ => {}
1083 +
                                }
1084 +
                            }
1085 +
                        }
1086 +
                        Focus::CreateContent => {
1087 +
                            if key.modifiers.contains(KeyModifiers::CONTROL) {
1088 +
                                match key.code {
1089 +
                                    KeyCode::Char('s') => app.save_create(backend),
1090 +
                                    KeyCode::Char('w') => {
1091 +
                                        app.wrap_content = !app.wrap_content;
1092 +
                                        app.edit_scroll = 0;
1093 +
                                    }
1094 +
                                    _ => {}
1095 +
                                }
1096 +
                            } else {
1097 +
                                match key.code {
1098 +
                                    KeyCode::Esc => app.cancel_create(),
1099 +
                                    KeyCode::Tab => app.focus = Focus::CreateTitle,
1100 +
                                    KeyCode::Enter => app.edit_content.push('\n'),
1101 +
                                    KeyCode::Backspace => {
1102 +
                                        app.edit_content.pop();
1103 +
                                    }
1104 +
                                    KeyCode::Char(c) => app.edit_content.push(c),
1105 +
                                    _ => {}
1106 +
                                }
1107 +
                            }
1108 +
                        }
1109 +
                        Focus::EditTitle => {
1110 +
                            if key.modifiers.contains(KeyModifiers::CONTROL)
1111 +
                                && key.code == KeyCode::Char('s')
1112 +
                            {
1113 +
                                app.save_edit(backend);
1114 +
                            } else {
1115 +
                                match key.code {
1116 +
                                    KeyCode::Esc => app.cancel_edit(),
1117 +
                                    KeyCode::Enter | KeyCode::Tab => {
1118 +
                                        app.focus = Focus::EditContent
1119 +
                                    }
1120 +
                                    KeyCode::Backspace => {
1121 +
                                        app.edit_title.pop();
1122 +
                                    }
1123 +
                                    KeyCode::Char(c) => app.edit_title.push(c),
1124 +
                                    _ => {}
1125 +
                                }
1126 +
                            }
1127 +
                        }
1128 +
                        Focus::EditContent => {
1129 +
                            if key.modifiers.contains(KeyModifiers::CONTROL) {
1130 +
                                match key.code {
1131 +
                                    KeyCode::Char('s') => app.save_edit(backend),
1132 +
                                    KeyCode::Char('w') => {
1133 +
                                        app.wrap_content = !app.wrap_content;
1134 +
                                        app.edit_scroll = 0;
1135 +
                                    }
1136 +
                                    _ => {}
1137 +
                                }
1138 +
                            } else {
1139 +
                                match key.code {
1140 +
                                    KeyCode::Esc => app.cancel_edit(),
1141 +
                                    KeyCode::Tab => app.focus = Focus::EditTitle,
1142 +
                                    KeyCode::Enter => app.edit_content.push('\n'),
1143 +
                                    KeyCode::Backspace => {
1144 +
                                        app.edit_content.pop();
1145 +
                                    }
1146 +
                                    KeyCode::Char(c) => app.edit_content.push(c),
1147 +
                                    _ => {}
1148 +
                                }
1149 +
                            }
1150 +
                        }
1151 +
                        Focus::Search => match key.code {
1152 +
                            KeyCode::Esc => app.cancel_search(),
1153 +
                            KeyCode::Enter => app.confirm_search(),
1154 +
                            KeyCode::Backspace => {
1155 +
                                app.search_query.pop();
1156 +
                                app.update_search_filter();
1157 +
                            }
1158 +
                            KeyCode::Char(c) => {
1159 +
                                app.search_query.push(c);
1160 +
                                app.update_search_filter();
1161 +
                            }
1162 +
                            _ => {}
1163 +
                        },
1164 +
                    }
1165 +
                }
1166 +
            }
1167 +
        }
1168 +
    }
1169 +
1170 +
    Ok(())
1171 +
}
apps/sipp/src/config.rs +21 −13
1 1
use serde::{Deserialize, Serialize};
2 -
use std::path::PathBuf;
2 +
use std::path::{Path, PathBuf};
3 3
4 4
#[derive(Debug, Default, Serialize, Deserialize)]
5 5
pub struct Config {
9 9
10 10
pub fn config_path() -> PathBuf {
11 11
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
12 -
    PathBuf::from(home).join(".config/sipp/config.toml")
12 +
    config_path_from(Path::new(&home))
13 +
}
14 +
15 +
pub fn config_path_from(home: &Path) -> PathBuf {
16 +
    home.join(".config/sipp/config.toml")
13 17
}
14 18
15 19
pub fn load_config() -> Config {
16 -
    let path = config_path();
17 -
    match std::fs::read_to_string(&path) {
20 +
    load_config_from(&config_path())
21 +
}
22 +
23 +
pub fn load_config_from(path: &Path) -> Config {
24 +
    match std::fs::read_to_string(path) {
18 25
        Ok(contents) => toml::from_str(&contents).unwrap_or_default(),
19 26
        Err(_) => Config::default(),
20 27
    }
21 28
}
22 29
23 30
pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
24 -
    let path = config_path();
31 +
    save_config_to(&config_path(), config)
32 +
}
33 +
34 +
pub fn save_config_to(path: &Path, config: &Config) -> Result<(), Box<dyn std::error::Error>> {
25 35
    if let Some(parent) = path.parent() {
26 36
        std::fs::create_dir_all(parent)?;
27 37
    }
28 38
    let contents = toml::to_string_pretty(config)?;
29 -
    std::fs::write(&path, contents)?;
39 +
    std::fs::write(path, contents)?;
30 40
    Ok(())
31 41
}
32 42
68 78
    #[test]
69 79
    fn load_config_missing_file_returns_default() {
70 80
        let tmp = tempfile::tempdir().unwrap();
71 -
        // SAFETY: test-only, single-threaded test runner for this test
72 -
        unsafe { std::env::set_var("HOME", tmp.path()); }
73 -
        let config = load_config();
81 +
        let path = config_path_from(tmp.path());
82 +
        let config = load_config_from(&path);
74 83
        assert!(config.remote_url.is_none());
75 84
        assert!(config.api_key.is_none());
76 85
    }
78 87
    #[test]
79 88
    fn save_and_load_config_roundtrip() {
80 89
        let tmp = tempfile::tempdir().unwrap();
81 -
        // SAFETY: test-only, single-threaded test runner for this test
82 -
        unsafe { std::env::set_var("HOME", tmp.path()); }
90 +
        let path = config_path_from(tmp.path());
83 91
84 92
        let config = Config {
85 93
            remote_url: Some("https://sipp.example.com".to_string()),
86 94
            api_key: Some("key123".to_string()),
87 95
        };
88 -
        save_config(&config).unwrap();
96 +
        save_config_to(&path, &config).unwrap();
89 97
90 -
        let loaded = load_config();
98 +
        let loaded = load_config_from(&path);
91 99
        assert_eq!(loaded.remote_url, config.remote_url);
92 100
        assert_eq!(loaded.api_key, config.api_key);
93 101
    }
crates/auth/src/lib.rs +51 −0
23 23
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
24 24
}
25 25
26 +
/// Constant-time API key comparison. Same shape as `verify_password`.
27 +
pub fn verify_api_key(input: &str, expected: &str) -> bool {
28 +
    const LEN: usize = 256;
29 +
    let mut a = [0u8; LEN];
30 +
    let mut b = [0u8; LEN];
31 +
    let ib = input.as_bytes();
32 +
    let eb = expected.as_bytes();
33 +
    a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]);
34 +
    b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]);
35 +
    let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8);
36 +
    (lengths_match & a.ct_eq(&b)).into()
37 +
}
38 +
39 +
/// Generate a 32-byte cryptographically random hex API key.
40 +
pub fn generate_api_key() -> String {
41 +
    let mut bytes = [0u8; 32];
42 +
    rand::rngs::OsRng.fill_bytes(&mut bytes);
43 +
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
44 +
}
45 +
26 46
/// Build a session cookie with HttpOnly, SameSite=Strict, 7-day Max-Age.
27 47
pub fn build_session_cookie(token: &str, secure: bool) -> String {
28 48
    let mut cookie = format!(
127 147
        let a = generate_session_token();
128 148
        let b = generate_session_token();
129 149
        assert_ne!(a, b);
150 +
    }
151 +
152 +
    // ── verify_api_key ─────────────────────────────────────────────────
153 +
154 +
    #[test]
155 +
    fn verify_api_key_correct() {
156 +
        assert!(verify_api_key("abc123", "abc123"));
157 +
    }
158 +
159 +
    #[test]
160 +
    fn verify_api_key_wrong() {
161 +
        assert!(!verify_api_key("abc123", "abc124"));
162 +
    }
163 +
164 +
    #[test]
165 +
    fn verify_api_key_length_mismatch() {
166 +
        assert!(!verify_api_key("short", "longer_key"));
167 +
    }
168 +
169 +
    // ── generate_api_key ───────────────────────────────────────────────
170 +
171 +
    #[test]
172 +
    fn api_key_is_64_hex_chars() {
173 +
        let key = generate_api_key();
174 +
        assert_eq!(key.len(), 64);
175 +
        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
176 +
    }
177 +
178 +
    #[test]
179 +
    fn api_key_unique_across_calls() {
180 +
        assert_ne!(generate_api_key(), generate_api_key());
130 181
    }
131 182
132 183
    // ── build_session_cookie ───────────────────────────────────────────
docs/docs/components/Landing.tsx +7 −46
3 3
export function Landing() {
4 4
	return (
5 5
		<main
6 -
			className="landing"
7 -
			style={{
8 -
				position: "relative",
9 -
				width: "100%",
10 -
				background: "#121113 url('/bg.webp') center center / cover no-repeat",
11 -
			}}
6 +
			className="landing relative w-full bg-[#121113] bg-[url('/bg.webp')] bg-cover bg-center bg-no-repeat"
12 7
		>
13 -
			<div
14 -
				style={{
15 -
					display: "flex",
16 -
					width: "100%",
17 -
					minHeight: "100vh",
18 -
					flexDirection: "column",
19 -
					alignItems: "center",
20 -
					justifyContent: "center",
21 -
					gap: "3rem",
22 -
					padding: "1rem",
23 -
				}}
24 -
			>
25 -
				<div
26 -
					style={{
27 -
						display: "flex",
28 -
						flexDirection: "column",
29 -
						alignItems: "center",
30 -
						gap: "1.5rem",
31 -
					}}
32 -
				>
33 -
					<h1
34 -
						style={{
35 -
							textAlign: "center",
36 -
							fontSize: "92px",
37 -
							fontWeight: 400,
38 -
							fontFamily: '"Commit Mono", monospace, sans-serif',
39 -
							color: "#ffffff",
40 -
						}}
41 -
					>
8 +
			<div className="flex w-full min-h-screen flex-col items-center justify-center gap-12 p-4">
9 +
				<div className="flex flex-col items-center gap-6">
10 +
					<h1 className="text-center sm:text-[92px] text-[64px] font-normal font-['Commit_Mono',monospace] text-white">
42 11
						Andromeda
43 12
					</h1>
44 -
					<h3
45 -
						style={{
46 -
							textAlign: "center",
47 -
							fontSize: "16px",
48 -
							fontWeight: 400,
49 -
							fontFamily: '"Commit Mono", monospace, sans-serif',
50 -
							color: "#ffffff",
51 -
						}}
52 -
					>
53 -
						Minimal, self-hosted personal software in Rust
13 +
					<h3 className="text-center text-lg font-normal font-['Commit_Mono',monospace] text-white">
14 +
						Minimal, self-hosted, personal software in Rust
54 15
					</h3>
55 16
				</div>
56 -
				<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
17 +
				<div className="flex items-center gap-4">
57 18
					<Button type="link" href="/quickstart">
58 19
						Get Started
59 20
					</Button>
docs/docs/landing.css +2 −0
1 +
@import "tailwindcss";
2 +
1 3
.vocs_DocsLayout_content:has(.landing) {
2 4
	background: #121113 !important;
3 5
	padding-top: 0 !important;
docs/docs/pages/apps/jotts.mdx +111 −10
8 8
- Password authentication with session cookies
9 9
- Create, edit, and delete markdown notes
10 10
- Markdown rendering with strikethrough, tables, and task lists
11 +
- Interactive TUI with authenticated access for note management
11 12
- Dark themed UI with Commit Mono font
12 13
- SQLite for persistent storage
13 14
15 +
## Install
16 +
17 +
Jotts can be installed several ways:
18 +
19 +
### Homebrew
20 +
21 +
```bash
22 +
brew install stevedylandev/tap/jotts
23 +
```
24 +
25 +
### Releases
26 +
27 +
Visit the [releases](https://github.com/stevedylandev/andromeda/releases?q=jotts&expanded=true) page for binaries and install scripts.
28 +
14 29
## Configure
15 30
16 31
### Environment Variables
17 32
18 33
| Variable | Description | Default |
19 34
|---|---|---|
20 -
| `JOTTS_PASSWORD` | Password for login authentication | `changeme` |
35 +
| `JOTTS_PASSWORD` | Password for web login authentication | `changeme` |
36 +
| `JOTTS_API_KEY` | API key for protecting `/api/*` endpoints | — |
21 37
| `JOTTS_DB_PATH` | SQLite database file path | `jotts.sqlite` |
22 38
| `HOST` | Server bind address | `127.0.0.1` |
23 39
| `PORT` | Server port | `3000` |
24 40
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
25 41
42 +
If `JOTTS_API_KEY` is not set, `/api/*` routes return `403`.
43 +
26 44
## Deploy
27 45
28 46
### Railway
29 47
30 -
The easiest way to deploy Jotts is with the one-click Railway template. See the [Deploying with Railway](/deploy-railway) guide for a walkthrough of the process. Jotts requires `JOTTS_PASSWORD` to be set during the configure step.
48 +
The easiest way to deploy Jotts is with the one-click Railway template. See the [Deploying with Railway](/deploy-railway) guide for a walkthrough of the process. Jotts requires `JOTTS_PASSWORD` to be set during the configure step. Set `JOTTS_API_KEY` too if you want to use the TUI against your remote instance.
31 49
32 50
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DLhUhH?referralCode=JGcIp6)
33 51
44 62
45 63
### Binary
46 64
47 -
Install with Homebrew:
65 +
```bash
66 +
cargo build --release -p jotts
67 +
```
68 +
69 +
The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly.
70 +
71 +
## Use
72 +
73 +
### CLI
74 +
75 +
```
76 +
jotts [OPTIONS] [FILE] [COMMAND]
77 +
```
78 +
79 +
#### Commands
80 +
81 +
| Command | Description |
82 +
|---|---|
83 +
| `server` | Start the web server |
84 +
| `tui` | Launch the interactive TUI |
85 +
| `auth` | Save remote URL and API key to config file |
86 +
87 +
#### Arguments
88 +
89 +
| Argument | Description |
90 +
|---|---|
91 +
| `[FILE]` | File path to create a note from |
92 +
93 +
#### Options
94 +
95 +
| Option | Description |
96 +
|---|---|
97 +
| `-r, --remote <URL>` | Remote server URL (env: `JOTTS_REMOTE_URL`) |
98 +
| `-k, --api-key <KEY>` | API key for authenticated operations (env: `JOTTS_API_KEY`) |
99 +
100 +
### Server
101 +
102 +
Start the web server with:
48 103
49 104
```bash
50 -
brew install stevedylandev/tap/jotts
105 +
jotts server --port 3000 --host localhost
51 106
```
52 107
53 -
Or grab a prebuilt binary from the [releases page](https://github.com/stevedylandev/andromeda/releases?q=jotts&expanded=true).
108 +
Log in at `/login` with your configured password. From there you can create, edit, and delete markdown notes. Notes support GitHub-flavored markdown including strikethrough, tables, and task lists.
54 109
55 -
You can also build from source:
110 +
#### API Endpoints
111 +
112 +
| Method | Endpoint | Description |
113 +
|---|---|---|
114 +
| `GET` | `/api/notes` | List all notes |
115 +
| `POST` | `/api/notes` | Create a note (`{"title": "...", "content": "..."}`) |
116 +
| `GET` | `/api/notes/{short_id}` | Get a note by ID |
117 +
| `PUT` | `/api/notes/{short_id}` | Update a note |
118 +
| `DELETE` | `/api/notes/{short_id}` | Delete a note by ID |
119 +
120 +
All `/api/*` endpoints require an `x-api-key` header matching `JOTTS_API_KEY`.
121 +
122 +
### TUI
123 +
124 +
The Jotts TUI makes it easy to create, edit, and manage your notes either locally or remotely.
56 125
57 126
```bash
58 -
cargo build --release -p jotts
127 +
# Launch TUI (default behavior when no file argument is given)
128 +
jotts
129 +
130 +
# Or explicitly
131 +
jotts tui
132 +
133 +
# With remote options
134 +
jotts -r https://your-jotts-instance.com -k your-api-key
59 135
```
60 136
61 -
The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly.
137 +
#### Local Access
62 138
63 -
## Use
139 +
If you are running `jotts` in the same directory as the `jotts.sqlite` file created by the server instance, the TUI will automatically access the database locally.
140 +
141 +
#### Remote Access
142 +
143 +
To access a remote instance:
144 +
- Set `JOTTS_API_KEY` on your server instance
145 +
- Run `jotts auth` to enter your server URL and API key, stored under `$HOME/.config/jotts`
64 146
65 -
Log in at `/login` with your configured password. From there you can create, edit, and delete markdown notes. Notes support GitHub-flavored markdown including strikethrough, tables, and task lists.
147 +
#### Keybindings
148 +
149 +
| Key | Action |
150 +
|---|---|
151 +
| `j`/`Down` | Move down / Scroll down |
152 +
| `k`/`Up` | Move up / Scroll up |
153 +
| `Enter` | Focus content pane |
154 +
| `Esc` | Back / Quit |
155 +
| `y` | Copy note content |
156 +
| `Y` | Copy note link |
157 +
| `o` | Open in browser |
158 +
| `e` | Edit note |
159 +
| `E` | Edit in `$EDITOR` |
160 +
| `d` | Delete note |
161 +
| `c` | Create note |
162 +
| `/` | Search notes |
163 +
| `^W` | Toggle word wrap (edit) |
164 +
| `r` | Refresh notes (remote only) |
165 +
| `q` | Quit |
166 +
| `?` | Toggle help |