Compare commits

...

5 commits
v0.4.0 ... main

2 changed files with 129 additions and 33 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`
- Status: Beta - expect minor changes.
- Instance/Demo: [aukpad.com](https://aufkpad.com/)
- Instance/Demo: [aukpad.com](https://aukpad.com/)
- Inspired by:
- [Rustpad](https://github.com/ekzhang/rustpad)

158
app.py
View file

@ -134,46 +134,70 @@ HTML = """<!doctype html>
<meta charset="utf-8"/>
<title>aukpad</title>
<style>
:root { --line-h: 1.4; }
:root {
--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; }
html, body { height: 100%; margin: 0; padding: 0; background-color: #fbfbfb; }
html, body { height: 100%; margin: 0; padding: 0; background-color: var(--bg); color: var(--text); }
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; }
header { display:flex; justify-content:space-between; align-items:center; margin-bottom: .5rem; flex-shrink: 0; }
a,button { padding:.35rem .6rem; text-decoration:none; border:1px solid #ddd; border-radius:4px; background:#fff; cursor:pointer; }
#newpad { background:#000; color:#fff; border:1px solid #000; font-weight:bold; }
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:hover,button:hover { background:var(--btn-hover); }
#lock-btn { font-weight:normal; }
#status { font-size:.9rem; opacity:.7; margin-left:.5rem; }
#status::before { content: "●"; margin-right: .3rem; color: #ef4444; }
#status.connected::before { color: #22c55e; }
#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 #ddd; border-radius:4px; overflow:hidden;
#wrap { display:grid; grid-template-columns: max-content 1fr; border:1px solid var(--border); border-radius:4px; overflow:hidden;
flex: 1; }
#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:#9ca3af; background:#f8fafc; border-right:1px solid #eee;
#gutter { padding:.5rem .75rem; text-align:right; color:var(--gutter-color); background:var(--gutter-bg); border-right:1px solid var(--gutter-border);
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;
overflow:auto; white-space: pre; }
#newpad { margin-left:.5rem; }
#info { margin-left:.5rem; color: black; font-size: 0.8rem; }
overflow:auto; white-space: pre; background:var(--bg); color:var(--text); }
pre { margin: 0; }
/* Password protection */
#lock-btn.locked { background:#fef3c7; border-color:#f59e0b; }
#pw-panel { display:none; position:absolute; top:2.5rem; right:0; background:#fff; border:1px solid #ddd;
#lock-btn.locked { background:#dbeafe; border-color:#3b82f6; }
[data-theme="dark"] #lock-btn.locked { background:#1e3a5f; border-color:#3b82f6; }
#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; }
#pw-panel.open { display:block; }
#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 #ddd; border-radius:4px;
margin-bottom:.5rem; font-size:.9rem; font-family:inherit; }
#pw-panel input { width:100%; padding:.35rem .5rem; border:1px solid var(--border); border-radius:4px;
margin-bottom:.5rem; font-size:.9rem; font-family:inherit; background:var(--btn-bg); color:var(--text); }
.pw-btns { display:flex; gap:.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-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:100;
#pw-overlay { display:none; position:fixed; inset:0; background:var(--overlay-bg); z-index:100;
align-items:center; justify-content:center; }
#pw-overlay.open { display:flex; }
#pw-box { background:#fff; border-radius:8px; padding:1.5rem; width:300px; }
#pw-box { background:var(--panel-bg); border-radius:8px; padding:1.5rem; width:300px; }
#pw-box h2 { margin:0 0 .75rem; font-size:1rem; }
#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; }
#auth-input { width:100%; padding:.45rem .6rem; border:1px solid var(--border); border-radius:4px;
font-size:1rem; margin-bottom:.5rem; font-family:inherit; display:block; background:var(--btn-bg); color:var(--text); }
#auth-error { color:#ef4444; font-size:.85rem; margin-bottom:.5rem; display:none; }
#auth-submit { width:100%; padding:.45rem; background:#000; color:#fff; border:none;
border-radius:4px; font-size:.95rem; cursor:pointer; }
@ -183,10 +207,11 @@ HTML = """<!doctype html>
<strong id="padname"></strong><span id="peers"></span><span id="status">disconnected</span>
</div>
<div style="position:relative; display:flex; align-items:center; gap:.25rem;">
<button id="copy" onclick="copyToClipboard()">Copy</button>
<button id="lock-btn" onclick="togglePwPanel()" title="No password click to set one">Lock</button>
<a id="newpad" href="/" target="_blank">New pad</a>
<a id="info" href="/system/info">Info</a>
<button id="copy" onclick="copyToClipboard()" title="Copy to clipboard"></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">🔒</button>
<button id="theme-btn" onclick="toggleTheme()" title="Toggle dark/light mode"></button>
<a id="info" href="/system/info" title="System info"></a>
<div id="pw-panel">
<label>Password protection</label>
<input id="pw-input" type="password" placeholder="New password…" onkeydown="if(event.key==='Enter')setPassword()"/>
@ -219,6 +244,28 @@ const $ = s => document.querySelector(s);
const proto = location.protocol === "https:" ? "wss" : "ws";
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
let docId = decodeURIComponent(location.pathname.replace(/(^\\/|\\/$)/g, ""));
if (!docId) { location.replace("/" + rand() + "/"); }
@ -227,6 +274,7 @@ $("#padname").textContent = "/"+docId+"/";
let ws, ver = 0, clientId = Math.random().toString(36).slice(2), debounce;
let isProtected = false, isAuthed = false;
const urlPw = new URLSearchParams(location.search).get("pw") || "";
// --- Line numbers ---
const ta = $("#t");
@ -282,11 +330,11 @@ function removePassword() {
function updateLockBtn() {
const btn = $("#lock-btn");
if (isProtected) {
btn.textContent = "Locked";
btn.textContent = "🔒︎";
btn.classList.add("locked");
btn.title = "Password protected click to manage";
} else {
btn.textContent = "Lock";
btn.textContent = "🔒︎";
btn.classList.remove("locked");
btn.title = "No password click to set one";
}
@ -335,7 +383,11 @@ function connect(){
updateLockBtn();
if (msg.peers !== undefined) { const el = $("#peers"); el.textContent = msg.peers; el.style.display = "inline"; }
if (isProtected && !isAuthed) {
if (urlPw) {
ws.send(JSON.stringify({type: "auth", password: urlPw}));
} else {
showOverlay();
}
} else {
isAuthed = true;
hideOverlay();
@ -347,15 +399,17 @@ function connect(){
// Real init with content follows immediately from server
} else if (msg.type === "error") {
if (!isAuthed) {
showOverlay();
const errEl = $("#auth-error");
errEl.textContent = msg.message;
errEl.style.display = "block";
}
} else if (msg.type === "update" && msg.ver > ver && msg.clientId !== clientId) {
const {selectionStart:s, selectionEnd:e} = ta;
const oldText = ta.value;
ta.value = msg.text; ver = msg.ver; updateGutter();
ta.selectionStart = Math.min(s, ta.value.length);
ta.selectionEnd = Math.min(e, ta.value.length);
ta.selectionStart = adjustCursor(oldText, msg.text, s);
ta.selectionEnd = adjustCursor(oldText, msg.text, e);
} else if (msg.type === "peers_changed") {
const el = $("#peers");
el.textContent = msg.count;
@ -380,7 +434,7 @@ async function copyToClipboard() {
await navigator.clipboard.writeText(ta.value);
const btn = $("#copy");
const original = btn.textContent;
btn.textContent = "Copied!";
btn.textContent = "";
setTimeout(() => { btn.textContent = original; }, 1500);
} catch (err) {
// Fallback for older browsers
@ -388,11 +442,30 @@ async function copyToClipboard() {
document.execCommand('copy');
const btn = $("#copy");
const original = btn.textContent;
btn.textContent = "Copied!";
btn.textContent = "";
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();
// Handle Tab key to insert 4 spaces instead of navigation
@ -434,16 +507,39 @@ def get_system_info():
<meta charset="utf-8"/>
<title>aukpad - System Info</title>
<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;
max-width: 800px; margin: 2rem auto; padding: 1rem; line-height: 1.6; }}
h1, h2 {{ color: #333; }}
.info-section {{ background: #f8fafc; padding: 1rem; border-radius: 8px; margin: 1rem 0; }}
max-width: 800px; margin: 2rem auto; padding: 1rem; line-height: 1.6;
background-color: var(--bg); color: var(--text); }}
h1, h2 {{ color: var(--heading); }}
.info-section {{ background: var(--section-bg); padding: 1rem; border-radius: 8px; margin: 1rem 0; }}
.config-item {{ margin: 0.5rem 0; }}
.value {{ font-family: monospace; background: #e5e7eb; padding: 0.2rem 0.4rem; border-radius: 4px; }}
.value {{ font-family: monospace; background: var(--value-bg); padding: 0.2rem 0.4rem; border-radius: 4px; }}
.back-link {{ display: inline-block; margin-top: 1rem; padding: 0.5rem 1rem;
background: #000; color: #fff; text-decoration: none; border-radius: 8px; }}
.back-link:hover {{ background: #333; }}
</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>
<body>
<h1>System Information</h1>