improvements

This commit is contained in:
Mateusz Gruszczyński
2026-08-05 09:53:28 +02:00
parent 248f4a3977
commit a9911da9a3
42 changed files with 1667 additions and 728 deletions
+77 -2
View File
@@ -3556,15 +3556,86 @@ dialog::backdrop {
.resource-actions {
display: flex;
flex: 0 0 auto;
flex: 0 1 auto;
gap: 8px;
flex-wrap: wrap;
max-width: 100%;
margin-left: auto;
}
.resource-actions button {
padding: 7px 10px;
}
.resource-password-menu {
position: relative;
flex: 0 0 auto;
}
.resource-password-menu>summary {
list-style: none;
user-select: none;
}
.resource-password-menu>summary::-webkit-details-marker {
display: none;
}
.resource-password-menu__chevron {
font-size: .72em;
line-height: 1;
transition: transform .16s ease;
}
.resource-password-menu[open] .resource-password-menu__chevron {
transform: rotate(180deg);
}
.resource-password-menu__panel {
position: absolute;
z-index: 50;
top: calc(100% + 6px);
right: 0;
display: grid;
gap: 3px;
min-width: 180px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--panel);
box-shadow: 0 12px 30px var(--shadow-28);
white-space: nowrap;
}
.resource-password-menu__item {
width: 100%;
min-height: 36px;
padding: 8px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-align: left;
cursor: pointer;
}
.resource-password-menu__item:hover,
.resource-password-menu__item:focus-visible {
background: var(--surface-3);
}
.resource-password-menu__item--danger {
color: var(--danger);
}
.resource-password-menu__item--danger:hover,
.resource-password-menu__item--danger:focus-visible {
background: var(--danger-subtle-bg);
}
@media (max-width:640px) {
.resource-row {
align-items: flex-start;
@@ -3580,8 +3651,10 @@ dialog::backdrop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
gap: 12px 16px;
width: 100%;
min-width: 0;
}
.resource-inline {
@@ -3630,6 +3703,7 @@ dialog::backdrop {
.resource-actions {
width: 100%;
margin-left: 0;
}
}
@@ -4348,6 +4422,7 @@ dialog::backdrop {
}
.resource-copy {
flex: 1 1 220px;
min-width: 0;
}
+40 -4
View File
@@ -126,6 +126,20 @@ function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); }
function renderResourcesPagination(meta) {
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
}
function closeResourcePasswordMenus(except = null) {
document.querySelectorAll(".resource-password-menu[open]").forEach(menu => {
if (menu !== except) menu.removeAttribute("open");
});
}
document.addEventListener("click", event => {
const menu = event.target.closest?.(".resource-password-menu");
closeResourcePasswordMenus(menu);
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") closeResourcePasswordMenus();
});
async function loadResources() {
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
try {
@@ -140,7 +154,10 @@ async function loadResources() {
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><button class="action-button action-button--secondary compact-button" type="button" data-password>Change password</button><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const passwordActions = item.protected
? `<button class="resource-password-menu__item" type="button" data-password>Change password</button><button class="resource-password-menu__item resource-password-menu__item--danger" type="button" data-remove-password>Remove password</button>`
: `<button class="resource-password-menu__item" type="button" data-password>Set password</button>`;
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button">Password…<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const inline = row.querySelector("[data-inline]");
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
@@ -267,16 +284,17 @@ async function loadResources() {
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
});
row.querySelector("[data-password]")?.addEventListener("click", () => {
row.querySelector("[data-password]")?.addEventListener("click", event => {
event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open");
inline.hidden = false;
inline.innerHTML = `<form class="resource-password-form" autocomplete="off"><label>New password<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters"></label><p class="resource-inline-help">Leave empty to remove password protection.</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="primary-button" type="submit">Save</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></form>`;
inline.innerHTML = `<form class="resource-password-form" autocomplete="off"><label>${item.protected ? "New password" : "Password"}<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters" required></label><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="primary-button" type="submit">Save</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></form>`;
const form = inline.querySelector("form");
const input = form.querySelector("input");
form.querySelector("[data-cancel]").addEventListener("click", closeInline);
form.addEventListener("submit", async event => {
event.preventDefault();
const password = input.value;
if (password && password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
if (password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
const submit = form.querySelector('[type="submit"]');
submit.disabled = true;
setInlineMessage("");
@@ -291,6 +309,24 @@ async function loadResources() {
input.focus();
});
row.querySelector("[data-remove-password]")?.addEventListener("click", event => {
event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open");
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Remove password protection from “${escapeHtml(item.title)}”?</p><p class="resource-inline-help">Anyone with the public link will be able to open it without a password.</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-remove-password>Remove password</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
inline.querySelector("[data-cancel]").addEventListener("click", closeInline);
inline.querySelector("[data-confirm-remove-password]").addEventListener("click", async event => {
event.currentTarget.disabled = true;
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password: "" }) });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
event.currentTarget.disabled = false;
}
});
});
row.querySelector("[data-delete]")?.addEventListener("click", () => {
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Delete “${escapeHtml(item.title)}” permanently?</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-delete>Delete</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
+6 -3
View File
@@ -38,7 +38,10 @@ export function createPadAdapter() {
method: "POST",
body: JSON.stringify({ kind: "pad", slug, password }),
}),
setPassword: password => api(`${base}/password`, { method: "POST", body: JSON.stringify({ password }) }),
setPassword: (password, clientId) => api(`${base}/password`, {
method: "POST",
body: JSON.stringify({ password, client_id: clientId || null }),
}),
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
method: "POST",
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
@@ -81,9 +84,9 @@ export function createWorkspaceNoteAdapter() {
method: "POST",
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
}),
setPassword: password => api(`/api/workspaces/${encode(workspaceSlug)}/password`, {
setPassword: (password, clientId) => api(`/api/workspaces/${encode(workspaceSlug)}/password`, {
method: "POST",
body: JSON.stringify({ password }),
body: JSON.stringify({ password, client_id: clientId || null }),
}),
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
method: "POST",
+19 -1
View File
@@ -1332,6 +1332,24 @@ export function startNoteEditor(adapter) {
onLatency: updateLatency,
onDiagnostics: renderConnectionDiagnostics,
onChat: appendChatMessage,
onPasswordRequired: async () => {
resourceUnlocked = false;
info = { ...info, protected: true, access_level: "none", can_set_password: false };
updatePageControls();
clearTimeout(saveTimer);
saveState.textContent = collaboration.hasPending()
? "Password required — pending changes kept"
: "Password required";
setDocumentReadOnly(true, "Password required");
document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue.";
if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus();
try {
await loadNoteInfo();
adapter.configureView?.(info);
updatePageControls();
} catch { }
},
onError: message => {
hideConnectionNotice();
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
@@ -1932,7 +1950,7 @@ export function startNoteEditor(adapter) {
const submit = setPagePasswordForm.querySelector('button[type="submit"]');
submit.disabled = true;
try {
await adapter.setPassword(passwordValue);
await adapter.setPassword(passwordValue, collaborationClientId);
const access = await adapter.requestAccess(passwordValue);
setAccessToken(adapter.access.kind, adapter.access.key, access.granted);
accessToken = getAccessToken(adapter.access.kind, adapter.access.key) || shareToken;
+11
View File
@@ -109,6 +109,17 @@ class RoomSocket {
try { message = JSON.parse(event.data); } catch { return; }
this.lastMessageAt = Date.now();
this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0);
if (message.type === "password_required") {
this.intentionalClose = true;
this.onPasswordRequired?.();
socket.close();
return;
}
if (message.type === "password_changed") {
this.intentionalClose = true;
socket.close();
return;
}
if (message.type === "error") {
this.intentionalClose = true;
this.onError?.(message.message);
+103 -5
View File
@@ -25,6 +25,12 @@ const shareToken = new URLSearchParams(location.search).get("share");
let accessToken = shareToken || getAccessToken("workspace", slug);
let nickname = getNickname();
getGuestId();
const workspaceWatchClientId = `workspace_watch_${crypto.randomUUID()}`;
let workspaceWatchSocket;
let workspaceWatchPingTimer;
let workspaceWatchReconnectTimer;
let workspaceWatchIntentionalClose = false;
let workspaceLockedForPassword = false;
const dialog = document.querySelector("#password-dialog");
const identityDialog = document.querySelector("#identity-dialog");
const workspaceContent = document.querySelector("#workspace-content");
@@ -45,6 +51,86 @@ function updateWorkspacePasswordControl() {
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
}
function stopWorkspaceWatch() {
clearInterval(workspaceWatchPingTimer);
clearTimeout(workspaceWatchReconnectTimer);
workspaceWatchPingTimer = undefined;
workspaceWatchReconnectTimer = undefined;
if (workspaceWatchSocket) {
workspaceWatchIntentionalClose = true;
workspaceWatchSocket.close();
workspaceWatchSocket = undefined;
}
}
function lockWorkspaceForPassword(message = "A password was set for this workspace. Enter it to continue.") {
workspaceLockedForPassword = true;
info = { ...info, protected: true, access_level: "none", can_set_password: false };
stopWorkspaceWatch();
workspaceContent.hidden = true;
document.querySelector("#password-error").textContent = message;
if (!dialog.open) dialog.showModal();
document.querySelector("#open-password")?.focus();
}
function scheduleWorkspaceWatchReconnect() {
clearTimeout(workspaceWatchReconnectTimer);
if (workspaceLockedForPassword || !nickname) return;
workspaceWatchReconnectTimer = window.setTimeout(connectWorkspaceWatch, 1500);
}
function connectWorkspaceWatch() {
if (workspaceLockedForPassword || !nickname) return;
if (workspaceWatchSocket?.readyState === WebSocket.OPEN || workspaceWatchSocket?.readyState === WebSocket.CONNECTING) return;
clearTimeout(workspaceWatchReconnectTimer);
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}/ws/watch/workspace/${encodeURIComponent(slug)}`);
workspaceWatchSocket = socket;
workspaceWatchIntentionalClose = false;
socket.addEventListener("open", () => {
if (socket !== workspaceWatchSocket) return;
socket.send(JSON.stringify({
type: "authenticate",
access_token: accessToken || null,
client_id: workspaceWatchClientId,
}));
clearInterval(workspaceWatchPingTimer);
workspaceWatchPingTimer = window.setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping", nonce: Date.now() }));
}, 10000);
});
socket.addEventListener("message", event => {
if (socket !== workspaceWatchSocket) return;
let message;
try { message = JSON.parse(event.data); } catch { return; }
if (message.type === "password_required") {
workspaceWatchIntentionalClose = true;
lockWorkspaceForPassword();
return;
}
if (message.type === "password_changed") {
workspaceWatchIntentionalClose = true;
socket.close();
return;
}
if (message.type === "error") {
workspaceWatchIntentionalClose = true;
socket.close();
if (/password/i.test(message.message || "")) lockWorkspaceForPassword(message.message);
}
});
socket.addEventListener("close", () => {
if (socket !== workspaceWatchSocket) return;
clearInterval(workspaceWatchPingTimer);
workspaceWatchPingTimer = undefined;
workspaceWatchSocket = undefined;
if (!workspaceWatchIntentionalClose) scheduleWorkspaceWatchReconnect();
});
socket.addEventListener("error", () => {
if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) socket.close();
});
}
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
@@ -143,11 +229,13 @@ async function openWorkspace() {
renderNotes();
renderNotesPagination(data.pagination);
updateWorkspacePasswordControl();
workspaceLockedForPassword = false;
workspaceContent.hidden = false;
if (dialog.open) dialog.close();
connectWorkspaceWatch();
} catch (e) {
if (info?.protected || e.message.toLowerCase().includes("password")) {
document.querySelector("#password-error").textContent = e.message;
if (!dialog.open) dialog.showModal();
lockWorkspaceForPassword(e.message);
} else if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
} else document.querySelector("#workspace-error").textContent = e.message;
@@ -160,7 +248,8 @@ async function init() {
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
updateWorkspacePasswordControl();
if (info.protected && info.access_level === "none") dialog.showModal(); else openWorkspace();
if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue.");
else await openWorkspace();
} catch (e) {
if (e.status === 403 || e.status === 404) await showSystemNotFound();
else document.querySelector("#workspace-error").textContent = e.message;
@@ -175,7 +264,7 @@ document.querySelector("#password-form").addEventListener("submit", async e => {
accessToken = getAccessToken("workspace", slug);
document.querySelector("#open-password").value = "";
document.querySelector("#password-error").textContent = "";
openWorkspace();
await openWorkspace();
} catch (error) { document.querySelector("#password-error").textContent = error.message; }
});
workspacePasswordForm.addEventListener("submit", async event => {
@@ -191,7 +280,7 @@ workspacePasswordForm.addEventListener("submit", async event => {
try {
await api(`/api/workspaces/${encodeURIComponent(slug)}/password`, {
method: "POST",
body: JSON.stringify({ password }),
body: JSON.stringify({ password, client_id: workspaceWatchClientId }),
});
const result = await api("/api/access-token", {
method: "POST",
@@ -278,5 +367,14 @@ identityDialog.addEventListener("close", () => {
});
});
dialog.addEventListener("cancel", event => {
if (workspaceLockedForPassword) {
event.preventDefault();
document.querySelector("#open-password")?.focus();
}
});
window.addEventListener("beforeunload", stopWorkspaceWatch);
setNotesView(notesView);
startAuthorizedWorkspace();