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