first commit

This commit is contained in:
Mateusz Gruszczyński
2026-07-17 15:29:08 +02:00
commit 771494671b
35 changed files with 5020 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
export async function api(path, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
const response = await fetch(path, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) },
signal: controller.signal,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Błąd ${response.status}`);
return data;
} catch (error) {
if (error.name === "AbortError") throw new Error("Przekroczono czas odpowiedzi serwera");
throw error;
} finally {
clearTimeout(timeout);
}
}
+16
View File
@@ -0,0 +1,16 @@
export async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const input = document.createElement("textarea");
input.value = text;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
const copied = document.execCommand("copy");
input.remove();
if (!copied) throw new Error("Nie udało się skopiować linku");
}
+23
View File
@@ -0,0 +1,23 @@
export function applyFormat(editor, format) {
const wrap = (before, after = before, placeholder = "tekst") => {
const start = editor.selectionStart, end = editor.selectionEnd;
const selected = editor.value.slice(start, end) || placeholder;
editor.setRangeText(before + selected + after, start, end, "select");
};
const prefix = (value) => {
const start = editor.selectionStart, end = editor.selectionEnd;
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
const selected = editor.value.slice(lineStart, end);
editor.setRangeText(selected.split("\n").map((line, index) => typeof value === "function" ? value(index) + line : value + line).join("\n"), lineStart, end, "select");
};
if (format === "bold") wrap("**");
if (format === "italic") wrap("*");
if (format === "strike") wrap("~~");
if (format === "heading") prefix("## ");
if (format === "bullet") prefix("- ");
if (format === "number") prefix((index) => `${index + 1}. `);
if (format === "quote") prefix("> ");
if (format === "link") wrap("[", "](https://)", "opis linku");
editor.focus();
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
+76
View File
@@ -0,0 +1,76 @@
import { api } from "./api.js?v=0.6.0";
function slugify(value, fallback) {
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
}
function bindPreview(inputId, previewId, countId, prefix, fallback) {
const input = document.querySelector(inputId);
const preview = document.querySelector(previewId);
const count = document.querySelector(countId);
const update = () => {
count.textContent = `${input.value.length}/80`;
preview.textContent = `${prefix}${slugify(input.value, fallback)}`;
};
input.addEventListener("input", update);
update();
}
function setBusy(button, busy, idleText, busyText) {
button.disabled = busy;
button.textContent = busy ? busyText : idleText;
}
bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "notatka");
bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace");
document.querySelectorAll(".password-toggle").forEach((button) => {
button.addEventListener("click", () => {
const input = document.getElementById(button.dataset.target);
const show = input.type === "password";
input.type = show ? "text" : "password";
button.textContent = show ? "Ukryj" : "Pokaż";
});
});
document.querySelector("#pad-form").addEventListener("submit", async (event) => {
event.preventDefault();
const name = document.querySelector("#pad-name");
const password = document.querySelector("#pad-password");
const button = document.querySelector("#pad-button");
const error = document.querySelector("#pad-error");
error.textContent = "";
setBusy(button, true, "Utwórz notatkę", "Tworzenie…");
try {
const payload = { name: name.value.trim() };
if (password.value) payload.password = password.value;
const result = await api("/api/pads", { method: "POST", body: JSON.stringify(payload) });
if (password.value) sessionStorage.setItem(`rustpad:pad:${result.slug}:password`, password.value);
window.location.assign(`${result.url}?view=split&mode=markdown`);
} catch (requestError) {
error.textContent = requestError.message;
} finally {
setBusy(button, false, "Utwórz notatkę", "Tworzenie…");
}
});
document.querySelector("#workspace-form").addEventListener("submit", async (event) => {
event.preventDefault();
const name = document.querySelector("#workspace-name");
const password = document.querySelector("#workspace-password");
const button = document.querySelector("#workspace-button");
const error = document.querySelector("#workspace-error");
error.textContent = "";
setBusy(button, true, "Utwórz workspace", "Tworzenie…");
try {
const payload = { name: name.value.trim() };
if (password.value) payload.password = password.value;
const result = await api("/api/workspaces", { method: "POST", body: JSON.stringify(payload) });
if (password.value) sessionStorage.setItem(`rustpad:workspace:${result.slug}:password`, password.value);
window.location.assign(result.url);
} catch (requestError) {
error.textContent = requestError.message;
} finally {
setBusy(button, false, "Utwórz workspace", "Tworzenie…");
}
});
+26
View File
@@ -0,0 +1,26 @@
function escapeHtml(value) {
return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[char]));
}
function inline(value) {
return escapeHtml(value)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
}
export function renderMarkdown(source) {
let html = "", inCode = false, list = null;
const closeList = () => { if (list) { html += `</${list}>`; list = null; } };
for (const line of source.split("\n")) {
if (line.startsWith("```")) { closeList(); html += inCode ? "</code></pre>" : "<pre><code>"; inCode = !inCode; continue; }
if (inCode) { html += `${escapeHtml(line)}\n`; continue; }
const heading = line.match(/^(#{1,6})\s+(.+)$/);
const ul = line.match(/^\s*[-*+]\s+(.+)$/);
const ol = line.match(/^\s*\d+\.\s+(.+)$/);
if (heading) { closeList(); const n = heading[1].length; html += `<h${n}>${inline(heading[2])}</h${n}>`; }
else if (ul || ol) { const type = ul ? "ul" : "ol"; if (list !== type) { closeList(); html += `<${type}>`; list = type; } html += `<li>${inline((ul || ol)[1])}</li>`; }
else { closeList(); if (/^---+$/.test(line)) html += "<hr>"; else if (line.startsWith("> ")) html += `<blockquote>${inline(line.slice(2))}</blockquote>`; else if (line.trim()) html += `<p>${inline(line)}</p>`; else html += "<br>"; }
}
closeList(); if (inCode) html += "</code></pre>"; return html;
}
+53
View File
@@ -0,0 +1,53 @@
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 { getPassword, setPassword } from "./session.js?v=0.6.0";
import { NoteSocket } from "./socket.js?v=0.6.0";
import { currentShareUrl, readEditorState, writeEditorState } from "./url-state.js?v=0.6.0";
const parts = location.pathname.split("/").filter(Boolean);
const workspaceSlug = parts[1], noteSlug = parts[3];
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace");
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog");
let password = getPassword(workspaceSlug), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState();
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.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("#note-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 ? "Kliknij, aby pokazać tekst bez interpretacji" : "Kliknij, aby interpretować Markdown";
render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel();
}
function applyRemote(content) { if (content === editor.value) return; const start = editor.selectionStart, 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 NoteSocket({ workspaceSlug, noteSlug, 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("#note-title").textContent = message.note_title; document.querySelector("#workspace-link").textContent = message.workspace_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/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`); document.querySelector("#note-title").textContent = info.title; document.querySelector("#workspace-link").textContent = info.workspace_title; document.querySelector("#workspace-link").href = `/w/${encodeURIComponent(workspaceSlug)}`; document.querySelector("#back-workspace").href = `/w/${encodeURIComponent(workspaceSlug)}`; document.title = `${info.title} · ${info.workspace_title}`; applyUi({ write: true, replace: true }); if (info.protected && !password) passwordDialog.showModal(); else connect(); } catch (e) { document.body.innerHTML = `<main class="error-page"><div><h1>Nie znaleziono notatki</h1><p>${e.message}</p><a href="/w/${encodeURIComponent(workspaceSlug)}">Wróć do workspace</a></div></main>`; }
}
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 { const url = currentShareUrl(uiState); await copyText(url); toast("Skopiowano link z widokiem"); } catch (e) { toast(e.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; setPassword(workspaceSlug, 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/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`, { method: "POST", body: JSON.stringify({ password: password || null }) }); list.innerHTML = revisions.length ? revisions.map(r => `<article class="revision"><time>${new Date(r.created_at.replace(" ", "T") + "Z").toLocaleString("pl-PL")}</time><button class="secondary-button" data-revision="${r.id}">Przywróć</button></article>`).join("") : '<p class="empty">Brak historii.</p>'; list.querySelectorAll("[data-revision]").forEach(button => button.addEventListener("click", async () => { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST", body: JSON.stringify({ password: password || null, revision_id: Number(button.dataset.revision) }) }); toast("Przywrócono wersję"); })); } catch (e) { list.innerHTML = `<p class="error">${e.message}</p>`; } });
document.querySelector("#close-history").addEventListener("click", () => { historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
initialize();
+173
View File
@@ -0,0 +1,173 @@
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");
});
initialize();
+6
View File
@@ -0,0 +1,6 @@
export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; }
export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; }
export function setPassword(workspaceSlug, password) {
if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password);
else sessionStorage.removeItem(passwordKey(workspaceSlug));
}
+60
View File
@@ -0,0 +1,60 @@
export class NoteSocket {
constructor({ workspaceSlug, noteSlug, password, onStatus, onAuthenticated, onDocument, onError }) {
Object.assign(this, { workspaceSlug, noteSlug, password, onStatus, onAuthenticated, onDocument, onError });
this.socket = null; this.timer = null; this.closed = false;
}
connect() {
clearTimeout(this.timer); this.onStatus?.("connecting");
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
this.socket = new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`);
this.socket.addEventListener("open", () => this.socket.send(JSON.stringify({ type: "authenticate", password: this.password || null })));
this.socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); }
if (message.type === "authenticated") { this.onStatus?.("online"); this.onAuthenticated?.(message); }
if (message.type === "document") this.onDocument?.(message);
});
this.socket.addEventListener("close", () => { if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); } });
this.socket.addEventListener("error", () => this.socket.close());
}
update(content) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: "update", content })); }
stop() { this.closed = true; clearTimeout(this.timer); this.socket?.close(); }
}
export class PadSocket {
constructor({ slug, password, onStatus, onAuthenticated, onDocument, onError }) {
Object.assign(this, { slug, password, onStatus, onAuthenticated, onDocument, onError });
this.socket = null;
this.timer = null;
this.closed = false;
}
connect() {
clearTimeout(this.timer);
this.closed = false;
this.onStatus?.("connecting");
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
this.socket = new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);
this.socket.addEventListener("open", () => this.socket.send(JSON.stringify({ type: "authenticate", password: this.password || null })));
this.socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); }
if (message.type === "authenticated") { this.onStatus?.("online"); this.onAuthenticated?.(message); }
if (message.type === "document") this.onDocument?.(message);
});
this.socket.addEventListener("close", () => {
if (!this.closed) {
this.onStatus?.("offline");
this.timer = setTimeout(() => this.connect(), 1500);
}
});
this.socket.addEventListener("error", () => this.socket.close());
}
update(content) {
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: "update", content }));
}
stop() {
this.closed = true;
clearTimeout(this.timer);
this.socket?.close();
}
}
+27
View File
@@ -0,0 +1,27 @@
const VIEWS = new Set(["edit", "split", "preview"]);
const MODES = new Set(["markdown", "text"]);
export function readEditorState() {
const params = new URLSearchParams(window.location.search);
return {
view: VIEWS.has(params.get("view")) ? params.get("view") : "split",
mode: MODES.has(params.get("mode")) ? params.get("mode") : "markdown",
};
}
export function writeEditorState(state, { replace = false } = {}) {
const url = new URL(window.location.href);
url.searchParams.set("view", state.view);
url.searchParams.set("mode", state.mode);
const method = replace ? "replaceState" : "pushState";
window.history[method]({ ...state }, "", `${url.pathname}${url.search}${url.hash}`);
window.dispatchEvent(new CustomEvent("rustpad:urlchange", { detail: { url: url.href } }));
return url.href;
}
export function currentShareUrl(state) {
const url = new URL(window.location.href);
url.searchParams.set("view", state.view);
url.searchParams.set("mode", state.mode);
return url.href;
}
+11
View File
@@ -0,0 +1,11 @@
import { api } from "./api.js?v=0.6.0"; import { copyText } from "./clipboard.js?v=0.6.0"; import { getPassword, setPassword } from "./session.js?v=0.6.0";
const parts = location.pathname.split("/").filter(Boolean), slug = parts[1]; let info, password = getPassword(slug); const dialog = document.querySelector("#password-dialog"), notesList = document.querySelector("#notes-list");
function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1600); }
function renderNotes(notes) { notesList.innerHTML = notes.length ? notes.map(note => `<a class="note-card" href="${note.url}?view=split&mode=markdown"><h3>${escapeHtml(note.title)}</h3><p>Aktualizacja: ${new Date(note.updated_at).toLocaleString("pl-PL")}</p></a>`).join("") : '<p class="empty">Brak notatek.</p>'; }
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
async function openWorkspace() { try { const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) }); info = data.workspace; document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; document.title = `${info.title} · RustPad`; renderNotes(data.notes); if (dialog.open) dialog.close(); } catch (e) { if (info?.protected || e.message.includes("hasło")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); } else document.querySelector("#workspace-error").textContent = e.message; } }
async function init() { try { info = await api(`/api/workspaces/${encodeURIComponent(slug)}`); document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; if (info.protected && !password) dialog.showModal(); else openWorkspace(); } catch (e) { document.querySelector("#workspace-error").textContent = e.message; } }
document.querySelector("#password-form").addEventListener("submit", e => { e.preventDefault(); password = document.querySelector("#open-password").value; setPassword(slug, password); openWorkspace(); });
document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close());
document.querySelector("#note-form").addEventListener("submit", async e => { e.preventDefault(); const error = document.querySelector("#note-error"); error.textContent = ""; try { const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { method: "POST", body: JSON.stringify({ name: document.querySelector("#note-name").value, password: password || null }) }); location.assign(`${note.url}?view=split&mode=markdown`); } catch (err) { error.textContent = err.message; } });
document.querySelector("#copy-workspace-link").addEventListener("click", async () => { try { await copyText(new URL(location.pathname, location.origin).href); toast("Skopiowano link"); } catch (e) { toast(e.message); } }); init();