Compare commits

..

No commits in common. "main" and "v0.4.0" have entirely different histories.
main ... v0.4.0

2 changed files with 33 additions and 129 deletions

View file

@ -5,7 +5,7 @@ Simple **live collaboration notepad** with websockets and FastAPI.
[Issue tracker](https://git.uphillsecurity.com/cf7/aukpad/issues) | `Libera Chat #aukpad` [Issue tracker](https://git.uphillsecurity.com/cf7/aukpad/issues) | `Libera Chat #aukpad`
- Status: Beta - expect minor changes. - Status: Beta - expect minor changes.
- Instance/Demo: [aukpad.com](https://aukpad.com/) - Instance/Demo: [aukpad.com](https://aufkpad.com/)
- Inspired by: - Inspired by:
- [Rustpad](https://github.com/ekzhang/rustpad) - [Rustpad](https://github.com/ekzhang/rustpad)

158
app.py
View file

@ -134,70 +134,46 @@ HTML = """<!doctype html>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<title>aukpad</title> <title>aukpad</title>
<style> <style>
:root { :root { --line-h: 1.4; }
--line-h: 1.4;
--bg: #fbfbfb;
--text: #111;
--border: #ddd;
--btn-bg: #fff;
--btn-hover: #f0f0f0;
--gutter-bg: #f8fafc;
--gutter-border: #eee;
--gutter-color: #9ca3af;
--panel-bg: #fff;
--overlay-bg: rgba(0,0,0,.55);
}
[data-theme="dark"] {
--bg: #17181E;
--text: #F0F0F0;
--border: #3a3b45;
--btn-bg: #2a2b33;
--btn-hover: #35363f;
--gutter-bg: #1e1f28;
--gutter-border: #2e2f3a;
--gutter-color: #6b7280;
--panel-bg: #2a2b33;
--overlay-bg: rgba(0,0,0,.75);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; margin: 0; padding: 0; background-color: var(--bg); color: var(--text); } html, body { height: 100%; margin: 0; padding: 0; background-color: #fbfbfb; }
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji"; body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji";
max-width: 1000px; margin: 0 auto; padding: 1rem; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; } max-width: 1000px; margin: 0 auto; padding: 1rem; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; }
header { display:flex; justify-content:space-between; align-items:center; margin-bottom: .5rem; flex-shrink: 0; } header { display:flex; justify-content:space-between; align-items:center; margin-bottom: .5rem; flex-shrink: 0; }
a,button { padding:.35rem 0; text-decoration:none; border:1px solid var(--border); border-radius:4px; background:var(--btn-bg); color:var(--text); cursor:pointer; font-size:.9rem; font-weight:bold; min-width:2rem; text-align:center; display:inline-block; } a,button { padding:.35rem .6rem; text-decoration:none; border:1px solid #ddd; border-radius:4px; background:#fff; cursor:pointer; }
a:hover,button:hover { background:var(--btn-hover); } #newpad { background:#000; color:#fff; border:1px solid #000; font-weight:bold; }
#lock-btn { font-weight:normal; }
#status { font-size:.9rem; opacity:.7; margin-left:.5rem; } #status { font-size:.9rem; opacity:.7; margin-left:.5rem; }
#status::before { content: "●"; margin-right: .3rem; color: #ef4444; } #status::before { content: "●"; margin-right: .3rem; color: #ef4444; }
#status.connected::before { color: #22c55e; } #status.connected::before { color: #22c55e; }
#peers { font-size:.95rem; font-weight:bold; margin-left:.5rem; margin-right:.3rem; color:#22c55e; display:none; } #peers { font-size:.95rem; font-weight:bold; margin-left:.5rem; margin-right:.3rem; color:#22c55e; display:none; }
#wrap { display:grid; grid-template-columns: max-content 1fr; border:1px solid var(--border); border-radius:4px; overflow:hidden; #wrap { display:grid; grid-template-columns: max-content 1fr; border:1px solid #ddd; border-radius:4px; overflow:hidden;
flex: 1; } flex: 1; }
#gutter, #t { font: 14px/var(--line-h) ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } #gutter, #t { font: 14px/var(--line-h) ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
#gutter { padding:.5rem .75rem; text-align:right; color:var(--gutter-color); background:var(--gutter-bg); border-right:1px solid var(--gutter-border); #gutter { padding:.5rem .75rem; text-align:right; color:#9ca3af; background:#f8fafc; border-right:1px solid #eee;
user-select:none; min-width: 3ch; white-space: pre; height: 100%; overflow: hidden; } user-select:none; min-width: 3ch; white-space: pre; height: 100%; overflow: hidden; }
#t { padding:.5rem .75rem; width:100%; height: 100%; resize: none; border:0; outline:0; #t { padding:.5rem .75rem; width:100%; height: 100%; resize: none; border:0; outline:0;
overflow:auto; white-space: pre; background:var(--bg); color:var(--text); } overflow:auto; white-space: pre; }
#newpad { margin-left:.5rem; }
#info { margin-left:.5rem; color: black; font-size: 0.8rem; }
pre { margin: 0; } pre { margin: 0; }
/* Password protection */ /* Password protection */
#lock-btn.locked { background:#dbeafe; border-color:#3b82f6; } #lock-btn.locked { background:#fef3c7; border-color:#f59e0b; }
[data-theme="dark"] #lock-btn.locked { background:#1e3a5f; border-color:#3b82f6; } #pw-panel { display:none; position:absolute; top:2.5rem; right:0; background:#fff; border:1px solid #ddd;
#pw-panel { display:none; position:absolute; top:2.5rem; right:0; background:var(--panel-bg); border:1px solid var(--border);
border-radius:6px; padding:.75rem; box-shadow:0 4px 12px rgba(0,0,0,.1); z-index:10; min-width:230px; } border-radius:6px; padding:.75rem; box-shadow:0 4px 12px rgba(0,0,0,.1); z-index:10; min-width:230px; }
#pw-panel.open { display:block; } #pw-panel.open { display:block; }
#pw-panel label { font-size:.8rem; font-weight:bold; display:block; margin-bottom:.4rem; } #pw-panel label { font-size:.8rem; font-weight:bold; display:block; margin-bottom:.4rem; }
#pw-panel input { width:100%; padding:.35rem .5rem; border:1px solid var(--border); border-radius:4px; #pw-panel input { width:100%; padding:.35rem .5rem; border:1px solid #ddd; border-radius:4px;
margin-bottom:.5rem; font-size:.9rem; font-family:inherit; background:var(--btn-bg); color:var(--text); } margin-bottom:.5rem; font-size:.9rem; font-family:inherit; }
.pw-btns { display:flex; gap:.4rem; } .pw-btns { display:flex; gap:.4rem; }
.pw-btns button { flex:1; font-size:.8rem; padding:.3rem .4rem; } .pw-btns button { flex:1; font-size:.8rem; padding:.3rem .4rem; }
#pw-msg { font-size:.8rem; margin-top:.4rem; color:#6b7280; min-height:1.2em; } #pw-msg { font-size:.8rem; margin-top:.4rem; color:#6b7280; min-height:1.2em; }
#pw-overlay { display:none; position:fixed; inset:0; background:var(--overlay-bg); z-index:100; #pw-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:100;
align-items:center; justify-content:center; } align-items:center; justify-content:center; }
#pw-overlay.open { display:flex; } #pw-overlay.open { display:flex; }
#pw-box { background:var(--panel-bg); border-radius:8px; padding:1.5rem; width:300px; } #pw-box { background:#fff; border-radius:8px; padding:1.5rem; width:300px; }
#pw-box h2 { margin:0 0 .75rem; font-size:1rem; } #pw-box h2 { margin:0 0 .75rem; font-size:1rem; }
#auth-input { width:100%; padding:.45rem .6rem; border:1px solid var(--border); border-radius:4px; #auth-input { width:100%; padding:.45rem .6rem; border:1px solid #ddd; border-radius:4px;
font-size:1rem; margin-bottom:.5rem; font-family:inherit; display:block; background:var(--btn-bg); color:var(--text); } font-size:1rem; margin-bottom:.5rem; font-family:inherit; display:block; }
#auth-error { color:#ef4444; font-size:.85rem; margin-bottom:.5rem; display:none; } #auth-error { color:#ef4444; font-size:.85rem; margin-bottom:.5rem; display:none; }
#auth-submit { width:100%; padding:.45rem; background:#000; color:#fff; border:none; #auth-submit { width:100%; padding:.45rem; background:#000; color:#fff; border:none;
border-radius:4px; font-size:.95rem; cursor:pointer; } border-radius:4px; font-size:.95rem; cursor:pointer; }
@ -207,11 +183,10 @@ HTML = """<!doctype html>
<strong id="padname"></strong><span id="peers"></span><span id="status">disconnected</span> <strong id="padname"></strong><span id="peers"></span><span id="status">disconnected</span>
</div> </div>
<div style="position:relative; display:flex; align-items:center; gap:.25rem;"> <div style="position:relative; display:flex; align-items:center; gap:.25rem;">
<button id="copy" onclick="copyToClipboard()" title="Copy to clipboard"></button> <button id="copy" onclick="copyToClipboard()">Copy</button>
<a id="newpad" href="/" target="_blank" title="Opens a new pad in a new tab">+</a> <button id="lock-btn" onclick="togglePwPanel()" title="No password click to set one">Lock</button>
<button id="lock-btn" onclick="togglePwPanel()" title="No password click to set one">🔒</button> <a id="newpad" href="/" target="_blank">New pad</a>
<button id="theme-btn" onclick="toggleTheme()" title="Toggle dark/light mode"></button> <a id="info" href="/system/info">Info</a>
<a id="info" href="/system/info" title="System info"></a>
<div id="pw-panel"> <div id="pw-panel">
<label>Password protection</label> <label>Password protection</label>
<input id="pw-input" type="password" placeholder="New password…" onkeydown="if(event.key==='Enter')setPassword()"/> <input id="pw-input" type="password" placeholder="New password…" onkeydown="if(event.key==='Enter')setPassword()"/>
@ -244,28 +219,6 @@ const $ = s => document.querySelector(s);
const proto = location.protocol === "https:" ? "wss" : "ws"; const proto = location.protocol === "https:" ? "wss" : "ws";
const rand = () => Math.random().toString(36).slice(2, 6); // 4 chars const rand = () => Math.random().toString(36).slice(2, 6); // 4 chars
// Theme
function applyTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
$('#theme-btn').textContent = dark ? '' : '';
}
function toggleTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
localStorage.setItem('theme', isDark ? 'light' : 'dark');
applyTheme(!isDark);
}
(function() {
const saved = localStorage.getItem('theme');
if (saved) { applyTheme(saved === 'dark'); }
else {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(prefersDark);
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) applyTheme(e.matches);
});
})();
// Derive docId from path; redirect root to random // Derive docId from path; redirect root to random
let docId = decodeURIComponent(location.pathname.replace(/(^\\/|\\/$)/g, "")); let docId = decodeURIComponent(location.pathname.replace(/(^\\/|\\/$)/g, ""));
if (!docId) { location.replace("/" + rand() + "/"); } if (!docId) { location.replace("/" + rand() + "/"); }
@ -274,7 +227,6 @@ $("#padname").textContent = "/"+docId+"/";
let ws, ver = 0, clientId = Math.random().toString(36).slice(2), debounce; let ws, ver = 0, clientId = Math.random().toString(36).slice(2), debounce;
let isProtected = false, isAuthed = false; let isProtected = false, isAuthed = false;
const urlPw = new URLSearchParams(location.search).get("pw") || "";
// --- Line numbers --- // --- Line numbers ---
const ta = $("#t"); const ta = $("#t");
@ -330,11 +282,11 @@ function removePassword() {
function updateLockBtn() { function updateLockBtn() {
const btn = $("#lock-btn"); const btn = $("#lock-btn");
if (isProtected) { if (isProtected) {
btn.textContent = "🔒︎"; btn.textContent = "Locked";
btn.classList.add("locked"); btn.classList.add("locked");
btn.title = "Password protected click to manage"; btn.title = "Password protected click to manage";
} else { } else {
btn.textContent = "🔒︎"; btn.textContent = "Lock";
btn.classList.remove("locked"); btn.classList.remove("locked");
btn.title = "No password click to set one"; btn.title = "No password click to set one";
} }
@ -383,11 +335,7 @@ function connect(){
updateLockBtn(); updateLockBtn();
if (msg.peers !== undefined) { const el = $("#peers"); el.textContent = msg.peers; el.style.display = "inline"; } if (msg.peers !== undefined) { const el = $("#peers"); el.textContent = msg.peers; el.style.display = "inline"; }
if (isProtected && !isAuthed) { if (isProtected && !isAuthed) {
if (urlPw) {
ws.send(JSON.stringify({type: "auth", password: urlPw}));
} else {
showOverlay(); showOverlay();
}
} else { } else {
isAuthed = true; isAuthed = true;
hideOverlay(); hideOverlay();
@ -399,17 +347,15 @@ function connect(){
// Real init with content follows immediately from server // Real init with content follows immediately from server
} else if (msg.type === "error") { } else if (msg.type === "error") {
if (!isAuthed) { if (!isAuthed) {
showOverlay();
const errEl = $("#auth-error"); const errEl = $("#auth-error");
errEl.textContent = msg.message; errEl.textContent = msg.message;
errEl.style.display = "block"; errEl.style.display = "block";
} }
} else if (msg.type === "update" && msg.ver > ver && msg.clientId !== clientId) { } else if (msg.type === "update" && msg.ver > ver && msg.clientId !== clientId) {
const {selectionStart:s, selectionEnd:e} = ta; const {selectionStart:s, selectionEnd:e} = ta;
const oldText = ta.value;
ta.value = msg.text; ver = msg.ver; updateGutter(); ta.value = msg.text; ver = msg.ver; updateGutter();
ta.selectionStart = adjustCursor(oldText, msg.text, s); ta.selectionStart = Math.min(s, ta.value.length);
ta.selectionEnd = adjustCursor(oldText, msg.text, e); ta.selectionEnd = Math.min(e, ta.value.length);
} else if (msg.type === "peers_changed") { } else if (msg.type === "peers_changed") {
const el = $("#peers"); const el = $("#peers");
el.textContent = msg.count; el.textContent = msg.count;
@ -434,7 +380,7 @@ async function copyToClipboard() {
await navigator.clipboard.writeText(ta.value); await navigator.clipboard.writeText(ta.value);
const btn = $("#copy"); const btn = $("#copy");
const original = btn.textContent; const original = btn.textContent;
btn.textContent = ""; btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1500); setTimeout(() => { btn.textContent = original; }, 1500);
} catch (err) { } catch (err) {
// Fallback for older browsers // Fallback for older browsers
@ -442,30 +388,11 @@ async function copyToClipboard() {
document.execCommand('copy'); document.execCommand('copy');
const btn = $("#copy"); const btn = $("#copy");
const original = btn.textContent; const original = btn.textContent;
btn.textContent = ""; btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 1500); setTimeout(() => { btn.textContent = original; }, 1500);
} }
} }
// Adjust cursor position after a remote text update.
// Finds the single changed region (common prefix + suffix),
// then shifts the cursor accordingly:
// - change is after cursor no movement
// - change is before cursor shift by length delta
// - cursor was inside the changed region place at end of new content
function adjustCursor(oldText, newText, pos) {
let start = 0;
const minLen = Math.min(oldText.length, newText.length);
while (start < minLen && oldText[start] === newText[start]) start++;
if (pos <= start) return pos; // change is entirely after cursor
let oldEnd = oldText.length, newEnd = newText.length;
while (oldEnd > start && newEnd > start && oldText[oldEnd - 1] === newText[newEnd - 1]) {
oldEnd--; newEnd--;
}
if (pos >= oldEnd) return pos + (newEnd - oldEnd); // change is before cursor
return newEnd; // cursor was inside changed region
}
connect(); connect();
// Handle Tab key to insert 4 spaces instead of navigation // Handle Tab key to insert 4 spaces instead of navigation
@ -507,39 +434,16 @@ def get_system_info():
<meta charset="utf-8"/> <meta charset="utf-8"/>
<title>aukpad - System Info</title> <title>aukpad - System Info</title>
<style> <style>
:root {{
--bg: #fbfbfb;
--text: #111;
--heading: #333;
--section-bg: #f8fafc;
--value-bg: #e5e7eb;
}}
[data-theme="dark"] {{
--bg: #17181E;
--text: #F0F0F0;
--heading: #e0e0e0;
--section-bg: #1e1f28;
--value-bg: #2a2b33;
}}
body {{ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; body {{ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
max-width: 800px; margin: 2rem auto; padding: 1rem; line-height: 1.6; max-width: 800px; margin: 2rem auto; padding: 1rem; line-height: 1.6; }}
background-color: var(--bg); color: var(--text); }} h1, h2 {{ color: #333; }}
h1, h2 {{ color: var(--heading); }} .info-section {{ background: #f8fafc; padding: 1rem; border-radius: 8px; margin: 1rem 0; }}
.info-section {{ background: var(--section-bg); padding: 1rem; border-radius: 8px; margin: 1rem 0; }}
.config-item {{ margin: 0.5rem 0; }} .config-item {{ margin: 0.5rem 0; }}
.value {{ font-family: monospace; background: var(--value-bg); padding: 0.2rem 0.4rem; border-radius: 4px; }} .value {{ font-family: monospace; background: #e5e7eb; padding: 0.2rem 0.4rem; border-radius: 4px; }}
.back-link {{ display: inline-block; margin-top: 1rem; padding: 0.5rem 1rem; .back-link {{ display: inline-block; margin-top: 1rem; padding: 0.5rem 1rem;
background: #000; color: #fff; text-decoration: none; border-radius: 8px; }} background: #000; color: #fff; text-decoration: none; border-radius: 8px; }}
.back-link:hover {{ background: #333; }} .back-link:hover {{ background: #333; }}
</style> </style>
<script>
(function() {{
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = saved ? saved === 'dark' : prefersDark;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
}})();
</script>
</head> </head>
<body> <body>
<h1>System Information</h1> <h1>System Information</h1>