Skip to main content

bookmarks_webapp/
lib.rs

1//! Embedded HTMX webapp for bookmarks
2
3use axum::Router;
4use axum::extract::{Path, Query, State};
5use axum::response::Html;
6use axum::routing::{get, post};
7use std::net::SocketAddr;
8use std::sync::{Arc, Mutex};
9
10use bookmarks_core::config::{Config, UrlEntry};
11use bookmarks_core::storage::Storage;
12use bookmarks_core::strings;
13
14struct AppState {
15    storage: Mutex<Box<dyn Storage>>,
16}
17
18impl AppState {
19    fn lock_storage(&self) -> std::sync::MutexGuard<'_, Box<dyn Storage>> {
20        self.storage.lock().unwrap_or_else(|e| e.into_inner())
21    }
22
23    fn load_config(&self) -> Config {
24        self.lock_storage().load().unwrap_or_default()
25    }
26
27    fn save_config(&self, config: &Config) -> Result<(), String> {
28        self.lock_storage().save(config).map_err(|e| e.to_string())
29    }
30}
31
32fn escape(s: &str) -> String {
33    s.replace('&', "&amp;")
34        .replace('<', "&lt;")
35        .replace('>', "&gt;")
36        .replace('"', "&quot;")
37}
38
39fn escape_js(s: &str) -> String {
40    s.replace('\\', "\\\\")
41        .replace('\'', "\\'")
42        .replace('"', "&quot;")
43        .replace('<', "\\x3c")
44        .replace('>', "\\x3e")
45}
46
47// -- HTML rendering ----------------------------------------------------------
48
49fn page(body: &str) -> String {
50    let project_url = strings::PROJECT_URL;
51    format!(
52        r##"<!DOCTYPE html>
53<html lang="en">
54<head>
55  <meta charset="utf-8">
56  <meta name="viewport" content="width=device-width, initial-scale=1">
57  <title>bookmarks</title>
58  <script src="https://unpkg.com/htmx.org@2.0.4"></script>
59  <style>
60    * {{ margin: 0; padding: 0; box-sizing: border-box; }}
61    html {{ background: #1a1a29; }}
62    body {{ font-family: system-ui, -apple-system, sans-serif; background: #1a1a29; color: #8c8ca6; width: 640px; margin: 0 auto; padding: 32px 0; }}
63    h1 {{ font-size: 1.4rem; color: #8c8ca6; margin-bottom: 8px; font-weight: 500; }}
64    .subtitle {{ font-size: 0.85rem; color: #8c8ca6; margin-bottom: 24px; }}
65    .subtitle a {{ color: #bf4dff; text-decoration: none; }}
66    .subtitle a:hover {{ text-decoration: underline; }}
67    h2 {{ font-size: 1rem; color: #8c8ca6; margin-bottom: 12px; text-transform: lowercase; }}
68    .section {{ margin-bottom: 28px; }}
69    table {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
70    col.col-check {{ width: 28px; }}
71    col.col-name {{ width: 130px; }}
72    col.col-value {{ }}
73    col.col-actions {{ width: 70px; }}
74    th {{ text-align: left; font-size: 0.75rem; color: #666680; text-transform: uppercase; letter-spacing: 0.05em; padding: 6px 8px; border-bottom: 1px solid #2e2e47; }}
75    th.sortable {{ cursor: pointer; user-select: none; }}
76    th.sortable:hover {{ color: #8c8ca6; }}
77    th.active {{ color: #bf4dff; }}
78    td {{ padding: 6px 8px; border-bottom: 1px solid #242438; font-size: 0.85rem; vertical-align: top; overflow: hidden; text-overflow: ellipsis; }}
79    td.check {{ text-align: center; overflow: visible; }}
80    td.check input {{ cursor: pointer; accent-color: #bf4dff; }}
81    th.check {{ text-align: center; overflow: visible; }}
82    th.check input {{ cursor: pointer; accent-color: #bf4dff; }}
83    td.name {{ color: #bf4dff; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
84    td.name a {{ color: #bf4dff; text-decoration: none; }}
85    td.name a:hover {{ text-decoration: underline; }}
86    td.url a {{ color: #22d3ee; text-decoration: none; word-break: break-all; }}
87    td.url a:hover {{ text-decoration: underline; color: #67e8f9; }}
88    td.aliases {{ color: #a640f2; font-size: 0.8rem; }}
89    td.entries a {{ color: #a640f2; text-decoration: none; }}
90    td.entries a:hover {{ text-decoration: underline; color: #bf4dff; }}
91    .actions {{ text-align: right; white-space: nowrap; }}
92    .btn {{ background: none; border: 1px solid #2e2e47; color: #8c8ca6; padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }}
93    .btn:hover {{ border-color: #666680; color: #edeedf; }}
94    .btn-danger {{ border-color: #5c2a2a; color: #ff7373; }}
95    .btn-danger:hover {{ border-color: #ff7373; color: #ffa0a0; }}
96    .btn-add {{ background: #242438; border-color: #2e2e47; color: #bf4dff; white-space: nowrap; width: 72px; text-align: center; flex-shrink: 0; }}
97    .btn-add:hover {{ background: #2e2e47; border-color: #666680; }}
98    .bulk-bar {{ display: none; align-items: center; gap: 8px; margin-bottom: 12px; padding: 8px 12px; background: #242438; border: 1px solid #2e2e47; border-radius: 6px; }}
99    .bulk-bar.visible {{ display: flex; }}
100    .bulk-bar .bulk-count {{ font-size: 0.8rem; color: #bf4dff; }}
101    .bulk-bar .btn {{ font-size: 0.75rem; }}
102    form.inline {{ display: flex; gap: 6px; align-items: center; margin-top: 6px; }}
103    form.inline input {{ background: #242438; border: 1px solid #2e2e47; color: #edeedf; padding: 5px 8px; border-radius: 4px; font-size: 0.8rem; min-width: 0; }}
104    form.inline input:first-of-type {{ flex: 2; }}
105    form.inline input:nth-of-type(2) {{ flex: 3; }}
106    form.inline input::placeholder {{ color: #666680; }}
107    form.inline input:focus {{ outline: none; border-color: #bf4dff; }}
108    .copy-btn {{ background: none; border: none; color: #666680; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; vertical-align: middle; }}
109    .copy-btn:hover {{ color: #8c8ca6; }}
110    .copy-btn.copied {{ color: #4ade80; }}
111    td.url {{ }}
112    td.url .url-cell {{ display: flex; align-items: center; gap: 6px; }}
113    .error-banner {{ background: #3a1a2a; border: 1px solid #5c2a2a; color: #ff7373; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; font-size: 0.8rem; cursor: pointer; }}
114    .editable {{ cursor: pointer; }}
115    .editable:hover {{ background: #2e2e47; border-radius: 3px; }}
116    .edit-input {{ background: #242438; border: 1px solid #bf4dff; color: #edeedf; padding: 3px 6px; border-radius: 3px; font-size: 0.8rem; width: 100%; font-family: inherit; }}
117    .edit-input:focus {{ outline: none; }}
118    .empty {{ color: #666680; font-style: italic; font-size: 0.85rem; padding: 12px 0; }}
119    .toolbar {{ display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }}
120    .toolbar input {{ background: #242438; border: 1px solid #2e2e47; color: #edeedf; padding: 5px 8px; border-radius: 4px; font-size: 0.8rem; width: 200px; }}
121    .toolbar input::placeholder {{ color: #666680; }}
122    .toolbar input:focus {{ outline: none; border-color: #bf4dff; }}
123    .tabs {{ display: flex; gap: 4px; flex-shrink: 0; }}
124    .tab {{ background: none; border: 1px solid #2e2e47; color: #8c8ca6; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }}
125    .tab:hover {{ color: #edeedf; border-color: #666680; }}
126    .tab.active {{ color: #bf4dff; border-color: #bf4dff; background: #382952; }}
127    .counts {{ font-size: 0.7rem; color: #666680; margin-left: 3px; }}
128    /* confirm modal */
129    .modal-overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }}
130    .modal-overlay.visible {{ display: flex; }}
131    .modal {{ background: #141421; border: 1px solid #2e2e47; border-radius: 8px; padding: 24px; max-width: 400px; width: 90%; }}
132    .modal h3 {{ color: #edeedf; font-size: 1rem; margin-bottom: 8px; }}
133    .modal p {{ color: #8c8ca6; font-size: 0.85rem; margin-bottom: 16px; line-height: 1.4; }}
134    .modal .modal-actions {{ display: flex; gap: 8px; justify-content: flex-end; }}
135    .modal .btn-cancel {{ border-color: #2e2e47; color: #8c8ca6; padding: 6px 16px; font-size: 0.8rem; }}
136    .modal .btn-cancel:hover {{ border-color: #666680; color: #edeedf; }}
137    .modal .btn-confirm {{ background: #3a1a2a; border-color: #ff7373; color: #ff7373; padding: 6px 16px; font-size: 0.8rem; }}
138    .modal .btn-confirm:hover {{ background: #4a2030; border-color: #ffa0a0; color: #ffa0a0; }}
139    @media (max-width: 680px) {{
140      body {{ width: auto; padding: 24px 16px; }}
141      .toolbar {{ flex-wrap: wrap; }}
142      .toolbar input {{ width: 100%; }}
143      .tabs {{ width: 100%; }}
144      .tab {{ flex: 1; text-align: center; }}
145      form.inline {{ flex-wrap: wrap; }}
146      form.inline input:first-of-type {{ flex: 1 1 100%; }}
147      form.inline input:nth-of-type(2) {{ flex: 1 1 auto; }}
148      .btn-add {{ flex-shrink: 0; }}
149      col.col-name {{ width: 100px; }}
150      col.col-actions {{ width: 60px; }}
151    }}
152  </style>
153</head>
154<body>
155  <h1>Bookmarks</h1>
156  <p class="subtitle"><a href="{project_url}" target="_blank" rel="noopener">bookmarks</a> in your filesystem</p>
157  <div id="content">
158    {body}
159  </div>
160
161  <!-- confirm modal -->
162  <div class="modal-overlay" id="confirm-modal">
163    <div class="modal">
164      <h3 id="confirm-title">confirm delete</h3>
165      <p id="confirm-message"></p>
166      <div class="modal-actions">
167        <button class="btn btn-cancel" onclick="closeModal()">cancel</button>
168        <button class="btn btn-confirm" id="confirm-btn">delete</button>
169      </div>
170    </div>
171  </div>
172
173  <script>
174    // -- confirm modal ---
175    var pendingAction = null;
176    function confirmDelete(title, message, action) {{
177      document.getElementById('confirm-title').textContent = title;
178      document.getElementById('confirm-message').textContent = message;
179      document.getElementById('confirm-modal').classList.add('visible');
180      pendingAction = action;
181      // wire up confirm button
182      var btn = document.getElementById('confirm-btn');
183      btn.onclick = function() {{
184        var action = pendingAction;
185        closeModal();
186        if (action) action();
187      }};
188    }}
189    function closeModal() {{
190      document.getElementById('confirm-modal').classList.remove('visible');
191      pendingAction = null;
192    }}
193    // close on escape or clicking overlay
194    document.getElementById('confirm-modal').addEventListener('click', function(e) {{
195      if (e.target === this) closeModal();
196    }});
197    document.addEventListener('keydown', function(e) {{
198      if (e.key === 'Escape') closeModal();
199    }});
200
201    // -- open all group URLs ---
202    function openGroup(urls) {{
203      urls.forEach(function(u) {{ window.open(u, '_blank', 'noopener'); }});
204    }}
205
206    // -- copy to clipboard ---
207    function copyUrl(btn, text) {{
208      navigator.clipboard.writeText(text).then(function() {{
209        btn.classList.add('copied');
210        btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
211        setTimeout(function() {{
212          btn.classList.remove('copied');
213          btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
214        }}, 1500);
215      }});
216    }}
217
218    // -- inline edit ---
219    function startEdit(type, name, field, currentValue) {{
220      var cell = event.target.closest('td');
221      if (cell.querySelector('.edit-input')) return; // already editing
222      var original = cell.innerHTML;
223      var done = false;
224      var input = document.createElement('input');
225      input.className = 'edit-input';
226      input.value = currentValue;
227      function finish(save) {{
228        if (done) return;
229        done = true;
230        if (save && input.value.trim() && input.value !== currentValue) {{
231          submitEdit(type, name, field, input.value.trim(), cell, original);
232        }} else {{
233          cell.innerHTML = original;
234        }}
235      }}
236      input.addEventListener('keydown', function(e) {{
237        if (e.key === 'Enter') {{ e.preventDefault(); finish(true); }}
238        if (e.key === 'Escape') {{ finish(false); }}
239      }});
240      input.addEventListener('blur', function() {{ finish(true); }});
241      cell.innerHTML = '';
242      cell.appendChild(input);
243      input.focus();
244      input.select();
245    }}
246    function submitEdit(type, name, field, value, cell, original) {{
247      var params = new URLSearchParams();
248      if (field === 'name') params.append('new_name', value);
249      if (field === 'url') params.append('new_url', value);
250      if (field === 'aliases') params.append('new_aliases', value);
251      if (field === 'entries') params.append('new_entries', value);
252      fetch('/edit/' + type + '/' + encodeURIComponent(name), {{method: 'POST', headers: {{'Content-Type': 'application/x-www-form-urlencoded'}}, body: params.toString()}})
253        .then(function(r) {{ if (!r.ok) throw new Error(r.statusText); return r.text(); }})
254        .then(function(html) {{ document.getElementById('content').innerHTML = html; }})
255        .catch(function() {{ cell.innerHTML = original; }});
256    }}
257
258    // -- single delete via modal ---
259    function deleteSingle(type, name) {{
260      confirmDelete(
261        'delete ' + type,
262        'are you sure you want to delete ' + type + ' "' + name + '"? this cannot be undone.',
263        function() {{
264          htmx.ajax('POST', '/delete/' + type + '/' + encodeURIComponent(name), {{target: '#content', swap: 'innerHTML'}});
265        }}
266      );
267    }}
268
269    // -- checkbox selection ---
270    function updateBulkBar() {{
271      var checked = document.querySelectorAll('input.row-check:checked');
272      var bar = document.getElementById('bulk-bar');
273      var count = document.getElementById('bulk-count');
274      if (checked.length > 0) {{
275        bar.classList.add('visible');
276        count.textContent = checked.length + ' selected';
277      }} else {{
278        bar.classList.remove('visible');
279      }}
280    }}
281    function toggleAll(src) {{
282      var boxes = document.querySelectorAll('input.row-check');
283      // only toggle visible rows
284      boxes.forEach(function(cb) {{
285        if (cb.closest('tr').style.display !== 'none') cb.checked = src.checked;
286      }});
287      updateBulkBar();
288    }}
289    function deleteSelected() {{
290      var checked = document.querySelectorAll('input.row-check:checked');
291      if (checked.length === 0) return;
292      var items = [];
293      checked.forEach(function(cb) {{ items.push(cb.dataset.type + ' "' + cb.dataset.name + '"'); }});
294      var count = checked.length;
295      confirmDelete(
296        'delete ' + count + ' item' + (count > 1 ? 's' : ''),
297        'are you sure you want to delete: ' + items.join(', ') + '? this cannot be undone.',
298        function() {{
299          // collect names grouped by type
300          var toDelete = [];
301          checked.forEach(function(cb) {{
302            toDelete.push({{type: cb.dataset.type, name: cb.dataset.name}});
303          }});
304          // delete sequentially, refresh at end
305          var i = 0;
306          function next() {{
307            if (i >= toDelete.length) {{
308              htmx.ajax('GET', '/content', {{target: '#content', swap: 'innerHTML'}});
309              return;
310            }}
311            var item = toDelete[i++];
312            fetch('/delete/' + item.type + '/' + encodeURIComponent(item.name), {{method: 'POST'}}).then(next);
313          }}
314          next();
315        }}
316      );
317    }}
318
319    // -- filter ---
320    function filterRows() {{
321      var q = document.getElementById('search').value.toLowerCase();
322      document.querySelectorAll('table tr[data-filter]').forEach(function(row) {{
323        row.style.display = row.getAttribute('data-filter').toLowerCase().includes(q) ? '' : 'none';
324      }});
325    }}
326    function showTab(tab) {{
327      ['urls','groups'].forEach(function(t) {{
328        var el = document.getElementById('section-' + t);
329        var btn = document.getElementById('tab-' + t);
330        if (el) el.style.display = (t === tab || tab === 'all') ? '' : 'none';
331        if (btn) btn.classList.toggle('active', t === tab);
332      }});
333      var allBtn = document.getElementById('tab-all');
334      if (allBtn) allBtn.classList.toggle('active', tab === 'all');
335      filterRows();
336    }}
337
338    // re-bind checkboxes after htmx swaps
339    document.body.addEventListener('htmx:afterSwap', function() {{
340      updateBulkBar();
341      // uncheck select-all headers
342      document.querySelectorAll('.select-all').forEach(function(cb) {{ cb.checked = false; }});
343    }});
344  </script>
345</body>
346</html>"##
347    )
348}
349
350fn resolve_url<'a>(name: &str, config: &'a Config) -> Option<&'a str> {
351    bookmarks_core::open::resolve_uri(name, config).ok()
352}
353
354fn linked_name(name: &str, url: &str) -> String {
355    let n = escape(name);
356    let u = escape(url);
357    format!(r##"<a href="{u}" target="_blank" rel="noopener" title="{u}">{n}</a>"##)
358}
359
360fn copy_btn(url: &str) -> String {
361    let uj = escape_js(url);
362    format!(
363        r##"<button class="copy-btn" onclick="copyUrl(this,'{uj}')" title="copy to clipboard"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>"##
364    )
365}
366
367fn url_row(name: &str, entry: &UrlEntry) -> String {
368    let n = escape(name);
369    let nj = escape_js(name);
370    let url = entry.url();
371    let u = escape(url);
372    let uj = escape_js(url);
373    let name_link = linked_name(name, url);
374    let copy = copy_btn(url);
375    let aliases = entry.aliases();
376    let aliases_raw = aliases.join(", ");
377    let aliases_raw_js = escape_js(&aliases_raw);
378    let aliases_html = if aliases.is_empty() {
379        r#"<span class="editable" style="color:#666680;font-size:0.8rem;font-style:italic">+ aliases</span>"#.to_string()
380    } else {
381        let escaped: Vec<String> = aliases.iter().map(|a| escape(a)).collect();
382        format!(
383            r#"<span style="color:#a640f2;font-size:0.8rem">{}</span>"#,
384            escaped.join(", ")
385        )
386    };
387    format!(
388        r##"<tr data-filter="{n} {u} {aliases_filter}">
389  <td class="check"><input type="checkbox" class="row-check" data-type="url" data-name="{n}" onchange="updateBulkBar()"></td>
390  <td class="name editable" ondblclick="startEdit('url','{nj}','name','{nj}')">{name_link}</td>
391  <td class="url editable" ondblclick="startEdit('url','{nj}','url','{uj}')"><span class="url-cell">{copy}<a href="{u}" target="_blank" rel="noopener">{u}</a></span></td>
392  <td class="aliases editable" ondblclick="startEdit('url','{nj}','aliases','{aliases_raw_js}')">{aliases_html}</td>
393  <td class="actions">
394    <button class="btn btn-danger" onclick="deleteSingle('url','{nj}')">delete</button>
395  </td>
396</tr>"##,
397        aliases_filter = escape(&aliases.join(" ")),
398    )
399}
400
401fn group_row(name: &str, entries: &[String], config: &Config) -> String {
402    let n = escape(name);
403    let nj = escape_js(name);
404    // Collect resolved URLs for the "open all" action
405    let urls: Vec<String> = entries
406        .iter()
407        .filter_map(|entry| resolve_url(entry, config).map(escape_js))
408        .collect();
409    let urls_arr: String = urls
410        .iter()
411        .map(|u| format!("'{u}'"))
412        .collect::<Vec<_>>()
413        .join(",");
414
415    let entry_links: Vec<String> = entries
416        .iter()
417        .map(|entry| {
418            let e = escape(entry);
419            if let Some(url) = resolve_url(entry, config) {
420                let u = escape(url);
421                format!(r##"<a href="{u}" target="_blank" rel="noopener" title="{u}">{e}</a>"##)
422            } else {
423                e
424            }
425        })
426        .collect();
427    let entries_html = entry_links.join(", ");
428    let filter_str: String = entries
429        .iter()
430        .map(|e| escape(e))
431        .collect::<Vec<_>>()
432        .join(", ");
433    let name_cell = if urls.is_empty() {
434        n.clone()
435    } else {
436        format!(
437            r##"<a href="#" onclick="openGroup([{urls_arr}]);return false;" title="open all {count} urls">{n}</a>"##,
438            count = urls.len()
439        )
440    };
441    let entries_raw_js = escape_js(&entries.join(", "));
442    format!(
443        r##"<tr data-filter="{n} {filter_str}">
444  <td class="check"><input type="checkbox" class="row-check" data-type="group" data-name="{n}" onchange="updateBulkBar()"></td>
445  <td class="name editable" ondblclick="startEdit('group','{nj}','name','{nj}')">{name_cell}</td>
446  <td class="entries editable" ondblclick="startEdit('group','{nj}','entries','{entries_raw_js}')">{entries_html}</td>
447  <td class="actions">
448    <button class="btn btn-danger" onclick="deleteSingle('group','{nj}')">delete</button>
449  </td>
450</tr>"##
451    )
452}
453
454#[derive(Debug, Clone, Copy, PartialEq)]
455enum SortField {
456    Name,
457    Url,
458}
459
460fn render_content(config: &Config, sort: SortField, error: Option<&str>) -> String {
461    let mut urls: Vec<_> = config.urls.iter().collect();
462    let mut groups: Vec<_> = config.groups.iter().collect();
463
464    match sort {
465        SortField::Name => {
466            urls.sort_by_key(|(k, _)| k.as_str());
467            groups.sort_by_key(|(k, _)| k.as_str());
468        }
469        SortField::Url => {
470            urls.sort_by_key(|(_, v)| v.url());
471            groups.sort_by_key(|(k, _)| k.as_str());
472        }
473    }
474
475    let name_cls = if sort == SortField::Name {
476        " active"
477    } else {
478        ""
479    };
480    let url_cls = if sort == SortField::Url {
481        " active"
482    } else {
483        ""
484    };
485
486    let mut html = String::new();
487
488    // Toolbar: search + tab filter
489    html.push_str(&format!(
490        r##"<div class="toolbar">
491  <input id="search" type="text" placeholder="{ph_filter}" oninput="filterRows()" autocomplete="off">
492  <div class="tabs">
493    <button id="tab-all" class="tab active" onclick="showTab('all')">all</button>
494    <button id="tab-urls" class="tab" onclick="showTab('urls')">urls<span class="counts">{uc}</span></button>
495    <button id="tab-groups" class="tab" onclick="showTab('groups')">groups<span class="counts">{gc}</span></button>
496  </div>
497</div>"##,
498        ph_filter = strings::PH_FILTER,
499        uc = urls.len(),
500        gc = groups.len(),
501    ));
502
503    // Error banner
504    if let Some(msg) = error {
505        let m = escape(msg);
506        html.push_str(&format!(
507            r##"<div class="error-banner" onclick="this.remove()">{m} <span style="margin-left:8px;cursor:pointer;opacity:0.6">✕</span></div>"##
508        ));
509    }
510
511    // Bulk action bar (hidden until selection)
512    html.push_str(
513        r##"<div class="bulk-bar" id="bulk-bar">
514  <span class="bulk-count" id="bulk-count">0 selected</span>
515  <button class="btn btn-danger" onclick="deleteSelected()">delete selected</button>
516  <button class="btn" onclick="document.querySelectorAll('input.row-check').forEach(function(c){{c.checked=false}});updateBulkBar()">clear</button>
517</div>"##,
518    );
519
520    // Add forms at the top
521    html.push_str(&format!(
522        r##"<div class="section">
523<form class="inline" hx-post="/add/url" hx-target="#content">
524  <input name="name" placeholder="{ph_url_name}" required>
525  <input name="url" placeholder="{ph_url}" required>
526  <button class="btn btn-add" type="submit">+ url</button>
527</form>
528<form class="inline" hx-post="/add/group" hx-target="#content">
529  <input name="name" placeholder="{ph_group_name}" required>
530  <input name="entries" placeholder="{ph_group_entries}" required>
531  <button class="btn btn-add" type="submit">+ group</button>
532</form>
533</div>"##,
534        ph_url_name = strings::PH_URL_NAME,
535        ph_url = strings::PH_URL,
536        ph_group_name = strings::PH_GROUP_NAME,
537        ph_group_entries = strings::PH_GROUP_ENTRIES,
538    ));
539
540    // Urls section
541    html.push_str(r##"<div class="section" id="section-urls"><h2>urls</h2>"##);
542    if urls.is_empty() {
543        html.push_str(r#"<p class="empty">no urls yet</p>"#);
544    } else {
545        html.push_str(&format!(
546            r##"<table><colgroup><col class="col-check"><col class="col-name"><col class="col-value"><col style="width:120px"><col class="col-actions"></colgroup><tr><th class="check"><input type="checkbox" class="select-all" onchange="toggleAll(this)"></th><th class="sortable{name_cls}" hx-get="/content?sort=name" hx-target="#content">name</th><th class="sortable{url_cls}" hx-get="/content?sort=url" hx-target="#content">url</th><th>aliases</th><th></th></tr>"##,
547        ));
548        for (name, entry) in &urls {
549            html.push_str(&url_row(name, entry));
550        }
551        html.push_str("</table>");
552    }
553    html.push_str("</div>");
554
555    // Groups section
556    html.push_str(r##"<div class="section" id="section-groups"><h2>groups</h2>"##);
557    if groups.is_empty() {
558        html.push_str(r#"<p class="empty">no groups yet</p>"#);
559    } else {
560        html.push_str(&format!(
561            r##"<table><colgroup><col class="col-check"><col class="col-name"><col class="col-value"><col class="col-actions"></colgroup><tr><th class="check"><input type="checkbox" class="select-all" onchange="toggleAll(this)"></th><th class="sortable{name_cls}" hx-get="/content?sort=name" hx-target="#content">group</th><th>entries</th><th></th></tr>"##,
562        ));
563        for (name, entries) in &groups {
564            html.push_str(&group_row(name, entries, config));
565        }
566        html.push_str("</table>");
567    }
568    html.push_str("</div>");
569
570    html
571}
572
573// -- Handlers ----------------------------------------------------------------
574
575type S = State<Arc<AppState>>;
576type Form = axum::extract::Form<std::collections::HashMap<String, String>>;
577
578#[derive(Debug, serde::Deserialize, Default)]
579struct ContentQuery {
580    #[serde(default)]
581    sort: Option<String>,
582}
583
584fn parse_sort(q: &ContentQuery) -> SortField {
585    match q.sort.as_deref() {
586        Some("url") => SortField::Url,
587        _ => SortField::Name,
588    }
589}
590
591async fn index(State(state): S, q: Query<ContentQuery>) -> Html<String> {
592    Html(page(&render_content(
593        &state.load_config(),
594        parse_sort(&q),
595        None,
596    )))
597}
598
599async fn content(State(state): S, q: Query<ContentQuery>) -> Html<String> {
600    Html(render_content(&state.load_config(), parse_sort(&q), None))
601}
602
603fn content_ok(state: &Arc<AppState>) -> Html<String> {
604    Html(render_content(&state.load_config(), SortField::Name, None))
605}
606
607fn content_err(state: &Arc<AppState>, msg: &str) -> Html<String> {
608    Html(render_content(
609        &state.load_config(),
610        SortField::Name,
611        Some(msg),
612    ))
613}
614
615fn save_or_err(state: &Arc<AppState>, config: &Config) -> Html<String> {
616    match state.save_config(config) {
617        Ok(()) => content_ok(state),
618        Err(e) => content_err(state, &format!("failed to save: {e}")),
619    }
620}
621
622async fn add_url(State(state): S, axum::extract::Form(form): Form) -> Html<String> {
623    let name = form.get("name").cloned().unwrap_or_default();
624    let url = form.get("url").cloned().unwrap_or_default();
625    if !name.is_empty() && !url.is_empty() {
626        let mut config = state.load_config();
627        config.urls.insert(name, UrlEntry::Simple(url));
628        return save_or_err(&state, &config);
629    }
630    content_ok(&state)
631}
632
633async fn add_group(State(state): S, axum::extract::Form(form): Form) -> Html<String> {
634    let name = form.get("name").cloned().unwrap_or_default();
635    let entries_raw = form.get("entries").cloned().unwrap_or_default();
636    if !name.is_empty() && !entries_raw.is_empty() {
637        let entries: Vec<String> = entries_raw
638            .split(',')
639            .map(|s| s.trim().to_string())
640            .filter(|s| !s.is_empty())
641            .collect();
642        if !entries.is_empty() {
643            let config = state.load_config();
644            let missing: Vec<&str> = entries
645                .iter()
646                .filter(|e| !config.contains(e))
647                .map(String::as_str)
648                .collect();
649            if !missing.is_empty() {
650                return content_err(&state, &strings::err_group_entries_missing(&missing));
651            }
652            let mut config = config;
653            config.groups.insert(name, entries);
654            return save_or_err(&state, &config);
655        }
656    }
657    content_ok(&state)
658}
659
660async fn delete_url(State(state): S, Path(name): Path<String>) -> Html<String> {
661    let mut config = state.load_config();
662    if let Err(e) = config.delete_url(&name) {
663        return content_err(&state, &e.to_string());
664    }
665    save_or_err(&state, &config)
666}
667
668async fn delete_group(State(state): S, Path(name): Path<String>) -> Html<String> {
669    let mut config = state.load_config();
670    if let Err(e) = config.delete_group(&name) {
671        return content_err(&state, &e.to_string());
672    }
673    save_or_err(&state, &config)
674}
675
676// -- Edit handlers -----------------------------------------------------------
677
678async fn edit_url(
679    State(state): S,
680    Path(name): Path<String>,
681    axum::extract::Form(form): Form,
682) -> Html<String> {
683    let mut config = state.load_config();
684    let new_name = form.get("new_name").filter(|s| !s.is_empty());
685    let new_url = form.get("new_url").filter(|s| !s.is_empty());
686
687    // Rename first (can fail), then update value on the (possibly new) key
688    let key = if let Some(new_name) = new_name
689        && new_name != &name
690    {
691        if let Err(e) = config.rename_url(&name, new_name) {
692            return content_err(&state, &e.to_string());
693        }
694        new_name.clone()
695    } else {
696        name
697    };
698
699    if let Some(new_url) = new_url
700        && let Some(entry) = config.urls.get_mut(&key)
701    {
702        entry.set_url(new_url.clone());
703    }
704
705    // Update aliases if provided
706    if let Some(new_aliases) = form.get("new_aliases") {
707        let aliases: Vec<String> = new_aliases
708            .split(',')
709            .map(|s| s.trim().to_string())
710            .filter(|s| !s.is_empty())
711            .collect();
712        if let Some(entry) = config.urls.get_mut(&key) {
713            match entry {
714                UrlEntry::Simple(url) => {
715                    if !aliases.is_empty() {
716                        *entry = UrlEntry::Full {
717                            url: url.clone(),
718                            aliases,
719                        };
720                    }
721                }
722                UrlEntry::Full {
723                    aliases: existing, ..
724                } => {
725                    *existing = aliases;
726                }
727            }
728        }
729    }
730
731    save_or_err(&state, &config)
732}
733
734async fn edit_group(
735    State(state): S,
736    Path(name): Path<String>,
737    axum::extract::Form(form): Form,
738) -> Html<String> {
739    let mut config = state.load_config();
740    let new_name = form.get("new_name").filter(|s| !s.is_empty());
741    let new_entries = form.get("new_entries").filter(|s| !s.is_empty());
742
743    // Parse and validate entries before any mutation
744    let parsed_entries = if let Some(new_entries) = new_entries {
745        let entries: Vec<String> = new_entries
746            .split(',')
747            .map(|s| s.trim().to_string())
748            .filter(|s| !s.is_empty())
749            .collect();
750        let missing: Vec<&str> = entries
751            .iter()
752            .filter(|e| !config.contains(e))
753            .map(String::as_str)
754            .collect();
755        if !missing.is_empty() {
756            return content_err(&state, &strings::err_group_entries_missing(&missing));
757        }
758        Some(entries)
759    } else {
760        None
761    };
762
763    // Rename first, then update entries on the (possibly new) key
764    let key = if let Some(new_name) = new_name
765        && new_name != &name
766    {
767        if let Err(e) = config.rename_group(&name, new_name) {
768            return content_err(&state, &e.to_string());
769        }
770        new_name.clone()
771    } else {
772        name
773    };
774
775    if let Some(entries) = parsed_entries
776        && let Some(existing) = config.groups.get_mut(&key)
777    {
778        *existing = entries;
779    }
780
781    save_or_err(&state, &config)
782}
783
784// -- Server ------------------------------------------------------------------
785
786fn create_router(storage: Box<dyn Storage>) -> Router {
787    let state = Arc::new(AppState {
788        storage: Mutex::new(storage),
789    });
790
791    Router::new()
792        .route("/", get(index))
793        .route("/content", get(content))
794        .route("/add/url", post(add_url))
795        .route("/add/group", post(add_group))
796        .route("/delete/url/{name}", post(delete_url))
797        .route("/delete/group/{name}", post(delete_group))
798        .route("/edit/url/{name}", post(edit_url))
799        .route("/edit/group/{name}", post(edit_group))
800        .with_state(state)
801}
802
803pub fn run_webapp(storage: Box<dyn Storage>) -> anyhow::Result<()> {
804    let rt = tokio::runtime::Runtime::new()?;
805    rt.block_on(async {
806        let port: u16 = 1414;
807        let app = create_router(storage);
808        let addr = SocketAddr::from(([127, 0, 0, 1], port));
809
810        println!("bookmarks webapp: http://localhost:{port}");
811        let _ = open::that(format!("http://localhost:{port}"));
812
813        let listener = tokio::net::TcpListener::bind(addr).await?;
814        axum::serve(listener, app)
815            .with_graceful_shutdown(async {
816                tokio::signal::ctrl_c()
817                    .await
818                    .expect("failed to listen for ctrl+c");
819                println!("\nshutting down...");
820            })
821            .await?;
822        Ok(())
823    })
824}