Skip to content

Commit 03fba85

Browse files
Add bookmarks app
Vanilla JS port of webmarks — save, tag, search, and organize bookmarks with remoteStorage. Compatible data format.
1 parent 2bffd16 commit 03fba85

3 files changed

Lines changed: 401 additions & 0 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ Simple apps that sync via the [remoteStorage](https://remotestorage.io) protocol
77
### [notes/](notes/)
88
Quick notes. Add, view, and delete plain-text notes synced to your storage.
99

10+
### [bookmarks/](bookmarks/)
11+
Save, tag, search, and organize your bookmarks. Compatible with the [webmarks](https://github.com/raucao/webmarks) data format.
12+
1013
### [editor/](editor/)
1114
Minimal text editor with sidebar document list, auto-save, and keyboard-friendly workflow.
1215

bookmarks/index.html

Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Bookmarks</title>
7+
<script src="https://cdn.jsdelivr.net/npm/remotestoragejs@1.2.3/release/remotestorage.min.js"></script>
8+
<script src="https://cdn.jsdelivr.net/npm/remotestorage-widget@1.4.0/build/widget.js"></script>
9+
<style>
10+
* { box-sizing: border-box; margin: 0; padding: 0; }
11+
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: #f5f5f5; max-width: 720px; margin: 0 auto; padding: 1rem; }
12+
13+
header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
14+
header h1 { font-size: 1.5rem; }
15+
.spacer { flex: 1; }
16+
17+
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
18+
.toolbar input { flex: 1; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; }
19+
.toolbar input:focus { outline: none; border-color: #4a9eff; box-shadow: 0 0 0 2px rgba(74,158,255,0.2); }
20+
.toolbar button { padding: 0.5rem 1rem; background: #4a9eff; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.95rem; font-weight: 500; white-space: nowrap; }
21+
.toolbar button:hover { background: #3a8eef; }
22+
.toolbar button:disabled { background: #ccc; cursor: default; }
23+
24+
.tags-bar { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; }
25+
.tag-filter { padding: 0.25rem 0.6rem; background: #e8e8e8; border: none; border-radius: 12px; font-size: 0.8rem; cursor: pointer; color: #555; }
26+
.tag-filter:hover { background: #ddd; }
27+
.tag-filter.active { background: #4a9eff; color: white; }
28+
29+
/* Add/Edit form */
30+
.form-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 10; }
31+
.form-overlay.open { display: flex; align-items: center; justify-content: center; }
32+
.form-card { background: white; border-radius: 12px; padding: 1.5rem; width: 90%; max-width: 480px; box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
33+
.form-card h2 { font-size: 1.1rem; margin-bottom: 1rem; }
34+
.form-card label { display: block; font-size: 0.8rem; font-weight: 500; color: #555; margin-bottom: 0.2rem; }
35+
.form-card input, .form-card textarea { width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.95rem; margin-bottom: 0.75rem; font-family: inherit; }
36+
.form-card input:focus, .form-card textarea:focus { outline: none; border-color: #4a9eff; }
37+
.form-card textarea { resize: vertical; min-height: 60px; }
38+
.form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }
39+
.form-actions button { padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
40+
.btn-save { background: #4a9eff; color: white; }
41+
.btn-save:hover { background: #3a8eef; }
42+
.btn-cancel { background: #e8e8e8; color: #333; }
43+
.btn-cancel:hover { background: #ddd; }
44+
45+
/* Bookmark list */
46+
#bookmarks { list-style: none; }
47+
#bookmarks li { background: white; margin-bottom: 0.5rem; padding: 0.8rem 1rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
48+
.bm-top { display: flex; align-items: baseline; gap: 0.5rem; }
49+
.bm-title { font-weight: 500; color: #1a0dab; text-decoration: none; word-break: break-word; }
50+
.bm-title:hover { text-decoration: underline; }
51+
.bm-domain { font-size: 0.75rem; color: #006621; flex-shrink: 0; }
52+
.bm-desc { font-size: 0.85rem; color: #666; margin-top: 0.3rem; line-height: 1.4; }
53+
.bm-bottom { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.4rem; flex-wrap: wrap; }
54+
.bm-tag { font-size: 0.7rem; padding: 0.15rem 0.45rem; background: #f0f0f0; border-radius: 3px; color: #888; }
55+
.bm-date { font-size: 0.7rem; color: #bbb; margin-left: auto; }
56+
.bm-actions { display: flex; gap: 0.3rem; margin-left: 0.5rem; }
57+
.bm-actions button { background: none; border: none; color: #ccc; cursor: pointer; font-size: 0.85rem; padding: 0 0.2rem; }
58+
.bm-actions button:hover { color: #666; }
59+
.bm-actions .del:hover { color: #dc3545; }
60+
61+
.empty { color: #999; text-align: center; padding: 3rem 1rem; }
62+
.count { font-size: 0.8rem; color: #999; margin-bottom: 0.75rem; }
63+
</style>
64+
</head>
65+
<body>
66+
<header>
67+
<h1>Bookmarks</h1>
68+
<div id="rs-widget"></div>
69+
<div class="spacer"></div>
70+
</header>
71+
72+
<div class="toolbar">
73+
<input type="text" id="search" placeholder="Search bookmarks..." disabled>
74+
<button id="add-btn" disabled>+ Add</button>
75+
</div>
76+
77+
<div class="tags-bar" id="tags-bar"></div>
78+
<div class="count" id="count"></div>
79+
<ul id="bookmarks"><li class="empty">Connect to your storage to start</li></ul>
80+
81+
<!-- Add/Edit modal -->
82+
<div class="form-overlay" id="form-overlay">
83+
<div class="form-card">
84+
<h2 id="form-title">Add Bookmark</h2>
85+
<label for="f-url">URL</label>
86+
<input type="url" id="f-url" placeholder="https://...">
87+
<label for="f-name">Title</label>
88+
<input type="text" id="f-name" placeholder="Page title">
89+
<label for="f-desc">Description (optional)</label>
90+
<textarea id="f-desc" placeholder="Notes about this bookmark..."></textarea>
91+
<label for="f-tags">Tags (comma-separated)</label>
92+
<input type="text" id="f-tags" placeholder="dev, reference, tools">
93+
<div class="form-actions">
94+
<button class="btn-cancel" id="form-cancel">Cancel</button>
95+
<button class="btn-save" id="form-save">Save</button>
96+
</div>
97+
</div>
98+
</div>
99+
100+
<script>
101+
// --- remoteStorage setup ---
102+
const rs = new RemoteStorage()
103+
rs.access.claim('bookmarks', 'rw')
104+
rs.caching.enable('/bookmarks/')
105+
106+
const widget = new Widget(rs)
107+
widget.attach('rs-widget')
108+
109+
const client = rs.scope('/bookmarks/archive/')
110+
111+
// --- DOM refs ---
112+
const searchInput = document.getElementById('search')
113+
const addBtn = document.getElementById('add-btn')
114+
const bookmarksList = document.getElementById('bookmarks')
115+
const tagsBar = document.getElementById('tags-bar')
116+
const countEl = document.getElementById('count')
117+
const overlay = document.getElementById('form-overlay')
118+
const formTitle = document.getElementById('form-title')
119+
const fUrl = document.getElementById('f-url')
120+
const fName = document.getElementById('f-name')
121+
const fDesc = document.getElementById('f-desc')
122+
const fTags = document.getElementById('f-tags')
123+
const formSave = document.getElementById('form-save')
124+
const formCancel = document.getElementById('form-cancel')
125+
126+
let allBookmarks = [] // { id, url, title, description, tags, createdAt }
127+
let activeTag = null
128+
let editingId = null
129+
130+
// --- RS events ---
131+
rs.on('connected', () => {
132+
searchInput.disabled = false
133+
addBtn.disabled = false
134+
loadBookmarks()
135+
})
136+
137+
rs.on('disconnected', () => {
138+
searchInput.disabled = true
139+
addBtn.disabled = true
140+
allBookmarks = []
141+
activeTag = null
142+
render()
143+
})
144+
145+
// --- Data ---
146+
function urlToId (url) {
147+
// Same scheme as webmarks: base64-ish of URL
148+
return btoa(url).replace(/\//g, '-').replace(/\+/g, '_').replace(/=+$/, '')
149+
}
150+
151+
async function loadBookmarks () {
152+
const listing = await client.getListing('')
153+
allBookmarks = []
154+
155+
if (listing) {
156+
const files = Object.keys(listing).filter(k => !k.endsWith('/'))
157+
for (const name of files) {
158+
try {
159+
const file = await client.getFile(name)
160+
if (file && file.data) {
161+
const bm = JSON.parse(file.data)
162+
bm.id = name
163+
allBookmarks.push(bm)
164+
}
165+
} catch (e) { console.warn('Skip', name, e) }
166+
}
167+
}
168+
169+
// Sort newest first
170+
allBookmarks.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
171+
render()
172+
}
173+
174+
async function saveBookmark (bm) {
175+
const id = bm.id || urlToId(bm.url)
176+
bm.id = id
177+
await client.storeFile('application/json', id, JSON.stringify({
178+
url: bm.url,
179+
title: bm.title,
180+
description: bm.description || '',
181+
tags: bm.tags || [],
182+
createdAt: bm.createdAt || Date.now()
183+
}))
184+
return id
185+
}
186+
187+
async function deleteBookmark (id) {
188+
await client.remove(id)
189+
allBookmarks = allBookmarks.filter(b => b.id !== id)
190+
render()
191+
}
192+
193+
// --- Render ---
194+
function getFilteredBookmarks () {
195+
let list = allBookmarks
196+
const q = searchInput.value.toLowerCase().trim()
197+
198+
if (q) {
199+
list = list.filter(b =>
200+
(b.title || '').toLowerCase().includes(q) ||
201+
(b.url || '').toLowerCase().includes(q) ||
202+
(b.description || '').toLowerCase().includes(q) ||
203+
(b.tags || []).some(t => t.toLowerCase().includes(q))
204+
)
205+
}
206+
207+
if (activeTag) {
208+
list = list.filter(b => (b.tags || []).includes(activeTag))
209+
}
210+
211+
return list
212+
}
213+
214+
function getAllTags () {
215+
const tags = {}
216+
for (const bm of allBookmarks) {
217+
for (const t of (bm.tags || [])) {
218+
tags[t] = (tags[t] || 0) + 1
219+
}
220+
}
221+
return Object.entries(tags).sort((a, b) => b[1] - a[1])
222+
}
223+
224+
function render () {
225+
// Tags bar
226+
const tags = getAllTags()
227+
tagsBar.innerHTML = ''
228+
if (activeTag) {
229+
const all = document.createElement('button')
230+
all.className = 'tag-filter'
231+
all.textContent = 'All'
232+
all.onclick = () => { activeTag = null; render() }
233+
tagsBar.appendChild(all)
234+
}
235+
for (const [tag, count] of tags) {
236+
const btn = document.createElement('button')
237+
btn.className = 'tag-filter' + (activeTag === tag ? ' active' : '')
238+
btn.textContent = `${tag} (${count})`
239+
btn.onclick = () => { activeTag = activeTag === tag ? null : tag; render() }
240+
tagsBar.appendChild(btn)
241+
}
242+
243+
// Bookmarks list
244+
const filtered = getFilteredBookmarks()
245+
countEl.textContent = filtered.length === allBookmarks.length
246+
? `${allBookmarks.length} bookmark${allBookmarks.length !== 1 ? 's' : ''}`
247+
: `${filtered.length} of ${allBookmarks.length} bookmarks`
248+
249+
bookmarksList.innerHTML = ''
250+
if (filtered.length === 0) {
251+
bookmarksList.innerHTML = `<li class="empty">${allBookmarks.length === 0 ? 'No bookmarks yet. Click + Add to get started.' : 'No matches'}</li>`
252+
return
253+
}
254+
255+
for (const bm of filtered) {
256+
const li = document.createElement('li')
257+
258+
// Title + domain
259+
const top = document.createElement('div')
260+
top.className = 'bm-top'
261+
const a = document.createElement('a')
262+
a.className = 'bm-title'
263+
a.href = bm.url
264+
a.target = '_blank'
265+
a.rel = 'noopener'
266+
a.textContent = bm.title || bm.url
267+
top.appendChild(a)
268+
try {
269+
const domain = document.createElement('span')
270+
domain.className = 'bm-domain'
271+
domain.textContent = new URL(bm.url).hostname
272+
top.appendChild(domain)
273+
} catch {}
274+
li.appendChild(top)
275+
276+
// Description
277+
if (bm.description) {
278+
const desc = document.createElement('div')
279+
desc.className = 'bm-desc'
280+
desc.textContent = bm.description
281+
li.appendChild(desc)
282+
}
283+
284+
// Bottom row: tags + date + actions
285+
const bottom = document.createElement('div')
286+
bottom.className = 'bm-bottom'
287+
for (const t of (bm.tags || [])) {
288+
const tag = document.createElement('span')
289+
tag.className = 'bm-tag'
290+
tag.textContent = t
291+
bottom.appendChild(tag)
292+
}
293+
if (bm.createdAt) {
294+
const date = document.createElement('span')
295+
date.className = 'bm-date'
296+
date.textContent = new Date(bm.createdAt).toLocaleDateString()
297+
bottom.appendChild(date)
298+
}
299+
300+
const actions = document.createElement('span')
301+
actions.className = 'bm-actions'
302+
const editBtn = document.createElement('button')
303+
editBtn.textContent = '\u270E'
304+
editBtn.title = 'Edit'
305+
editBtn.onclick = () => openForm(bm)
306+
const delBtn = document.createElement('button')
307+
delBtn.className = 'del'
308+
delBtn.textContent = '\u00d7'
309+
delBtn.title = 'Delete'
310+
delBtn.onclick = () => deleteBookmark(bm.id)
311+
actions.append(editBtn, delBtn)
312+
bottom.appendChild(actions)
313+
314+
li.appendChild(bottom)
315+
bookmarksList.appendChild(li)
316+
}
317+
}
318+
319+
// --- Form ---
320+
function openForm (bm) {
321+
editingId = bm ? bm.id : null
322+
formTitle.textContent = bm ? 'Edit Bookmark' : 'Add Bookmark'
323+
fUrl.value = bm ? bm.url : ''
324+
fName.value = bm ? bm.title : ''
325+
fDesc.value = bm ? (bm.description || '') : ''
326+
fTags.value = bm ? (bm.tags || []).join(', ') : ''
327+
overlay.classList.add('open')
328+
fUrl.focus()
329+
}
330+
331+
function closeForm () {
332+
overlay.classList.remove('open')
333+
editingId = null
334+
}
335+
336+
async function submitForm () {
337+
const url = fUrl.value.trim()
338+
if (!url) return
339+
340+
const tags = fTags.value.split(',').map(t => t.trim()).filter(Boolean)
341+
const bm = {
342+
id: editingId,
343+
url,
344+
title: fName.value.trim() || url,
345+
description: fDesc.value.trim(),
346+
tags,
347+
createdAt: editingId
348+
? (allBookmarks.find(b => b.id === editingId)?.createdAt || Date.now())
349+
: Date.now()
350+
}
351+
352+
// If editing and URL changed, remove old entry
353+
if (editingId && urlToId(url) !== editingId) {
354+
await client.remove(editingId)
355+
allBookmarks = allBookmarks.filter(b => b.id !== editingId)
356+
}
357+
358+
const id = await saveBookmark(bm)
359+
bm.id = id
360+
361+
// Update local state
362+
const idx = allBookmarks.findIndex(b => b.id === id)
363+
if (idx >= 0) {
364+
allBookmarks[idx] = bm
365+
} else {
366+
allBookmarks.unshift(bm)
367+
}
368+
369+
closeForm()
370+
render()
371+
}
372+
373+
addBtn.onclick = () => openForm(null)
374+
formCancel.onclick = closeForm
375+
formSave.onclick = submitForm
376+
overlay.addEventListener('click', e => { if (e.target === overlay) closeForm() })
377+
searchInput.addEventListener('input', render)
378+
379+
// Keyboard: Escape closes form
380+
document.addEventListener('keydown', e => {
381+
if (e.key === 'Escape' && overlay.classList.contains('open')) closeForm()
382+
})
383+
</script>
384+
</body>
385+
</html>

0 commit comments

Comments
 (0)