1use 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('&', "&")
34 .replace('<', "<")
35 .replace('>', ">")
36 .replace('"', """)
37}
38
39fn escape_js(s: &str) -> String {
40 s.replace('\\', "\\\\")
41 .replace('\'', "\\'")
42 .replace('"', """)
43 .replace('<', "\\x3c")
44 .replace('>', "\\x3e")
45}
46
47fn 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) {{ body {{ width: auto; padding: 24px 16px; }} }}
140 </style>
141</head>
142<body>
143 <h1>Bookmarks</h1>
144 <p class="subtitle"><a href="{project_url}" target="_blank" rel="noopener">bookmarks</a> in your filesystem</p>
145 <div id="content">
146 {body}
147 </div>
148
149 <!-- confirm modal -->
150 <div class="modal-overlay" id="confirm-modal">
151 <div class="modal">
152 <h3 id="confirm-title">confirm delete</h3>
153 <p id="confirm-message"></p>
154 <div class="modal-actions">
155 <button class="btn btn-cancel" onclick="closeModal()">cancel</button>
156 <button class="btn btn-confirm" id="confirm-btn">delete</button>
157 </div>
158 </div>
159 </div>
160
161 <script>
162 // -- confirm modal ---
163 var pendingAction = null;
164 function confirmDelete(title, message, action) {{
165 document.getElementById('confirm-title').textContent = title;
166 document.getElementById('confirm-message').textContent = message;
167 document.getElementById('confirm-modal').classList.add('visible');
168 pendingAction = action;
169 // wire up confirm button
170 var btn = document.getElementById('confirm-btn');
171 btn.onclick = function() {{
172 var action = pendingAction;
173 closeModal();
174 if (action) action();
175 }};
176 }}
177 function closeModal() {{
178 document.getElementById('confirm-modal').classList.remove('visible');
179 pendingAction = null;
180 }}
181 // close on escape or clicking overlay
182 document.getElementById('confirm-modal').addEventListener('click', function(e) {{
183 if (e.target === this) closeModal();
184 }});
185 document.addEventListener('keydown', function(e) {{
186 if (e.key === 'Escape') closeModal();
187 }});
188
189 // -- open all group URLs ---
190 function openGroup(urls) {{
191 urls.forEach(function(u) {{ window.open(u, '_blank', 'noopener'); }});
192 }}
193
194 // -- copy to clipboard ---
195 function copyUrl(btn, text) {{
196 navigator.clipboard.writeText(text).then(function() {{
197 btn.classList.add('copied');
198 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>';
199 setTimeout(function() {{
200 btn.classList.remove('copied');
201 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>';
202 }}, 1500);
203 }});
204 }}
205
206 // -- inline edit ---
207 function startEdit(type, name, field, currentValue) {{
208 var cell = event.target.closest('td');
209 if (cell.querySelector('.edit-input')) return; // already editing
210 var original = cell.innerHTML;
211 var done = false;
212 var input = document.createElement('input');
213 input.className = 'edit-input';
214 input.value = currentValue;
215 function finish(save) {{
216 if (done) return;
217 done = true;
218 if (save && input.value.trim() && input.value !== currentValue) {{
219 submitEdit(type, name, field, input.value.trim(), cell, original);
220 }} else {{
221 cell.innerHTML = original;
222 }}
223 }}
224 input.addEventListener('keydown', function(e) {{
225 if (e.key === 'Enter') {{ e.preventDefault(); finish(true); }}
226 if (e.key === 'Escape') {{ finish(false); }}
227 }});
228 input.addEventListener('blur', function() {{ finish(true); }});
229 cell.innerHTML = '';
230 cell.appendChild(input);
231 input.focus();
232 input.select();
233 }}
234 function submitEdit(type, name, field, value, cell, original) {{
235 var params = new URLSearchParams();
236 if (field === 'name') params.append('new_name', value);
237 if (field === 'url') params.append('new_url', value);
238 if (field === 'aliases') params.append('new_aliases', value);
239 if (field === 'entries') params.append('new_entries', value);
240 fetch('/edit/' + type + '/' + encodeURIComponent(name), {{method: 'POST', headers: {{'Content-Type': 'application/x-www-form-urlencoded'}}, body: params.toString()}})
241 .then(function(r) {{ if (!r.ok) throw new Error(r.statusText); return r.text(); }})
242 .then(function(html) {{ document.getElementById('content').innerHTML = html; }})
243 .catch(function() {{ cell.innerHTML = original; }});
244 }}
245
246 // -- single delete via modal ---
247 function deleteSingle(type, name) {{
248 confirmDelete(
249 'delete ' + type,
250 'are you sure you want to delete ' + type + ' "' + name + '"? this cannot be undone.',
251 function() {{
252 htmx.ajax('POST', '/delete/' + type + '/' + encodeURIComponent(name), {{target: '#content', swap: 'innerHTML'}});
253 }}
254 );
255 }}
256
257 // -- checkbox selection ---
258 function updateBulkBar() {{
259 var checked = document.querySelectorAll('input.row-check:checked');
260 var bar = document.getElementById('bulk-bar');
261 var count = document.getElementById('bulk-count');
262 if (checked.length > 0) {{
263 bar.classList.add('visible');
264 count.textContent = checked.length + ' selected';
265 }} else {{
266 bar.classList.remove('visible');
267 }}
268 }}
269 function toggleAll(src) {{
270 var boxes = document.querySelectorAll('input.row-check');
271 // only toggle visible rows
272 boxes.forEach(function(cb) {{
273 if (cb.closest('tr').style.display !== 'none') cb.checked = src.checked;
274 }});
275 updateBulkBar();
276 }}
277 function deleteSelected() {{
278 var checked = document.querySelectorAll('input.row-check:checked');
279 if (checked.length === 0) return;
280 var items = [];
281 checked.forEach(function(cb) {{ items.push(cb.dataset.type + ' "' + cb.dataset.name + '"'); }});
282 var count = checked.length;
283 confirmDelete(
284 'delete ' + count + ' item' + (count > 1 ? 's' : ''),
285 'are you sure you want to delete: ' + items.join(', ') + '? this cannot be undone.',
286 function() {{
287 // collect names grouped by type
288 var toDelete = [];
289 checked.forEach(function(cb) {{
290 toDelete.push({{type: cb.dataset.type, name: cb.dataset.name}});
291 }});
292 // delete sequentially, refresh at end
293 var i = 0;
294 function next() {{
295 if (i >= toDelete.length) {{
296 htmx.ajax('GET', '/content', {{target: '#content', swap: 'innerHTML'}});
297 return;
298 }}
299 var item = toDelete[i++];
300 fetch('/delete/' + item.type + '/' + encodeURIComponent(item.name), {{method: 'POST'}}).then(next);
301 }}
302 next();
303 }}
304 );
305 }}
306
307 // -- filter ---
308 function filterRows() {{
309 var q = document.getElementById('search').value.toLowerCase();
310 document.querySelectorAll('table tr[data-filter]').forEach(function(row) {{
311 row.style.display = row.getAttribute('data-filter').toLowerCase().includes(q) ? '' : 'none';
312 }});
313 }}
314 function showTab(tab) {{
315 ['urls','groups'].forEach(function(t) {{
316 var el = document.getElementById('section-' + t);
317 var btn = document.getElementById('tab-' + t);
318 if (el) el.style.display = (t === tab || tab === 'all') ? '' : 'none';
319 if (btn) btn.classList.toggle('active', t === tab);
320 }});
321 var allBtn = document.getElementById('tab-all');
322 if (allBtn) allBtn.classList.toggle('active', tab === 'all');
323 filterRows();
324 }}
325
326 // re-bind checkboxes after htmx swaps
327 document.body.addEventListener('htmx:afterSwap', function() {{
328 updateBulkBar();
329 // uncheck select-all headers
330 document.querySelectorAll('.select-all').forEach(function(cb) {{ cb.checked = false; }});
331 }});
332 </script>
333</body>
334</html>"##
335 )
336}
337
338fn resolve_url<'a>(name: &str, config: &'a Config) -> Option<&'a str> {
339 bookmarks_core::open::resolve_uri(name, config).ok()
340}
341
342fn linked_name(name: &str, url: &str) -> String {
343 let n = escape(name);
344 let u = escape(url);
345 format!(r##"<a href="{u}" target="_blank" rel="noopener" title="{u}">{n}</a>"##)
346}
347
348fn copy_btn(url: &str) -> String {
349 let uj = escape_js(url);
350 format!(
351 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>"##
352 )
353}
354
355fn url_row(name: &str, entry: &UrlEntry) -> String {
356 let n = escape(name);
357 let nj = escape_js(name);
358 let url = entry.url();
359 let u = escape(url);
360 let uj = escape_js(url);
361 let name_link = linked_name(name, url);
362 let copy = copy_btn(url);
363 let aliases = entry.aliases();
364 let aliases_raw = aliases.join(", ");
365 let aliases_raw_js = escape_js(&aliases_raw);
366 let aliases_html = if aliases.is_empty() {
367 r#"<span class="editable" style="color:#666680;font-size:0.8rem;font-style:italic">+ aliases</span>"#.to_string()
368 } else {
369 let escaped: Vec<String> = aliases.iter().map(|a| escape(a)).collect();
370 format!(
371 r#"<span style="color:#a640f2;font-size:0.8rem">{}</span>"#,
372 escaped.join(", ")
373 )
374 };
375 format!(
376 r##"<tr data-filter="{n} {u} {aliases_filter}">
377 <td class="check"><input type="checkbox" class="row-check" data-type="url" data-name="{n}" onchange="updateBulkBar()"></td>
378 <td class="name editable" ondblclick="startEdit('url','{nj}','name','{nj}')">{name_link}</td>
379 <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>
380 <td class="aliases editable" ondblclick="startEdit('url','{nj}','aliases','{aliases_raw_js}')">{aliases_html}</td>
381 <td class="actions">
382 <button class="btn btn-danger" onclick="deleteSingle('url','{nj}')">delete</button>
383 </td>
384</tr>"##,
385 aliases_filter = escape(&aliases.join(" ")),
386 )
387}
388
389fn group_row(name: &str, entries: &[String], config: &Config) -> String {
390 let n = escape(name);
391 let nj = escape_js(name);
392 let urls: Vec<String> = entries
394 .iter()
395 .filter_map(|entry| resolve_url(entry, config).map(escape_js))
396 .collect();
397 let urls_arr: String = urls
398 .iter()
399 .map(|u| format!("'{u}'"))
400 .collect::<Vec<_>>()
401 .join(",");
402
403 let entry_links: Vec<String> = entries
404 .iter()
405 .map(|entry| {
406 let e = escape(entry);
407 if let Some(url) = resolve_url(entry, config) {
408 let u = escape(url);
409 format!(r##"<a href="{u}" target="_blank" rel="noopener" title="{u}">{e}</a>"##)
410 } else {
411 e
412 }
413 })
414 .collect();
415 let entries_html = entry_links.join(", ");
416 let filter_str: String = entries
417 .iter()
418 .map(|e| escape(e))
419 .collect::<Vec<_>>()
420 .join(", ");
421 let name_cell = if urls.is_empty() {
422 n.clone()
423 } else {
424 format!(
425 r##"<a href="#" onclick="openGroup([{urls_arr}]);return false;" title="open all {count} urls">{n}</a>"##,
426 count = urls.len()
427 )
428 };
429 let entries_raw_js = escape_js(&entries.join(", "));
430 format!(
431 r##"<tr data-filter="{n} {filter_str}">
432 <td class="check"><input type="checkbox" class="row-check" data-type="group" data-name="{n}" onchange="updateBulkBar()"></td>
433 <td class="name editable" ondblclick="startEdit('group','{nj}','name','{nj}')">{name_cell}</td>
434 <td class="entries editable" ondblclick="startEdit('group','{nj}','entries','{entries_raw_js}')">{entries_html}</td>
435 <td class="actions">
436 <button class="btn btn-danger" onclick="deleteSingle('group','{nj}')">delete</button>
437 </td>
438</tr>"##
439 )
440}
441
442#[derive(Debug, Clone, Copy, PartialEq)]
443enum SortField {
444 Name,
445 Url,
446}
447
448fn render_content(config: &Config, sort: SortField, error: Option<&str>) -> String {
449 let mut urls: Vec<_> = config.urls.iter().collect();
450 let mut groups: Vec<_> = config.groups.iter().collect();
451
452 match sort {
453 SortField::Name => {
454 urls.sort_by_key(|(k, _)| k.as_str());
455 groups.sort_by_key(|(k, _)| k.as_str());
456 }
457 SortField::Url => {
458 urls.sort_by_key(|(_, v)| v.url());
459 groups.sort_by_key(|(k, _)| k.as_str());
460 }
461 }
462
463 let name_cls = if sort == SortField::Name {
464 " active"
465 } else {
466 ""
467 };
468 let url_cls = if sort == SortField::Url {
469 " active"
470 } else {
471 ""
472 };
473
474 let mut html = String::new();
475
476 html.push_str(&format!(
478 r##"<div class="toolbar">
479 <input id="search" type="text" placeholder="{ph_filter}" oninput="filterRows()" autocomplete="off">
480 <div class="tabs">
481 <button id="tab-all" class="tab active" onclick="showTab('all')">all</button>
482 <button id="tab-urls" class="tab" onclick="showTab('urls')">urls<span class="counts">{uc}</span></button>
483 <button id="tab-groups" class="tab" onclick="showTab('groups')">groups<span class="counts">{gc}</span></button>
484 </div>
485</div>"##,
486 ph_filter = strings::PH_FILTER,
487 uc = urls.len(),
488 gc = groups.len(),
489 ));
490
491 if let Some(msg) = error {
493 let m = escape(msg);
494 html.push_str(&format!(
495 r##"<div class="error-banner" onclick="this.remove()">{m} <span style="margin-left:8px;cursor:pointer;opacity:0.6">✕</span></div>"##
496 ));
497 }
498
499 html.push_str(
501 r##"<div class="bulk-bar" id="bulk-bar">
502 <span class="bulk-count" id="bulk-count">0 selected</span>
503 <button class="btn btn-danger" onclick="deleteSelected()">delete selected</button>
504 <button class="btn" onclick="document.querySelectorAll('input.row-check').forEach(function(c){{c.checked=false}});updateBulkBar()">clear</button>
505</div>"##,
506 );
507
508 html.push_str(&format!(
510 r##"<div class="section">
511<form class="inline" hx-post="/add/url" hx-target="#content">
512 <input name="name" placeholder="{ph_url_name}" required>
513 <input name="url" placeholder="{ph_url}" required>
514 <button class="btn btn-add" type="submit">+ url</button>
515</form>
516<form class="inline" hx-post="/add/group" hx-target="#content">
517 <input name="name" placeholder="{ph_group_name}" required>
518 <input name="entries" placeholder="{ph_group_entries}" required>
519 <button class="btn btn-add" type="submit">+ group</button>
520</form>
521</div>"##,
522 ph_url_name = strings::PH_URL_NAME,
523 ph_url = strings::PH_URL,
524 ph_group_name = strings::PH_GROUP_NAME,
525 ph_group_entries = strings::PH_GROUP_ENTRIES,
526 ));
527
528 html.push_str(r##"<div class="section" id="section-urls"><h2>urls</h2>"##);
530 if urls.is_empty() {
531 html.push_str(r#"<p class="empty">no urls yet</p>"#);
532 } else {
533 html.push_str(&format!(
534 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>"##,
535 ));
536 for (name, entry) in &urls {
537 html.push_str(&url_row(name, entry));
538 }
539 html.push_str("</table>");
540 }
541 html.push_str("</div>");
542
543 html.push_str(r##"<div class="section" id="section-groups"><h2>groups</h2>"##);
545 if groups.is_empty() {
546 html.push_str(r#"<p class="empty">no groups yet</p>"#);
547 } else {
548 html.push_str(&format!(
549 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>"##,
550 ));
551 for (name, entries) in &groups {
552 html.push_str(&group_row(name, entries, config));
553 }
554 html.push_str("</table>");
555 }
556 html.push_str("</div>");
557
558 html
559}
560
561type S = State<Arc<AppState>>;
564type Form = axum::extract::Form<std::collections::HashMap<String, String>>;
565
566#[derive(Debug, serde::Deserialize, Default)]
567struct ContentQuery {
568 #[serde(default)]
569 sort: Option<String>,
570}
571
572fn parse_sort(q: &ContentQuery) -> SortField {
573 match q.sort.as_deref() {
574 Some("url") => SortField::Url,
575 _ => SortField::Name,
576 }
577}
578
579async fn index(State(state): S, q: Query<ContentQuery>) -> Html<String> {
580 Html(page(&render_content(
581 &state.load_config(),
582 parse_sort(&q),
583 None,
584 )))
585}
586
587async fn content(State(state): S, q: Query<ContentQuery>) -> Html<String> {
588 Html(render_content(&state.load_config(), parse_sort(&q), None))
589}
590
591fn content_ok(state: &Arc<AppState>) -> Html<String> {
592 Html(render_content(&state.load_config(), SortField::Name, None))
593}
594
595fn content_err(state: &Arc<AppState>, msg: &str) -> Html<String> {
596 Html(render_content(
597 &state.load_config(),
598 SortField::Name,
599 Some(msg),
600 ))
601}
602
603fn save_or_err(state: &Arc<AppState>, config: &Config) -> Html<String> {
604 match state.save_config(config) {
605 Ok(()) => content_ok(state),
606 Err(e) => content_err(state, &format!("failed to save: {e}")),
607 }
608}
609
610async fn add_url(State(state): S, axum::extract::Form(form): Form) -> Html<String> {
611 let name = form.get("name").cloned().unwrap_or_default();
612 let url = form.get("url").cloned().unwrap_or_default();
613 if !name.is_empty() && !url.is_empty() {
614 let mut config = state.load_config();
615 config.urls.insert(name, UrlEntry::Simple(url));
616 return save_or_err(&state, &config);
617 }
618 content_ok(&state)
619}
620
621async fn add_group(State(state): S, axum::extract::Form(form): Form) -> Html<String> {
622 let name = form.get("name").cloned().unwrap_or_default();
623 let entries_raw = form.get("entries").cloned().unwrap_or_default();
624 if !name.is_empty() && !entries_raw.is_empty() {
625 let entries: Vec<String> = entries_raw
626 .split(',')
627 .map(|s| s.trim().to_string())
628 .filter(|s| !s.is_empty())
629 .collect();
630 if !entries.is_empty() {
631 let config = state.load_config();
632 let missing: Vec<&str> = entries
633 .iter()
634 .filter(|e| !config.contains(e))
635 .map(String::as_str)
636 .collect();
637 if !missing.is_empty() {
638 return content_err(&state, &strings::err_group_entries_missing(&missing));
639 }
640 let mut config = config;
641 config.groups.insert(name, entries);
642 return save_or_err(&state, &config);
643 }
644 }
645 content_ok(&state)
646}
647
648async fn delete_url(State(state): S, Path(name): Path<String>) -> Html<String> {
649 let mut config = state.load_config();
650 if let Err(e) = config.delete_url(&name) {
651 return content_err(&state, &e.to_string());
652 }
653 save_or_err(&state, &config)
654}
655
656async fn delete_group(State(state): S, Path(name): Path<String>) -> Html<String> {
657 let mut config = state.load_config();
658 if let Err(e) = config.delete_group(&name) {
659 return content_err(&state, &e.to_string());
660 }
661 save_or_err(&state, &config)
662}
663
664async fn edit_url(
667 State(state): S,
668 Path(name): Path<String>,
669 axum::extract::Form(form): Form,
670) -> Html<String> {
671 let mut config = state.load_config();
672 let new_name = form.get("new_name").filter(|s| !s.is_empty());
673 let new_url = form.get("new_url").filter(|s| !s.is_empty());
674
675 let key = if let Some(new_name) = new_name
677 && new_name != &name
678 {
679 if let Err(e) = config.rename_url(&name, new_name) {
680 return content_err(&state, &e.to_string());
681 }
682 new_name.clone()
683 } else {
684 name
685 };
686
687 if let Some(new_url) = new_url
688 && let Some(entry) = config.urls.get_mut(&key)
689 {
690 entry.set_url(new_url.clone());
691 }
692
693 if let Some(new_aliases) = form.get("new_aliases") {
695 let aliases: Vec<String> = new_aliases
696 .split(',')
697 .map(|s| s.trim().to_string())
698 .filter(|s| !s.is_empty())
699 .collect();
700 if let Some(entry) = config.urls.get_mut(&key) {
701 match entry {
702 UrlEntry::Simple(url) => {
703 if !aliases.is_empty() {
704 *entry = UrlEntry::Full {
705 url: url.clone(),
706 aliases,
707 };
708 }
709 }
710 UrlEntry::Full {
711 aliases: existing, ..
712 } => {
713 *existing = aliases;
714 }
715 }
716 }
717 }
718
719 save_or_err(&state, &config)
720}
721
722async fn edit_group(
723 State(state): S,
724 Path(name): Path<String>,
725 axum::extract::Form(form): Form,
726) -> Html<String> {
727 let mut config = state.load_config();
728 let new_name = form.get("new_name").filter(|s| !s.is_empty());
729 let new_entries = form.get("new_entries").filter(|s| !s.is_empty());
730
731 let parsed_entries = if let Some(new_entries) = new_entries {
733 let entries: Vec<String> = new_entries
734 .split(',')
735 .map(|s| s.trim().to_string())
736 .filter(|s| !s.is_empty())
737 .collect();
738 let missing: Vec<&str> = entries
739 .iter()
740 .filter(|e| !config.contains(e))
741 .map(String::as_str)
742 .collect();
743 if !missing.is_empty() {
744 return content_err(&state, &strings::err_group_entries_missing(&missing));
745 }
746 Some(entries)
747 } else {
748 None
749 };
750
751 let key = if let Some(new_name) = new_name
753 && new_name != &name
754 {
755 if let Err(e) = config.rename_group(&name, new_name) {
756 return content_err(&state, &e.to_string());
757 }
758 new_name.clone()
759 } else {
760 name
761 };
762
763 if let Some(entries) = parsed_entries
764 && let Some(existing) = config.groups.get_mut(&key)
765 {
766 *existing = entries;
767 }
768
769 save_or_err(&state, &config)
770}
771
772fn create_router(storage: Box<dyn Storage>) -> Router {
775 let state = Arc::new(AppState {
776 storage: Mutex::new(storage),
777 });
778
779 Router::new()
780 .route("/", get(index))
781 .route("/content", get(content))
782 .route("/add/url", post(add_url))
783 .route("/add/group", post(add_group))
784 .route("/delete/url/{name}", post(delete_url))
785 .route("/delete/group/{name}", post(delete_group))
786 .route("/edit/url/{name}", post(edit_url))
787 .route("/edit/group/{name}", post(edit_group))
788 .with_state(state)
789}
790
791pub fn run_webapp(storage: Box<dyn Storage>) -> anyhow::Result<()> {
792 let rt = tokio::runtime::Runtime::new()?;
793 rt.block_on(async {
794 let port: u16 = 1414;
795 let app = create_router(storage);
796 let addr = SocketAddr::from(([127, 0, 0, 1], port));
797
798 println!("bookmarks webapp: http://localhost:{port}");
799 let _ = open::that(format!("http://localhost:{port}"));
800
801 let listener = tokio::net::TcpListener::bind(addr).await?;
802 axum::serve(listener, app)
803 .with_graceful_shutdown(async {
804 tokio::signal::ctrl_c()
805 .await
806 .expect("failed to listen for ctrl+c");
807 println!("\nshutting down...");
808 })
809 .await?;
810 Ok(())
811 })
812}