Skip to content

Commit 64b7064

Browse files
Add notes and editor apps
Two single-file remoteStorage apps with no build step: - notes: quick note-taking with add/delete - editor: minimal text editor with sidebar and auto-save
0 parents  commit 64b7064

3 files changed

Lines changed: 332 additions & 0 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# remoteStorage Apps
2+
3+
Simple apps that sync via the [remoteStorage](https://remotestorage.io) protocol. Works with any remoteStorage server including [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer).
4+
5+
## Apps
6+
7+
### [notes/](notes/)
8+
Quick notes. Add, view, and delete plain-text notes synced to your storage.
9+
10+
### [editor/](editor/)
11+
Minimal text editor with sidebar document list, auto-save, and keyboard-friendly workflow.
12+
13+
## Usage
14+
15+
Each app is a single `index.html` with no build step. Serve from any static host or open directly.
16+
17+
To connect to a JSS instance:
18+
19+
```
20+
jss start --activitypub --idp
21+
```
22+
23+
Then enter `user@hostname` in the remoteStorage widget.
24+
25+
## License
26+
27+
AGPL-3.0-only

editor/index.html

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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>Editor</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; height: 100vh; display: flex; flex-direction: column; }
12+
header { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; background: white; border-bottom: 1px solid #e0e0e0; }
13+
header h1 { font-size: 1.1rem; flex-shrink: 0; }
14+
#rs-widget { flex-shrink: 0; }
15+
.spacer { flex: 1; }
16+
#save-status { font-size: 0.8rem; color: #999; flex-shrink: 0; }
17+
.main { display: flex; flex: 1; min-height: 0; }
18+
.sidebar { width: 220px; background: white; border-right: 1px solid #e0e0e0; overflow-y: auto; flex-shrink: 0; }
19+
.sidebar .actions { padding: 0.5rem; border-bottom: 1px solid #eee; }
20+
.sidebar .actions button { width: 100%; padding: 0.4rem; background: #4a9eff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
21+
.sidebar .actions button:hover { background: #3a8eef; }
22+
.sidebar .actions button:disabled { background: #ccc; }
23+
.sidebar ul { list-style: none; }
24+
.sidebar li { padding: 0.6rem 0.75rem; cursor: pointer; font-size: 0.85rem; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; }
25+
.sidebar li:hover { background: #f5f5f5; }
26+
.sidebar li.active { background: #e8f0fe; font-weight: 500; }
27+
.sidebar li .del { background: none; border: none; color: #ccc; cursor: pointer; font-size: 1rem; padding: 0 0.2rem; }
28+
.sidebar li .del:hover { color: #dc3545; }
29+
.editor-wrap { flex: 1; display: flex; flex-direction: column; min-width: 0; }
30+
#editor { flex: 1; width: 100%; border: none; outline: none; resize: none; padding: 1.5rem; font-family: 'Georgia', serif; font-size: 1.1rem; line-height: 1.7; background: #fafafa; }
31+
#editor:disabled { background: #f0f0f0; }
32+
.empty-state { flex: 1; display: flex; align-items: center; justify-content: center; color: #999; font-size: 1rem; }
33+
@media (max-width: 600px) {
34+
.sidebar { width: 160px; }
35+
#editor { padding: 1rem; font-size: 1rem; }
36+
}
37+
</style>
38+
</head>
39+
<body>
40+
<header>
41+
<h1>Editor</h1>
42+
<div id="rs-widget"></div>
43+
<div class="spacer"></div>
44+
<span id="save-status"></span>
45+
</header>
46+
<div class="main">
47+
<div class="sidebar">
48+
<div class="actions">
49+
<button id="new-btn" disabled>New Document</button>
50+
</div>
51+
<ul id="doc-list"></ul>
52+
</div>
53+
<div class="editor-wrap">
54+
<textarea id="editor" disabled placeholder="Select or create a document..."></textarea>
55+
</div>
56+
</div>
57+
58+
<script>
59+
const rs = new RemoteStorage()
60+
rs.access.claim('documents', 'rw')
61+
rs.caching.enable('/documents/')
62+
63+
const widget = new Widget(rs)
64+
widget.attach('rs-widget')
65+
66+
const client = rs.scope('/documents/texts/')
67+
const editor = document.getElementById('editor')
68+
const docList = document.getElementById('doc-list')
69+
const newBtn = document.getElementById('new-btn')
70+
const saveStatus = document.getElementById('save-status')
71+
72+
let currentDoc = null
73+
let saveTimer = null
74+
75+
rs.on('connected', () => {
76+
newBtn.disabled = false
77+
loadDocList()
78+
})
79+
80+
rs.on('disconnected', () => {
81+
newBtn.disabled = true
82+
editor.disabled = true
83+
editor.value = ''
84+
currentDoc = null
85+
docList.innerHTML = ''
86+
})
87+
88+
async function loadDocList () {
89+
const listing = await client.getListing('')
90+
docList.innerHTML = ''
91+
92+
if (!listing) return
93+
94+
const files = Object.keys(listing).filter(k => !k.endsWith('/')).sort().reverse()
95+
for (const name of files) {
96+
addDocToSidebar(name)
97+
}
98+
99+
// Auto-select first doc
100+
if (files.length > 0) selectDoc(files[0])
101+
}
102+
103+
function addDocToSidebar (name) {
104+
const li = document.createElement('li')
105+
li.dataset.name = name
106+
107+
const label = document.createElement('span')
108+
label.textContent = docTitle(name)
109+
label.onclick = () => selectDoc(name)
110+
111+
const del = document.createElement('button')
112+
del.className = 'del'
113+
del.textContent = '\u00d7'
114+
del.onclick = async (e) => {
115+
e.stopPropagation()
116+
await client.remove(name)
117+
li.remove()
118+
if (currentDoc === name) {
119+
currentDoc = null
120+
editor.value = ''
121+
editor.disabled = true
122+
}
123+
}
124+
125+
li.append(label, del)
126+
docList.appendChild(li)
127+
}
128+
129+
function docTitle (name) {
130+
// name is like "1741234567890.txt" — show date
131+
const ts = parseInt(name.replace('.txt', ''))
132+
if (!isNaN(ts)) return new Date(ts).toLocaleDateString() + ' ' + new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
133+
return name.replace('.txt', '')
134+
}
135+
136+
async function selectDoc (name) {
137+
// Save current doc before switching
138+
if (currentDoc && saveTimer) {
139+
clearTimeout(saveTimer)
140+
await saveCurrentDoc()
141+
}
142+
143+
currentDoc = name
144+
editor.disabled = false
145+
146+
// Highlight in sidebar
147+
docList.querySelectorAll('li').forEach(li => {
148+
li.classList.toggle('active', li.dataset.name === name)
149+
})
150+
151+
const file = await client.getFile(name)
152+
editor.value = (file && file.data) || ''
153+
editor.focus()
154+
saveStatus.textContent = ''
155+
}
156+
157+
async function saveCurrentDoc () {
158+
if (!currentDoc) return
159+
saveStatus.textContent = 'Saving...'
160+
await client.storeFile('text/plain', currentDoc, editor.value)
161+
saveStatus.textContent = 'Saved'
162+
setTimeout(() => { if (saveStatus.textContent === 'Saved') saveStatus.textContent = '' }, 2000)
163+
}
164+
165+
// Auto-save on typing (debounced 1s)
166+
editor.addEventListener('input', () => {
167+
if (!currentDoc) return
168+
saveStatus.textContent = 'Editing...'
169+
clearTimeout(saveTimer)
170+
saveTimer = setTimeout(saveCurrentDoc, 1000)
171+
})
172+
173+
// New document
174+
newBtn.onclick = async () => {
175+
const name = Date.now() + '.txt'
176+
await client.storeFile('text/plain', name, '')
177+
addDocToSidebar(name)
178+
selectDoc(name)
179+
}
180+
</script>
181+
</body>
182+
</html>

notes/index.html

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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>Notes</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: 640px; margin: 0 auto; padding: 1rem; }
12+
h1 { margin-bottom: 1rem; font-size: 1.5rem; }
13+
.controls { margin-bottom: 1rem; display: flex; gap: 0.5rem; }
14+
.controls input { flex: 1; padding: 0.6rem; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem; }
15+
.controls button { padding: 0.6rem 1.2rem; background: #4a9eff; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1rem; font-weight: 500; }
16+
.controls button:hover { background: #3a8eef; }
17+
.controls button:disabled { background: #ccc; cursor: default; }
18+
#notes { list-style: none; }
19+
#notes 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); display: flex; justify-content: space-between; align-items: center; }
20+
#notes li .text { flex: 1; white-space: pre-wrap; word-break: break-word; }
21+
#notes li .meta { color: #999; font-size: 0.75rem; margin-left: 1rem; white-space: nowrap; }
22+
#notes li .del { background: none; color: #ccc; border: none; font-size: 1.2rem; cursor: pointer; padding: 0 0.3rem; margin-left: 0.5rem; }
23+
#notes li .del:hover { color: #dc3545; }
24+
.empty { color: #999; text-align: center; padding: 2rem; }
25+
</style>
26+
</head>
27+
<body>
28+
<h1>Notes</h1>
29+
<div id="rs-widget"></div>
30+
<div class="controls">
31+
<input type="text" id="note-input" placeholder="Type a note..." disabled>
32+
<button id="add-btn" disabled>Add</button>
33+
</div>
34+
<ul id="notes"><li class="empty">Connect to your storage to start</li></ul>
35+
36+
<script>
37+
const rs = new RemoteStorage()
38+
rs.access.claim('notes', 'rw')
39+
rs.caching.enable('/notes/')
40+
41+
const widget = new Widget(rs)
42+
widget.attach('rs-widget')
43+
44+
const client = rs.scope('/notes/')
45+
const noteInput = document.getElementById('note-input')
46+
const addBtn = document.getElementById('add-btn')
47+
const notesList = document.getElementById('notes')
48+
49+
rs.on('connected', () => {
50+
noteInput.disabled = false
51+
addBtn.disabled = false
52+
loadNotes()
53+
})
54+
55+
rs.on('disconnected', () => {
56+
noteInput.disabled = true
57+
addBtn.disabled = true
58+
notesList.innerHTML = '<li class="empty">Connect to your storage to start</li>'
59+
})
60+
61+
async function loadNotes () {
62+
const listing = await client.getListing('')
63+
notesList.innerHTML = ''
64+
65+
if (!listing || Object.keys(listing).length === 0) {
66+
notesList.innerHTML = '<li class="empty">No notes yet</li>'
67+
return
68+
}
69+
70+
const files = Object.keys(listing).filter(k => !k.endsWith('/')).sort().reverse()
71+
for (const name of files) {
72+
const file = await client.getFile(name)
73+
if (file && file.data) addNoteToDOM(name, file.data)
74+
}
75+
76+
if (notesList.children.length === 0) {
77+
notesList.innerHTML = '<li class="empty">No notes yet</li>'
78+
}
79+
}
80+
81+
function addNoteToDOM (name, text) {
82+
const li = document.createElement('li')
83+
84+
const span = document.createElement('span')
85+
span.className = 'text'
86+
span.textContent = text
87+
88+
const meta = document.createElement('span')
89+
meta.className = 'meta'
90+
const ts = parseInt(name.replace('.txt', ''))
91+
if (!isNaN(ts)) meta.textContent = new Date(ts).toLocaleString()
92+
93+
const del = document.createElement('button')
94+
del.className = 'del'
95+
del.textContent = '\u00d7'
96+
del.onclick = async () => {
97+
await client.remove(name)
98+
li.remove()
99+
if (notesList.children.length === 0) {
100+
notesList.innerHTML = '<li class="empty">No notes yet</li>'
101+
}
102+
}
103+
104+
li.append(span, meta, del)
105+
notesList.prepend(li)
106+
}
107+
108+
async function addNote () {
109+
const text = noteInput.value.trim()
110+
if (!text) return
111+
const name = Date.now() + '.txt'
112+
await client.storeFile('text/plain', name, text)
113+
const empty = notesList.querySelector('.empty')
114+
if (empty) empty.remove()
115+
addNoteToDOM(name, text)
116+
noteInput.value = ''
117+
}
118+
119+
addBtn.onclick = addNote
120+
noteInput.addEventListener('keydown', e => { if (e.key === 'Enter') addNote() })
121+
</script>
122+
</body>
123+
</html>

0 commit comments

Comments
 (0)