This commit is contained in:
Mateusz Gruszczyński
2026-07-20 15:59:15 +02:00
parent 771494671b
commit dfa0828c38
42 changed files with 964 additions and 755 deletions
+32 -171
View File
@@ -1,173 +1,34 @@
import { api } from "./api.js?v=0.6.0";
import { copyText } from "./clipboard.js?v=0.6.0";
import { applyFormat } from "./editor-format.js?v=0.6.0";
import { renderMarkdown } from "./markdown.js?v=0.6.0";
import { PadSocket } from "./socket.js?v=0.6.0";
import { currentShareUrl, readEditorState, writeEditorState } from "./url-state.js?v=0.6.0";
const slug = location.pathname.split("/").filter(Boolean)[1];
const passwordKey = `rustpad:pad:${slug}:password`;
const editor = document.querySelector("#editor");
const preview = document.querySelector("#preview");
const editorWorkspace = document.querySelector("#editor-workspace");
const modeToggle = document.querySelector("#mode-toggle");
const passwordDialog = document.querySelector("#password-dialog");
let password = sessionStorage.getItem(passwordKey) || "";
let info;
let socket;
let saveTimer;
let applyingRemote = false;
let uiState = readEditorState();
function toast(text) {
const element = document.querySelector("#toast");
element.textContent = text;
element.classList.add("visible");
setTimeout(() => element.classList.remove("visible"), 1600);
}
function setStatus(kind, text) {
document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`;
document.querySelector("#status-text").textContent = text;
}
function updateAddressLabel() {
document.querySelector("#pad-url").textContent = `${location.pathname}${location.search}`;
}
function render() {
if (uiState.mode === "markdown") {
preview.classList.remove("preview--raw");
preview.innerHTML = renderMarkdown(editor.value);
document.querySelector("#preview-label").textContent = "Podgląd Markdown";
} else {
preview.classList.add("preview--raw");
preview.textContent = editor.value;
document.querySelector("#preview-label").textContent = "Tekst źródłowy";
}
document.querySelector("#characters").textContent = `${editor.value.length} znaków`;
const words = editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0;
document.querySelector("#words").textContent = `${words} słów`;
}
function applyUi({ write = false, replace = false } = {}) {
editorWorkspace.className = `workspace view-${uiState.view}`;
document.querySelectorAll("[data-view]").forEach((button) => {
const active = button.dataset.view === uiState.view;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
const markdown = uiState.mode === "markdown";
modeToggle.classList.toggle("active", markdown);
modeToggle.setAttribute("aria-pressed", String(markdown));
modeToggle.textContent = markdown ? "Markdown" : "Tekst";
modeToggle.title = markdown ? "Pokaż tekst bez interpretacji" : "Interpretuj Markdown";
render();
if (write) writeEditorState(uiState, { replace });
updateAddressLabel();
}
function applyRemote(content) {
if (content === editor.value) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
applyingRemote = true;
editor.value = content;
editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length));
applyingRemote = false;
render();
}
function connect() {
socket?.stop();
socket = new PadSocket({
slug,
password,
onStatus: (state) => setStatus(state === "online" ? "online" : state === "offline" ? "offline" : null, state === "online" ? "Połączono" : state === "offline" ? "Ponowne łączenie…" : "Łączenie…"),
onAuthenticated: (message) => {
if (passwordDialog.open) passwordDialog.close();
document.querySelector("#pad-title").textContent = message.title;
applyRemote(message.content);
editor.focus();
},
onDocument: (message) => {
applyRemote(message.content);
document.querySelector("#save-state").textContent = `Zapisano ${new Date(message.updated_at).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })}`;
},
onError: (message) => {
document.querySelector("#password-error").textContent = message;
if (info?.protected && !passwordDialog.open) passwordDialog.showModal();
},
});
socket.connect();
}
async function initialize() {
try {
info = await api(`/api/pads/${encodeURIComponent(slug)}`);
document.querySelector("#pad-title").textContent = info.title;
document.title = `${info.title} · RustPad`;
applyUi({ write: true, replace: true });
if (info.protected && !password) passwordDialog.showModal();
else connect();
} catch (error) {
location.replace("/");
}
}
document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => {
uiState = { ...uiState, view: button.dataset.view };
applyUi({ write: true });
}));
modeToggle.addEventListener("click", () => {
uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" };
applyUi({ write: true });
});
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); });
window.addEventListener("rustpad:urlchange", updateAddressLabel);
document.querySelector("#copy-link").addEventListener("click", async () => {
try {
await copyText(currentShareUrl(uiState));
toast("Skopiowano link z widokiem");
} catch (error) {
toast(error.message);
}
});
document.querySelectorAll("[data-format]").forEach((button) => button.addEventListener("click", () => applyFormat(editor, button.dataset.format)));
editor.addEventListener("input", () => {
render();
if (applyingRemote) return;
clearTimeout(saveTimer);
document.querySelector("#save-state").textContent = "Zapisywanie…";
saveTimer = setTimeout(() => socket?.update(editor.value), 250);
});
document.querySelector("#password-form").addEventListener("submit", (event) => {
event.preventDefault();
password = document.querySelector("#open-password").value;
sessionStorage.setItem(passwordKey, password);
document.querySelector("#password-error").textContent = "";
connect();
});
const historyPanel = document.querySelector("#history-panel");
document.querySelector("#history-button").addEventListener("click", async () => {
historyPanel.setAttribute("aria-hidden", "false");
document.body.classList.add("history-open");
const list = document.querySelector("#history-list");
list.innerHTML = '<p class="empty">Ładowanie…</p>';
try {
const revisions = await api(`/api/pads/${encodeURIComponent(slug)}/history`, { method: "POST", body: JSON.stringify({ password: password || null }) });
list.innerHTML = revisions.length ? revisions.map((revision) => `<article class="revision"><time>${new Date(revision.created_at.replace(" ", "T") + "Z").toLocaleString("pl-PL")}</time><button class="secondary-button" data-revision="${revision.id}">Przywróć</button></article>`).join("") : '<p class="empty">Brak historii.</p>';
list.querySelectorAll("[data-revision]").forEach((button) => button.addEventListener("click", async () => {
await api(`/api/pads/${encodeURIComponent(slug)}/restore`, { method: "POST", body: JSON.stringify({ password: password || null, revision_id: Number(button.dataset.revision) }) });
toast("Przywrócono wersję");
}));
} catch (error) {
list.innerHTML = `<p class="error">${error.message}</p>`;
}
});
document.querySelector("#close-history").addEventListener("click", () => {
historyPanel.setAttribute("aria-hidden", "true");
document.body.classList.remove("history-open");
});
import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { applyFormat } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown";
import { getNickname, setNickname } from "@rustpad/session";
import { PadSocket } from "@rustpad/socket";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
const slug=location.pathname.split("/").filter(Boolean)[1];
const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter");
const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog");
let password=sessionStorage.getItem(`rustpad:pad:${slug}:password`)||"", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[];
const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off";
function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;}
function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);}
function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;}
function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;}
async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'<p class="error">Failed to load Mermaid.</p>'));}}
function renderGutter(){const lines=editor.value.split("\n");owners=owners.slice(0,lines.length);while(owners.length<lines.length)owners.push(owners.at(-1)||nickname||"");gutter.innerHTML=lines.map((_,i)=>`<div title="${escapeHtml(owners[i]||"no author")}" style="--owner:${colorFor(owners[i])}">${i+1}</div>`).join("");document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);}
function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;"}[c]));}
function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview";renderMermaid();}else{preview.classList.add("preview--raw");preview.textContent=editor.value;document.querySelector("#preview-label").textContent="Source text";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();}
function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view}`;document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();}
function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();document.querySelector("#pad-title").textContent=m.title;applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.querySelector("#pad-title").textContent=info.title;document.title=`${info.title} · RustPad`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}catch(e){document.body.innerHTML=`<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();});
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});
window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format)));
document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}});
editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.length<newLines)owners.push(nickname);owners=owners.slice(0,newLines);owners[cursorLine]=nickname;render();if(applyingRemote)return;clearTimeout(saveTimer);document.querySelector("#save-state").textContent="Saving…";saveTimer=setTimeout(()=>socket?.update(editor.value,JSON.stringify(owners)),250);});
document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;sessionStorage.setItem(`rustpad:pad:${slug}:password`,password);document.querySelector("#password-error").textContent="";connect();});
const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='<p class="empty">Loading…</p>';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${new Date(r.created_at.replace(" ","T")+"Z").toLocaleString("en-US")}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`;}).join(""):'<p class="empty">No history yet.</p>';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`<p class="error">${escapeHtml(e.message)}</p>`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");});
document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{const file=e.target.files[0];if(!file)return;const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";});
initialize();