Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c60381c59a | |||
| 99e33f9277 | |||
| 7c5218dba2 | |||
| 6f07a180c9 | |||
| 3bfe886a96 |
2 changed files with 129 additions and 33 deletions
|
|
@ -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
158
app.py
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue