From 7766c80c6aeb934b14297091b86230d7877f92a3 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Thu, 26 Mar 2026 20:26:52 +0100 Subject: [PATCH 1/4] Add support for browser back/forward --- integ_tests/test_scenes.py | 21 +++++ .../js/scenes/CollectionsScene.js | 1 + .../js/scenes/CreateEditCollectionScene.js | 1 + .../js/scenes/CreateEditShareScene.js | 1 + .../js/scenes/DeleteConfirmationScene.js | 1 + .../js/scenes/IncomingSharingScene.js | 1 + .../internal_data/js/scenes/LoadingScene.js | 1 + .../web/internal_data/js/scenes/LoginScene.js | 1 + .../js/scenes/ShareCollectionScene.js | 1 + .../js/scenes/UploadCollectionScene.js | 1 + .../internal_data/js/scenes/scene_manager.js | 91 +++++++++++++++++++ 11 files changed, 121 insertions(+) diff --git a/integ_tests/test_scenes.py b/integ_tests/test_scenes.py index 1fd30061..68d1cefd 100644 --- a/integ_tests/test_scenes.py +++ b/integ_tests/test_scenes.py @@ -126,3 +126,24 @@ def test_navigation_refresh_button( page.click('#logoutview a[data-name="refresh"]') # It shows LoadingScene briefly then back to CollectionsScene expect(page.locator("#collectionsscene")).to_be_visible() + + +def test_navigation_browser_history( + context: BrowserContext, page: Page, radicale_server: str, config: Config +) -> None: + login(page, radicale_server, config, context=context) + expect(page.locator("#collectionsscene")).to_be_visible() + + # Navigate forward inside the SPA to CreateEditCollectionScene + page.click('a[data-name="new"]') + expect(page.locator("#createcollectionscene")).to_be_visible() + + # Emulate browser Back button + page.go_back() + expect(page.locator("#createcollectionscene")).to_be_hidden() + expect(page.locator("#collectionsscene")).to_be_visible() + + # Emulate browser Forward button + page.go_forward() + expect(page.locator("#collectionsscene")).to_be_hidden() + expect(page.locator("#createcollectionscene")).to_be_visible() diff --git a/radicale/web/internal_data/js/scenes/CollectionsScene.js b/radicale/web/internal_data/js/scenes/CollectionsScene.js index a22c6324..8a7e6277 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -290,5 +290,6 @@ export class CollectionsScene { }; this.release = function () { }; + this.is_transient = function () { return false; }; } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js index 422c36ac..efa7206d 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js @@ -211,6 +211,7 @@ export class CreateEditCollectionScene { submit_btn.onclick = null; cancel_btn.onclick = null; }; + this.is_transient = function () { return false; }; this.release = function () { if (create_edit_req !== null) { create_edit_req.abort(); diff --git a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js index 9bc79d14..a16f8bf4 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js @@ -365,5 +365,6 @@ export class CreateEditShareScene { this.release = function () { }; + this.is_transient = function () { return false; }; } } diff --git a/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js b/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js index d0d9b5a0..e2f9ffa7 100644 --- a/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js +++ b/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js @@ -134,6 +134,7 @@ export class DeleteConfirmationScene { cancel_btn.onclick = null; delete_btn.onclick = null; }; + this.is_transient = function () { return false; }; this.release = function () { if (delete_req !== null) { delete_req.abort(); diff --git a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js index 608cd71e..fad42a41 100644 --- a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js +++ b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js @@ -157,6 +157,7 @@ export class IncomingSharingScene { error_handler.clearError(); }; + this.is_transient = function () { return false; }; this.release = function () { error_handler.clearError(); nodes.forEach(function (node) { diff --git a/radicale/web/internal_data/js/scenes/LoadingScene.js b/radicale/web/internal_data/js/scenes/LoadingScene.js index fd1f5378..1dc5341c 100644 --- a/radicale/web/internal_data/js/scenes/LoadingScene.js +++ b/radicale/web/internal_data/js/scenes/LoadingScene.js @@ -36,4 +36,5 @@ export class LoadingScene { this.html_scene.classList.add("hidden"); } release() { } + is_transient() { return true; } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index aff56d6a..ca5f33de 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -183,6 +183,7 @@ export class LoginScene { html_scene.classList.add("hidden"); form.onsubmit = null; }; + this.is_transient = function () { return false; }; this.release = function () { // cancel pending requests if (principal_req !== null) { diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 99c5d301..3c1ea910 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -126,6 +126,7 @@ export class ShareCollectionScene { }; this.release = function () { }; + this.is_transient = function () { return false; }; } } diff --git a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js index 3b11e654..95845868 100644 --- a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js @@ -227,6 +227,7 @@ export class UploadCollectionScene { }); nodes = null; }; + this.is_transient = function () { return false; }; this.release = function () { if (upload_req !== null) { upload_req.abort(); diff --git a/radicale/web/internal_data/js/scenes/scene_manager.js b/radicale/web/internal_data/js/scenes/scene_manager.js index 0e79955c..efba9026 100644 --- a/radicale/web/internal_data/js/scenes/scene_manager.js +++ b/radicale/web/internal_data/js/scenes/scene_manager.js @@ -36,6 +36,11 @@ export class Scene { * Scene is removed from scene stack. */ release() { } + /** + * Whether the scene should be excluded from browser history. + * @returns boolean + */ + is_transient() { return false; } } @@ -44,6 +49,87 @@ export class Scene { */ let scene_stack = []; +/** @type {Array>} */ +let history_array = []; +let current_history_index = -1; +let is_navigating_history = false; + +function record_history() { + if (is_navigating_history) return; + + let current_scene = scene_stack.length > 0 ? scene_stack[scene_stack.length - 1] : null; + if (current_scene && current_scene.is_transient && current_scene.is_transient()) { + return; + } + + // Compare with current history to avoid duplicates + if (history_array.length > 0 && current_history_index >= 0) { + let last_stack = history_array[current_history_index]; + if (last_stack && last_stack.length === scene_stack.length) { + let is_identical = true; + for (let i = 0; i < scene_stack.length; i++) { + if (last_stack[i] !== scene_stack[i]) { + is_identical = false; + break; + } + } + if (is_identical) return; + } + } + + history_array.splice(current_history_index + 1); + history_array.push(scene_stack.slice()); + current_history_index++; + + // Check if this is the first history entry we are recording + if (typeof window !== "undefined" && window.history) { + if (current_history_index === 0) { + history.replaceState({ history_index: current_history_index }, ''); + } else { + history.pushState({ history_index: current_history_index }, ''); + } + } +} + +if (typeof window !== "undefined" && window.history) { + if (!history.state || typeof history.state.history_index !== "number") { + current_history_index = -1; + } else { + // If there's an existing state but we just loaded, we don't have the history_array memory. + // We'll reset it. The user will be redirected to the root if they go back out of bounds. + current_history_index = -1; + } + + window.addEventListener("popstate", (event) => { + if (!event.state || typeof event.state.history_index !== "number") return; + let new_index = event.state.history_index; + + if (new_index >= 0 && new_index < history_array.length) { + is_navigating_history = true; + try { + let new_stack = history_array[new_index]; + + if (scene_stack.length > 0) { + scene_stack[scene_stack.length - 1].hide(); + } + + scene_stack = new_stack.slice(); + + if (scene_stack.length > 0) { + scene_stack[scene_stack.length - 1].show(); + } + + current_history_index = new_index; + } finally { + is_navigating_history = false; + } + } else { + // Out of bounds (e.g., from a previous session after a reload) + window.location.reload(); + } + }); +} + /** * Push scene onto stack. * @param {Scene} scene @@ -54,6 +140,7 @@ export function push_scene(scene) { } scene_stack.push(scene); scene.show(); + record_history(); } /** @@ -80,6 +167,7 @@ export function replace_scene(scene) { } scene_stack.push(scene); scene.show(); + record_history(); } /** @@ -94,6 +182,7 @@ export function pop_scene() { if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].show(); } + record_history(); } /** @@ -112,6 +201,7 @@ export function pop_to_parent() { if (scene_stack.length >= 1) { scene_stack[scene_stack.length - 1].show(); } + record_history(); } /** @@ -131,6 +221,7 @@ export function pop_to_root() { if (scene_stack.length === 1) { scene_stack[0].show(); // Ensure the root scene is visible } + record_history(); } /** From e631734e6e5627b42dd02331bd99cbbec3224105 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 28 Mar 2026 23:37:12 +0100 Subject: [PATCH 2/4] Make class methods actual class methods instead of setting them in the constructor --- .../js/scenes/CollectionsScene.js | 496 +++++++-------- .../js/scenes/CreateEditCollectionScene.js | 321 +++++----- .../js/scenes/CreateEditShareScene.js | 577 +++++++++--------- .../js/scenes/DeleteConfirmationScene.js | 169 ++--- .../js/scenes/IncomingSharingScene.js | 223 ++++--- .../web/internal_data/js/scenes/LoginScene.js | 303 ++++----- .../js/scenes/ShareCollectionScene.js | 167 ++--- .../js/scenes/UploadCollectionScene.js | 354 +++++------ 8 files changed, 1326 insertions(+), 1284 deletions(-) diff --git a/radicale/web/internal_data/js/scenes/CollectionsScene.js b/radicale/web/internal_data/js/scenes/CollectionsScene.js index 8a7e6277..8c6c37d9 100644 --- a/radicale/web/internal_data/js/scenes/CollectionsScene.js +++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js @@ -45,251 +45,259 @@ export class CollectionsScene { * scene is popped. */ constructor(user, password, principal_collection, onerror) { - /** @type {HTMLElement} */ let html_scene = get_element_by_id("collectionsscene"); - /** @type {HTMLElement} */ let template = get_element(html_scene, "[data-name=collectiontemplate]"); - /** @type {HTMLElement} */ let new_btn = get_element(html_scene, "[data-name=new]"); - /** @type {HTMLElement} */ let upload_btn = get_element(html_scene, "[data-name=upload]"); - /** @type {HTMLElement} */ let incomingshares_btn = get_element(html_scene, "[data-name=incomingshares]"); - /** @type {HTMLElement} */ let error_div = get_element(html_scene, "[data-name=collectionsscene_error]"); + this._user = user; + this._password = password; + this._principal_collection = principal_collection; + this._onerror = onerror; - /** @type {Array} */ let nodes = []; - let errorHandler = new ErrorHandler(error_div); + this._html_scene = get_element_by_id("collectionsscene"); + this._template = get_element(this._html_scene, "[data-name=collectiontemplate]"); + this._new_btn = get_element(this._html_scene, "[data-name=new]"); + this._upload_btn = get_element(this._html_scene, "[data-name=upload]"); + this._incomingshares_btn = get_element(this._html_scene, "[data-name=incomingshares]"); + this._error_div = get_element(this._html_scene, "[data-name=collectionsscene_error]"); - function onnew() { - try { - let create_collection_scene = new CreateEditCollectionScene(user, password, principal_collection); - push_scene(create_collection_scene); - } catch (err) { - console.error(err); - } - return false; - } - - function onupload() { - try { - let upload_scene = new UploadCollectionScene(user, password, principal_collection); - push_scene(upload_scene); - } catch (err) { - console.error(err); - } - return false; - } - - function onincomingshares() { - try { - let incoming_sharing_scene = new IncomingSharingScene(user, password); - push_scene(incoming_sharing_scene); - } catch (err) { - console.error(err); - } - return false; - } - - /** - * @param {Collection} collection - */ - function onedit(collection) { - try { - let edit_collection_scene = new CreateEditCollectionScene(user, password, collection); - push_scene(edit_collection_scene); - } catch (err) { - console.error(err); - } - return false; - } - - /** - * @param {Collection} collection - */ - function onshare(collection) { - try { - let share_collection_scene = new ShareCollectionScene(user, password, collection); - push_scene(share_collection_scene); - } catch (err) { - console.error(err); - } - return false; - } - - /** - * @param {Collection} collection - */ - function ondelete(collection) { - try { - let delete_collection_scene = new DeleteConfirmationScene( - user, password, "Delete Collection", collection, collection.displayname || collection.href, - delete_collection, true - ); - push_scene(delete_collection_scene); - } catch (err) { - console.error(err); - } - return false; - } - - /** - * @param {any[]} collections - * @param {import("../api/sharing.js").Share[]} shares - * @param {boolean} clear_error - */ - function show_collections(collections, shares, clear_error) { - /** @type {HTMLElement} */ let navBar = get_element(document, "#logoutview"); - let heightOfNavBar = navBar.offsetHeight + "px"; - html_scene.style.marginTop = heightOfNavBar; - html_scene.style.height = "calc(100vh - " + heightOfNavBar + ")"; - - if (clear_error) { - errorHandler.clearError(); - } - - // Clear old nodes - nodes.forEach(function (node) { - if (node.parentNode) { - node.parentNode.removeChild(node); - } - }); - nodes = []; - - collections.forEach(function (/** @type {Collection} */ collection) { - /** @type {HTMLElement} */ let node = /** @type {HTMLElement} */(template.cloneNode(true)); - node.classList.remove("hidden"); - /** @type {HTMLElement} */ let title_form = get_element(node, "[data-name=title]"); - /** @type {HTMLElement} */ let description_form = get_element(node, "[data-name=description]"); - /** @type {HTMLElement} */ let contentcount_form = get_element(node, "[data-name=contentcount]"); - /** @type {HTMLInputElement} */ let url_form = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=url]")); - /** @type {HTMLElement} */ let color_form = get_element(node, "[data-name=color]"); - /** @type {HTMLElement} */ let delete_btn = get_element(node, "[data-name=delete]"); - /** @type {HTMLElement} */ let edit_btn = get_element(node, "[data-name=edit]"); - /** @type {HTMLElement} */ let share_btn = get_element(node, "[data-name=share]"); - /** @type {HTMLAnchorElement} */ let download_btn = /** @type {HTMLAnchorElement} */ (get_element(node, "[data-name=download]")); - if (collection.color) { - color_form.style.background = collection.color; - } - let possible_types = [CollectionType.ADDRESSBOOK, CollectionType.WEBCAL]; - [CollectionType.CALENDAR, ""].forEach(function (e) { - [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function (e) { - [CollectionType.union(e, CollectionType.TASKS), e].forEach(function (e) { - if (e) { - possible_types.push(e); - } - }); - }); - }); - possible_types.forEach(function (e) { - if (e !== collection.type) { - get_element(node, "[data-name=" + e + "]").classList.add("hidden"); - } - }); - let share_info = get_element(node, "[data-name=shared-by]"); - let transformed_from = get_element(node, "[data-name=transformed-from]"); - let share = (shares || []).find( - s => (s.ShareType === "map") && - (s.PathOrToken || "").replace(/\/+$/, "") === (collection.href || "").replace(/\/+$/, "")); - if (share) { - if (share.Owner !== user) { - share_info.classList.remove("hidden"); - get_element(node, "[data-name=shared-by-owner]").textContent = share.Owner; - } else { - transformed_from.classList.remove("hidden"); - } - let share_option = get_element(node, "[data-name=shareoption]"); - if (share_option) { - share_option.classList.add("hidden"); - share_option.removeAttribute("data-name"); - } - delete_btn.classList.add("hidden"); - if (!/w/i.test(share.Permissions || "")) { - edit_btn.classList.add("hidden"); - } else { - edit_btn.classList.remove("hidden"); - } - } - title_form.textContent = collection.displayname || collection.href; - if (title_form.textContent.length > 30) { - title_form.classList.add("smalltext"); - } - description_form.textContent = collection.description; - if (description_form.textContent.length > 150) { - description_form.classList.add("smalltext"); - } - if (collection.type != CollectionType.WEBCAL) { - let contentcount_form_txt = (collection.contentcount > 0 ? Number(collection.contentcount).toLocaleString() : "No") + " item" + (collection.contentcount == 1 ? "" : "s") + " in collection"; - if (collection.contentcount > 0) { - contentcount_form_txt += " (" + bytesToHumanReadable(collection.size) + ")"; - } - contentcount_form.textContent = contentcount_form_txt; - } - let href = SERVER + collection.href; - url_form.value = href; - download_btn.href = href; - download_btn.onclick = function (event) { - event.preventDefault(); - let auth = get_auth_header(user, password); - let headers = auth ? { - 'Authorization': auth - } : undefined; - fetch(href, { headers: headers }).then(function (response) { - if (response.ok) { - return response.blob(); - } - throw new Error("Download failed: " + response.statusText); - }).then(function (blob) { - let url = window.URL.createObjectURL(blob); - let a = document.createElement("a"); - a.href = url; - a.download = (collection.displayname || collection.href).replace(/\/+$/, "") + (collection.type === CollectionType.ADDRESSBOOK ? ".vcf" : ".ics"); - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - })["catch"](function (error) { - errorHandler.setError(error.message); - }); - }; - if (collection.type == CollectionType.WEBCAL) { - if (download_btn.parentElement) { - download_btn.parentElement.classList.add("hidden"); - } - } - delete_btn.onclick = function () { return ondelete(collection); }; - edit_btn.onclick = function () { return onedit(collection); }; - share_btn.onclick = function () { return onshare(collection); }; - node.classList.remove("hidden"); - nodes.push(node); - if (template.parentNode) { - template.parentNode.insertBefore(node, template); - } - }); - } - - this.errorwrapper = function (/** @type {string} */ error) { - errorHandler.setError(error); - if (onerror) onerror(error); - }; - - this.show = function () { - html_scene.classList.remove("hidden"); - new_btn.onclick = onnew; - upload_btn.onclick = onupload; - incomingshares_btn.onclick = onincomingshares; - collectionsCache.getChildCollections(user, password, principal_collection, this.errorwrapper, show_collections); - collectionsCache.getServerFeatures(user, password, this.errorwrapper, maybe_enable_sharing_options); - }; - this.hide = function () { - html_scene.classList.add("hidden"); - new_btn.onclick = null; - upload_btn.onclick = null; - incomingshares_btn.onclick = null; - // remove collection - nodes.forEach(function (node) { - if (node.parentNode) { - node.parentNode.removeChild(node); - } - }); - nodes = []; - }; - this.release = function () { - }; - this.is_transient = function () { return false; }; + /** @type {Array} */ this._nodes = []; + this._errorHandler = new ErrorHandler(this._error_div); } + + _onnew() { + try { + let create_collection_scene = new CreateEditCollectionScene(this._user, this._password, this._principal_collection); + push_scene(create_collection_scene); + } catch (err) { + console.error(err); + } + return false; + } + + _onupload() { + try { + let upload_scene = new UploadCollectionScene(this._user, this._password, this._principal_collection); + push_scene(upload_scene); + } catch (err) { + console.error(err); + } + return false; + } + + _onincomingshares() { + try { + let incoming_sharing_scene = new IncomingSharingScene(this._user, this._password); + push_scene(incoming_sharing_scene); + } catch (err) { + console.error(err); + } + return false; + } + + /** + * @param {Collection} collection + */ + _onedit(collection) { + try { + let edit_collection_scene = new CreateEditCollectionScene(this._user, this._password, collection); + push_scene(edit_collection_scene); + } catch (err) { + console.error(err); + } + return false; + } + + /** + * @param {Collection} collection + */ + _onshare(collection) { + try { + let share_collection_scene = new ShareCollectionScene(this._user, this._password, collection); + push_scene(share_collection_scene); + } catch (err) { + console.error(err); + } + return false; + } + + /** + * @param {Collection} collection + */ + _ondelete(collection) { + try { + let delete_collection_scene = new DeleteConfirmationScene( + this._user, this._password, "Delete Collection", collection, collection.displayname || collection.href, + delete_collection, true + ); + push_scene(delete_collection_scene); + } catch (err) { + console.error(err); + } + return false; + } + + /** + * @param {any[]} collections + * @param {import("../api/sharing.js").Share[]} shares + * @param {boolean} clear_error + */ + _show_collections(collections, shares, clear_error) { + /** @type {HTMLElement} */ let navBar = get_element(document, "#logoutview"); + let heightOfNavBar = navBar.offsetHeight + "px"; + this._html_scene.style.marginTop = heightOfNavBar; + this._html_scene.style.height = "calc(100vh - " + heightOfNavBar + ")"; + + if (clear_error) { + this._errorHandler.clearError(); + } + + // Clear old nodes + this._nodes.forEach(function (node) { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + }); + this._nodes = []; + + collections.forEach((/** @type {Collection} */ collection) => { + /** @type {HTMLElement} */ let node = /** @type {HTMLElement} */(this._template.cloneNode(true)); + node.classList.remove("hidden"); + /** @type {HTMLElement} */ let title_form = get_element(node, "[data-name=title]"); + /** @type {HTMLElement} */ let description_form = get_element(node, "[data-name=description]"); + /** @type {HTMLElement} */ let contentcount_form = get_element(node, "[data-name=contentcount]"); + /** @type {HTMLInputElement} */ let url_form = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=url]")); + /** @type {HTMLElement} */ let color_form = get_element(node, "[data-name=color]"); + /** @type {HTMLElement} */ let delete_btn = get_element(node, "[data-name=delete]"); + /** @type {HTMLElement} */ let edit_btn = get_element(node, "[data-name=edit]"); + /** @type {HTMLElement} */ let share_btn = get_element(node, "[data-name=share]"); + /** @type {HTMLAnchorElement} */ let download_btn = /** @type {HTMLAnchorElement} */ (get_element(node, "[data-name=download]")); + if (collection.color) { + color_form.style.background = collection.color; + } + let possible_types = [CollectionType.ADDRESSBOOK, CollectionType.WEBCAL]; + [CollectionType.CALENDAR, ""].forEach(function (e) { + [CollectionType.union(e, CollectionType.JOURNAL), e].forEach(function (e) { + [CollectionType.union(e, CollectionType.TASKS), e].forEach(function (e) { + if (e) { + possible_types.push(e); + } + }); + }); + }); + possible_types.forEach(function (e) { + if (e !== collection.type) { + get_element(node, "[data-name=" + e + "]").classList.add("hidden"); + } + }); + let share_info = get_element(node, "[data-name=shared-by]"); + let transformed_from = get_element(node, "[data-name=transformed-from]"); + let share = (shares || []).find( + s => (s.ShareType === "map") && + (s.PathOrToken || "").replace(/\/+$/, "") === (collection.href || "").replace(/\/+$/, "")); + if (share) { + if (share.Owner !== this._user) { + share_info.classList.remove("hidden"); + get_element(node, "[data-name=shared-by-owner]").textContent = share.Owner; + } else { + transformed_from.classList.remove("hidden"); + } + let share_option = get_element(node, "[data-name=shareoption]"); + if (share_option) { + share_option.classList.add("hidden"); + share_option.removeAttribute("data-name"); + } + delete_btn.classList.add("hidden"); + if (!/w/i.test(share.Permissions || "")) { + edit_btn.classList.add("hidden"); + } else { + edit_btn.classList.remove("hidden"); + } + } + title_form.textContent = collection.displayname || collection.href; + if (title_form.textContent.length > 30) { + title_form.classList.add("smalltext"); + } + description_form.textContent = collection.description; + if (description_form.textContent.length > 150) { + description_form.classList.add("smalltext"); + } + if (collection.type != CollectionType.WEBCAL) { + let contentcount_form_txt = (collection.contentcount > 0 ? Number(collection.contentcount).toLocaleString() : "No") + " item" + (collection.contentcount == 1 ? "" : "s") + " in collection"; + if (collection.contentcount > 0) { + contentcount_form_txt += " (" + bytesToHumanReadable(collection.size) + ")"; + } + contentcount_form.textContent = contentcount_form_txt; + } + let href = SERVER + collection.href; + url_form.value = href; + download_btn.href = href; + download_btn.onclick = (event) => { + event.preventDefault(); + let auth = get_auth_header(this._user, this._password); + let headers = auth ? { + 'Authorization': auth + } : undefined; + fetch(href, { headers: headers }).then(function (response) { + if (response.ok) { + return response.blob(); + } + throw new Error("Download failed: " + response.statusText); + }).then(function (blob) { + let url = window.URL.createObjectURL(blob); + let a = document.createElement("a"); + a.href = url; + a.download = (collection.displayname || collection.href).replace(/\/+$/, "") + (collection.type === CollectionType.ADDRESSBOOK ? ".vcf" : ".ics"); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + })["catch"]((error) => { + this._errorHandler.setError(error.message); + }); + }; + if (collection.type == CollectionType.WEBCAL) { + if (download_btn.parentElement) { + download_btn.parentElement.classList.add("hidden"); + } + } + delete_btn.onclick = () => { return this._ondelete(collection); }; + edit_btn.onclick = () => { return this._onedit(collection); }; + share_btn.onclick = () => { return this._onshare(collection); }; + node.classList.remove("hidden"); + this._nodes.push(node); + if (this._template.parentNode) { + this._template.parentNode.insertBefore(node, this._template); + } + }); + } + + _errorwrapper(/** @type {string} */ error) { + this._errorHandler.setError(error); + if (this._onerror) this._onerror(error); + } + + show() { + this._html_scene.classList.remove("hidden"); + this._new_btn.onclick = () => this._onnew(); + this._upload_btn.onclick = () => this._onupload(); + this._incomingshares_btn.onclick = () => this._onincomingshares(); + collectionsCache.getChildCollections(this._user, this._password, this._principal_collection, (e) => this._errorwrapper(e), (c, s, ce) => this._show_collections(c, s, ce)); + collectionsCache.getServerFeatures(this._user, this._password, (e) => this._errorwrapper(e), maybe_enable_sharing_options); + } + + hide() { + this._html_scene.classList.add("hidden"); + this._new_btn.onclick = null; + this._upload_btn.onclick = null; + this._incomingshares_btn.onclick = null; + // remove collection + this._nodes.forEach(function (node) { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + }); + this._nodes = []; + } + + release() { + } + + is_transient() { return false; } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js index efa7206d..f0a8c92a 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js @@ -41,182 +41,187 @@ export class CreateEditCollectionScene { * Otherwise the collection will be edited. */ constructor(user, password, collection) { - let edit = collection.type !== CollectionType.PRINCIPAL; - let html_scene = get_element_by_id(edit ? "editcollectionscene" : "createcollectionscene"); - /** @type {?HTMLElement} */ let title_form = edit ? get_element(html_scene, "[data-name=title]") : null; - /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); - /** @type {?HTMLInputElement} */ let href_form = edit ? null : /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=href]")); - /** @type {HTMLInputElement} */ let displayname_form = /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=displayname]")); - /** @type {HTMLInputElement} */ let description_form = /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=description]")); - /** @type {HTMLInputElement} */ let source_form = /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=source]")); - /** @type {HTMLElement} */ let source_label = get_element(html_scene, "label[for=source]"); - /** @type {HTMLSelectElement} */ let type_form = /** @type {HTMLSelectElement} */(get_element(html_scene, "[data-name=type]")); - /** @type {HTMLInputElement} */ let color_form = /** @type {HTMLInputElement} */(get_element(html_scene, "[data-name=color]")); - /** @type {HTMLElement} */ let submit_btn = get_element(html_scene, "[data-name=submit]"); - /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); + this._user = user; + this._password = password; + this._collection = collection; + this._edit = collection.type !== CollectionType.PRINCIPAL; + this._html_scene = get_element_by_id(this._edit ? "editcollectionscene" : "createcollectionscene"); + this._title_form = this._edit ? get_element(this._html_scene, "[data-name=title]") : null; + this._error_form = get_element(this._html_scene, "[data-name=error]"); + this._href_form = this._edit ? null : /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=href]")); + this._displayname_form = /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=displayname]")); + this._description_form = /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=description]")); + this._source_form = /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=source]")); + this._source_label = get_element(this._html_scene, "label[for=source]"); + this._type_form = /** @type {HTMLSelectElement} */(get_element(this._html_scene, "[data-name=type]")); + this._color_form = /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=color]")); + this._submit_btn = get_element(this._html_scene, "[data-name=submit]"); + this._cancel_btn = get_element(this._html_scene, "[data-name=cancel]"); - /** @type {?XMLHttpRequest} */ let create_edit_req = null; - /** @type {?HTMLSelectElement} */ let saved_type_form = null; + /** @type {?XMLHttpRequest} */ this._create_edit_req = null; + /** @type {?HTMLSelectElement} */ this._saved_type_form = null; - let errorHandler = new ErrorHandler(error_form); - let validator = new FormValidator(errorHandler); + this._errorHandler = new ErrorHandler(this._error_form); + this._validator = new FormValidator(this._errorHandler); - if (!edit && href_form) { - validator.addValidator(href_form, validate_href(href_form, "HREF")); + if (!this._edit && this._href_form) { + this._validator.addValidator(this._href_form, validate_href(this._href_form, "HREF")); } - validator.addValidator(color_form, validate_color(color_form, "Color")); + this._validator.addValidator(this._color_form, validate_color(this._color_form, "Color")); - let href = edit ? collection.href : collection.href + random_uuid() + "/"; - let displayname = edit ? collection.displayname : ""; - let description = edit ? collection.description : ""; - let source = edit ? collection.source : ""; - let type = edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; - let color = edit && collection.color ? collection.color : "#" + random_hex(6); + this._href = this._edit ? collection.href : collection.href + random_uuid() + "/"; + this._displayname = this._edit ? collection.displayname : ""; + this._description = this._edit ? collection.description : ""; + this._source = this._edit ? collection.source : ""; + this._type = this._edit ? collection.type : CollectionType.CALENDAR_JOURNAL_TASKS; + this._color = this._edit && collection.color ? collection.color : "#" + random_hex(6); - if (!edit && href_form) { - href_form.addEventListener("input", onCleanHREFinput); + if (!this._edit && this._href_form) { + this._href_form.addEventListener("input", onCleanHREFinput); } + } - function remove_invalid_types() { - if (!edit) { - return; + _remove_invalid_types() { + if (!this._edit) { + return; + } + /** @type {HTMLOptionsCollection} */ let options = this._type_form.options; + let valid_type_options = CollectionType.valid_options_for_type(this._type); + for (let i = options.length - 1; i >= 0; i--) { + if (valid_type_options.indexOf(options[i].value) < 0) { + options.remove(i); } - /** @type {HTMLOptionsCollection} */ let options = type_form.options; - // remove all options that are not supersets - let valid_type_options = CollectionType.valid_options_for_type(type); - for (let i = options.length - 1; i >= 0; i--) { - if (valid_type_options.indexOf(options[i].value) < 0) { - options.remove(i); + } + } + + _read_form() { + if (!this._edit && this._href_form) { + cleanHREFinput(this._href_form); + let newhreftxtvalue = this._href_form.value.trim().toLowerCase(); + this._href = this._collection.href + newhreftxtvalue + "/"; + } + this._displayname = this._displayname_form.value; + this._description = this._description_form.value; + this._source = this._source_form.value; + this._type = this._type_form.value; + this._color = this._color_form.value; + return true; + } + + _fill_form() { + if (!this._edit && this._href_form) { + this._href_form.value = random_uuid(); + } + this._displayname_form.value = this._displayname; + this._description_form.value = this._description; + this._source_form.value = this._source; + this._type_form.value = this._type; + this._color_form.value = this._color; + this._onTypeChange(null); + this._type_form.addEventListener("change", (e) => this._onTypeChange(e)); + } + + _onsubmit() { + try { + if (!this._validator.validate()) { + return false; + } + this._read_form(); + let sane_color = this._color.trim(); + if (sane_color) { + /** @type {?RegExpExecArray} */ let match = COLOR_RE.exec(sane_color); + if (match) { + sane_color = match[1]; } } - } - - function read_form() { - if (!edit && href_form) { - cleanHREFinput(href_form); - let newhreftxtvalue = href_form.value.trim().toLowerCase(); - href = collection.href + newhreftxtvalue + "/"; - } - displayname = displayname_form.value; - description = description_form.value; - source = source_form.value; - type = type_form.value; - color = color_form.value; - return true; - } - - function fill_form() { - if (!edit && href_form) { - href_form.value = random_uuid(); - } - displayname_form.value = displayname; - description_form.value = description; - source_form.value = source; - type_form.value = type; - color_form.value = color; - onTypeChange(null); - type_form.addEventListener("change", onTypeChange); - } - - function onsubmit() { - try { - if (!validator.validate()) { - return false; + let loading_scene = new LoadingScene(); + push_scene(loading_scene); + let collection = new Collection(this._href, this._type, this._displayname, this._description, sane_color, 0, 0, this._source); + let callback = (/** @type {?string} */ error1) => { + if (!is_current_scene(loading_scene)) { + return; } - read_form(); - let sane_color = color.trim(); - if (sane_color) { - /** @type {?RegExpExecArray} */ let match = COLOR_RE.exec(sane_color); - if (match) { - sane_color = match[1]; - } - } - let loading_scene = new LoadingScene(); - push_scene(loading_scene); - let collection = new Collection(href, type, displayname, description, sane_color, 0, 0, source); - let callback = function (/** @type {?string} */ error1) { - if (!is_current_scene(loading_scene)) { - return; - } - create_edit_req = null; - if (error1) { - errorHandler.setError(error1); - pop_scene(); - } else { - collectionsCache.invalidate(); - pop_to_parent(); - } - }; - if (edit) { - create_edit_req = edit_collection(user, password, collection, callback); + this._create_edit_req = null; + if (error1) { + this._errorHandler.setError(error1); + pop_scene(); } else { - create_edit_req = create_collection(user, password, collection, callback); + collectionsCache.invalidate(); + pop_to_parent(); } - } catch (err) { - console.error(err); - } - return false; - } - - function oncancel() { - try { - pop_scene(); - } catch (err) { - console.error(err); - } - return false; - } - - /** - * @param {?Event} _e - */ - function onTypeChange(_e) { - if (type_form.value == CollectionType.WEBCAL) { - source_label.classList.remove("hidden"); - source_form.classList.remove("hidden"); + }; + if (this._edit) { + this._create_edit_req = edit_collection(this._user, this._password, collection, callback); } else { - source_label.classList.add("hidden"); - source_form.classList.add("hidden"); + this._create_edit_req = create_collection(this._user, this._password, collection, callback); } + } catch (err) { + console.error(err); } + return false; + } - this.show = function () { - this.release(); - // Clone type_form because it's impossible to hide options without removing them - saved_type_form = type_form; - type_form = /** @type {HTMLSelectElement} */ (type_form.cloneNode(true)); - if (saved_type_form.parentNode) { - saved_type_form.parentNode.replaceChild(type_form, saved_type_form); - } - remove_invalid_types(); - html_scene.classList.remove("hidden"); - if (edit && title_form) { - title_form.textContent = collection.displayname || collection.href; - } - fill_form(); - submit_btn.onclick = onsubmit; - cancel_btn.onclick = oncancel; - validator.validate(); - }; - this.hide = function () { - read_form(); - html_scene.classList.add("hidden"); - // restore type_form - if (type_form.parentNode && saved_type_form) { - type_form.parentNode.replaceChild(saved_type_form, type_form); - type_form = saved_type_form; - } - saved_type_form = null; - submit_btn.onclick = null; - cancel_btn.onclick = null; - }; - this.is_transient = function () { return false; }; - this.release = function () { - if (create_edit_req !== null) { - create_edit_req.abort(); - create_edit_req = null; - } - }; + _oncancel() { + try { + pop_scene(); + } catch (err) { + console.error(err); + } + return false; + } + + /** + * @param {?Event} _e + */ + _onTypeChange(_e) { + if (this._type_form.value == CollectionType.WEBCAL) { + this._source_label.classList.remove("hidden"); + this._source_form.classList.remove("hidden"); + } else { + this._source_label.classList.add("hidden"); + this._source_form.classList.add("hidden"); + } + } + + show() { + this.release(); + // Clone type_form because it's impossible to hide options without removing them + this._saved_type_form = this._type_form; + this._type_form = /** @type {HTMLSelectElement} */ (this._type_form.cloneNode(true)); + if (this._saved_type_form.parentNode) { + this._saved_type_form.parentNode.replaceChild(this._type_form, this._saved_type_form); + } + this._remove_invalid_types(); + this._html_scene.classList.remove("hidden"); + if (this._edit && this._title_form) { + this._title_form.textContent = this._collection.displayname || this._collection.href; + } + this._fill_form(); + this._submit_btn.onclick = () => this._onsubmit(); + this._cancel_btn.onclick = () => this._oncancel(); + this._validator.validate(); + } + + hide() { + this._read_form(); + this._html_scene.classList.add("hidden"); + // restore type_form + if (this._type_form.parentNode && this._saved_type_form) { + this._type_form.parentNode.replaceChild(this._saved_type_form, this._type_form); + this._type_form = this._saved_type_form; + } + this._saved_type_form = null; + this._submit_btn.onclick = null; + this._cancel_btn.onclick = null; + } + + is_transient() { return false; } + + release() { + if (this._create_edit_req !== null) { + this._create_edit_req.abort(); + this._create_edit_req = null; + } } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js index a16f8bf4..6f8e74b3 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js @@ -39,332 +39,339 @@ export class CreateEditShareScene { * @param {Share} [share] If provided, the scene will be in edit mode. */ constructor(user, password, collection, shareType, share) { - let self = this; - let edit = !!share; - let pathMapped = collection.href; - /** @type {HTMLElement} */ let html_scene = get_element_by_id("newshare"); - /** @type {HTMLFormElement} */ let form = /** @type {HTMLFormElement} */ (get_element(html_scene, "form")); - /** @type {HTMLElement} */ let sharemapfields = get_element(html_scene, "[data-name=sharemapfields]"); - /** @type {HTMLInputElement} */ let shareuser_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=shareuser]")); - /** @type {HTMLInputElement} */ let sharehref_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=sharehref]")); - /** @type {HTMLInputElement} */ let enabled_checkbox = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=enabled]")); - /** @type {HTMLInputElement} */ let hidden_checkbox = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=hidden]")); - let permissions_ro_radio = /** @type {HTMLInputElement} */ (get_element_by_id("newshare_attr_permissions_ro")); - let permissions_rw_radio = /** @type {HTMLInputElement} */ (get_element_by_id("newshare_attr_permissions_rw")); - /** @type {HTMLDetailsElement} */ let conversions_details = /** @type {HTMLDetailsElement} */ (get_element(html_scene, "[data-name=conversions]")); - /** @type {HTMLElement} */ let conversions_container = get_element(html_scene, "[data-name=conversions_container]"); + this._user = user; + this._password = password; + this._collection = collection; + this._shareType = shareType; + this._share = share; + this._edit = !!share; + this._pathMapped = collection.href; - /** @type {HTMLDetailsElement} */ let properties_fieldset = /** @type {HTMLDetailsElement} */ (get_element(html_scene, "[data-name=properties_override]")); - /** @type {HTMLInputElement} */ let displayname_override_enabled = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=displayname_override_enabled]")); - /** @type {HTMLInputElement} */ let displayname_override_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=displayname_override]")); - /** @type {HTMLInputElement} */ let description_override_enabled = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=description_override_enabled]")); - /** @type {HTMLInputElement} */ let description_override_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=description_override]")); - /** @type {HTMLInputElement} */ let color_override_enabled = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=color_override_enabled]")); - /** @type {HTMLInputElement} */ let color_override_input = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=color_override]")); + this._html_scene = get_element_by_id("newshare"); + this._form = /** @type {HTMLFormElement} */ (get_element(this._html_scene, "form")); + this._sharemapfields = get_element(this._html_scene, "[data-name=sharemapfields]"); + this._shareuser_input = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=shareuser]")); + this._sharehref_input = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=sharehref]")); + this._enabled_checkbox = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=enabled]")); + this._hidden_checkbox = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=hidden]")); + this._permissions_ro_radio = /** @type {HTMLInputElement} */ (get_element_by_id("newshare_attr_permissions_ro")); + this._permissions_rw_radio = /** @type {HTMLInputElement} */ (get_element_by_id("newshare_attr_permissions_rw")); + this._conversions_details = /** @type {HTMLDetailsElement} */ (get_element(this._html_scene, "[data-name=conversions]")); + this._conversions_container = get_element(this._html_scene, "[data-name=conversions_container]"); - /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); - /** @type {HTMLElement} */ let submit_btn = get_element(html_scene, "[data-name=submit]"); - /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); + this._properties_fieldset = /** @type {HTMLDetailsElement} */ (get_element(this._html_scene, "[data-name=properties_override]")); + this._displayname_override_enabled = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=displayname_override_enabled]")); + this._displayname_override_input = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=displayname_override]")); + this._description_override_enabled = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=description_override_enabled]")); + this._description_override_input = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=description_override]")); + this._color_override_enabled = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=color_override_enabled]")); + this._color_override_input = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=color_override]")); - let errorHandler = new ErrorHandler(error_form); - let map_validator = new FormValidator(errorHandler); + this._error_form = get_element(this._html_scene, "[data-name=error]"); + this._submit_btn = get_element(this._html_scene, "[data-name=submit]"); + this._cancel_btn = get_element(this._html_scene, "[data-name=cancel]"); - map_validator.addValidator(shareuser_input, function () { - let conversion = get_selected_conversion(); + this._errorHandler = new ErrorHandler(this._error_form); + this._map_validator = new FormValidator(this._errorHandler); + + this._map_validator.addValidator(this._shareuser_input, () => { + let conversion = this._get_selected_conversion(); if (conversion === "bday") { - return validate_non_empty(shareuser_input, "Share User")(); + return validate_non_empty(this._shareuser_input, "Share User")(); } else { - return validate_not_empty_or_equals(shareuser_input, user, "Share User")(); + return validate_not_empty_or_equals(this._shareuser_input, user, "Share User")(); } }); - map_validator.addValidator(sharehref_input, validate_href(sharehref_input, "Share Href")); + this._map_validator.addValidator(this._sharehref_input, validate_href(this._sharehref_input, "Share Href")); - sharehref_input.addEventListener("input", onCleanHREFinput); + this._sharehref_input.addEventListener("input", onCleanHREFinput); - displayname_override_enabled.onchange = function () { - displayname_override_input.disabled = !displayname_override_enabled.checked; + this._displayname_override_enabled.onchange = () => { + this._displayname_override_input.disabled = !this._displayname_override_enabled.checked; }; - description_override_enabled.onchange = function () { - description_override_input.disabled = !description_override_enabled.checked; + this._description_override_enabled.onchange = () => { + this._description_override_input.disabled = !this._description_override_enabled.checked; }; - color_override_enabled.onchange = function () { - color_override_input.disabled = !color_override_enabled.checked; + this._color_override_enabled.onchange = () => { + this._color_override_input.disabled = !this._color_override_enabled.checked; }; - function get_selected_conversion() { - /** @type {HTMLInputElement | null} */ - let checked = conversions_container.querySelector("input[name=conversion]:checked"); - return checked ? checked.value : "none"; + } + + _get_selected_conversion() { + /** @type {HTMLInputElement | null} */ + let checked = this._conversions_container.querySelector("input[name=conversion]:checked"); + return checked ? checked.value : "none"; + } + + _on_conversion_change() { + let conversion = this._get_selected_conversion(); + if (conversion === "bday") { + this._permissions_ro_radio.checked = true; + this._permissions_rw_radio.checked = false; + this._permissions_ro_radio.disabled = true; + this._permissions_rw_radio.disabled = true; + this._enabled_checkbox.checked = true; + this._hidden_checkbox.checked = false; + } else { + this._permissions_ro_radio.disabled = false; + this._permissions_rw_radio.disabled = false; } + if (this._shareType === "map") { + this._map_validator.validate(); + } + } - function on_conversion_change() { - let conversion = get_selected_conversion(); - if (conversion === "bday") { - permissions_ro_radio.checked = true; - permissions_rw_radio.checked = false; - permissions_ro_radio.disabled = true; - permissions_rw_radio.disabled = true; - enabled_checkbox.checked = true; - hidden_checkbox.checked = false; + _oncancel() { + try { + pop_scene(); + } catch (err) { + console.error(err); + } + return false; + } + + _onsubmit() { + try { + if (this._shareType === "map") { + if (!this._map_validator.validate()) { + return false; + } + } + let conversion = this._get_selected_conversion(); + let is_birthday = conversion === "bday"; + let enabled_by_owner = is_birthday ? true : this._enabled_checkbox.checked; + let hidden_by_owner = is_birthday ? false : this._hidden_checkbox.checked; + let permissions = is_birthday ? "r" : (this._permissions_rw_radio.checked ? "rw" : "r"); + /** @type {string} */ let conversion_value = conversion; + + /** @type {Object} */ let properties = {}; + if (this._displayname_override_enabled.checked) { + let key = get_property_key(this._collection.type, "DISPLAYNAME"); + if (key) properties[key] = this._displayname_override_input.value; + } + if (this._description_override_enabled.checked) { + let key = get_property_key(this._collection.type, "DESCRIPTION"); + if (key) properties[key] = this._description_override_input.value; + } + if (this._color_override_enabled.checked) { + let key = get_property_key(this._collection.type, "COLOR"); + if (key) properties[key] = this._color_override_input.value + (this._color_override_input.value ? "ff" : ""); + } + + let callback = (/** @type {?string} */ error) => { + if (!is_current_scene(this)) { + return; + } + if (error) { + this._errorHandler.setError(error); + } else { + // On any share-to-self (currently only bday conversion), invalidate the + // collections cache so the virtual calendar appears immediately. + if (this._shareuser_input.value === this._user) { + collectionsCache.invalidate(); + } + pop_scene(); + } + }; + + let new_share = new Share({ + ShareType: this._shareType, + PathMapped: this._pathMapped, + Permissions: permissions, + EnabledByOwner: enabled_by_owner, + EnabledByUser: (this._edit && this._share) ? this._share.EnabledByUser : null, + HiddenByOwner: hidden_by_owner, + HiddenByUser: (this._edit && this._share) ? this._share.HiddenByUser : null, + Properties: properties, + User: (this._edit && this._share) ? this._share.User : this._shareuser_input.value, + PathOrToken: (this._edit && this._share) ? this._share.PathOrToken : (this._shareType === "map" ? "/" + this._shareuser_input.value + "/" + this._sharehref_input.value + "/" : ""), + Conversion: conversion_value, + }); + + if (this._edit) { + if (this._shareType === "map") { + update_share_by_map(this._user, this._password, new_share, callback); + } else { + update_share_by_token(this._user, this._password, new_share, callback); + } } else { - permissions_ro_radio.disabled = false; - permissions_rw_radio.disabled = false; - } - if (shareType === "map") { - map_validator.validate(); + if (this._shareType === "map") { + add_share_by_map(this._user, this._password, new_share, callback); + } else { + add_share_by_token(this._user, this._password, new_share, callback); + } } + } catch (err) { + console.error(err); } + return false; + } - function oncancel() { - try { - pop_scene(); - } catch (err) { - console.error(err); + show() { + this.release(); + this._html_scene.classList.remove("hidden"); + this._html_scene.querySelectorAll("details").forEach(function (details) { + if (details.dataset.name !== "properties_override") { + details.open = true; + } else { + details.open = false; } - return false; - } + }); + this._cancel_btn.onclick = () => this._oncancel(); + this._form.onsubmit = () => this._onsubmit(); - function onsubmit() { - try { - if (shareType === "map") { - if (!map_validator.validate()) { - return false; - } - } - let conversion = get_selected_conversion(); - let is_birthday = conversion === "bday"; - let enabled_by_owner = is_birthday ? true : enabled_checkbox.checked; - let hidden_by_owner = is_birthday ? false : hidden_checkbox.checked; - let permissions = is_birthday ? "r" : (permissions_rw_radio.checked ? "rw" : "r"); - /** @type {string} */ let conversion_value = conversion; + /** @type {HTMLHeadingElement} */ let title = /** @type {HTMLHeadingElement} */ (get_element(this._html_scene, "h1")); + title.textContent = this._edit ? "Edit Share" : "New Share"; + this._submit_btn.textContent = this._edit ? "Save" : "Create"; - /** @type {Object} */ let properties = {}; - if (displayname_override_enabled.checked) { - let key = get_property_key(collection.type, "DISPLAYNAME"); - if (key) properties[key] = displayname_override_input.value; - } - if (description_override_enabled.checked) { - let key = get_property_key(collection.type, "DESCRIPTION"); - if (key) properties[key] = description_override_input.value; - } - if (color_override_enabled.checked) { - let key = get_property_key(collection.type, "COLOR"); - if (key) properties[key] = color_override_input.value + (color_override_input.value ? "ff" : ""); - } + this._shareuser_input.value = (this._edit && this._share) ? this._share.User : ""; + this._shareuser_input.disabled = this._edit; + this._enabled_checkbox.checked = (this._edit && this._share && this._share.EnabledByOwner !== null) ? this._share.EnabledByOwner : true; + this._hidden_checkbox.checked = (this._edit && this._share && this._share.HiddenByOwner !== null) ? this._share.HiddenByOwner : false; - let callback = function (/** @type {?string} */ error) { - if (!is_current_scene(self)) { - return; - } - if (error) { - errorHandler.setError(error); + let initial_conversion = (this._edit && this._share && this._share.Conversion) ? this._share.Conversion : "none"; + + collectionsCache.getServerFeatures(this._user, this._password, this._errorHandler.setError, (features) => { + this._conversions_container.innerHTML = ""; + let supported = (features.sharing && features.sharing.SupportedConversions) || []; + if (supported.length > 0) { + let options = ["none", ...supported.filter(opt => opt !== "none")]; + options.forEach(opt => { + let id = "newshare_conv_" + opt; + let radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "conversion"; + radio.value = opt; + radio.id = id; + radio.checked = (opt === initial_conversion); + radio.onchange = () => this._on_conversion_change(); + + let label = document.createElement("label"); + label.htmlFor = id; + if (opt === "bday") { + label.textContent = "Birthday"; } else { - // On any share-to-self (currently only bday conversion), invalidate the - // collections cache so the virtual calendar appears immediately. - if (shareuser_input.value === user) { - collectionsCache.invalidate(); - } - pop_scene(); + label.textContent = opt.charAt(0).toUpperCase() + opt.slice(1); } - }; - let new_share = new Share({ - ShareType: shareType, - PathMapped: pathMapped, - Permissions: permissions, - EnabledByOwner: enabled_by_owner, - EnabledByUser: (edit && share) ? share.EnabledByUser : null, - HiddenByOwner: hidden_by_owner, - HiddenByUser: (edit && share) ? share.HiddenByUser : null, - Properties: properties, - User: (edit && share) ? share.User : shareuser_input.value, - PathOrToken: (edit && share) ? share.PathOrToken : (shareType === "map" ? "/" + shareuser_input.value + "/" + sharehref_input.value + "/" : ""), - Conversion: conversion_value, + this._conversions_container.appendChild(radio); + this._conversions_container.appendChild(label); }); - if (edit) { - if (shareType === "map") { - update_share_by_map(user, password, new_share, callback); - } else { - update_share_by_token(user, password, new_share, callback); - } + let is_addressbook = this._collection.type === CollectionType.ADDRESSBOOK; + if (is_addressbook) { + this._conversions_details.classList.remove("hidden"); + this._conversions_details.open = true; } else { - if (shareType === "map") { - add_share_by_map(user, password, new_share, callback); - } else { - add_share_by_token(user, password, new_share, callback); - } + this._conversions_details.classList.add("hidden"); } - } catch (err) { - console.error(err); + } else { + this._conversions_details.classList.add("hidden"); + } + this._on_conversion_change(); + }); + + let displayname = this._collection.displayname || ""; + let description = this._collection.description || ""; + let color = this._collection.color || "#ffffff"; + let displayname_override_enabled_value = false; + let description_override_enabled_value = false; + let color_override_enabled_value = false; + + if (this._edit && this._share && this._share.Properties) { + let displayname_key = get_property_key(this._collection.type, "DISPLAYNAME"); + if (displayname_key && this._share.Properties[displayname_key] !== undefined) { + displayname = this._share.Properties[displayname_key]; + displayname_override_enabled_value = true; + } + let description_key = get_property_key(this._collection.type, "DESCRIPTION"); + if (description_key && this._share.Properties[description_key] !== undefined) { + description = this._share.Properties[description_key]; + description_override_enabled_value = true; + } + let color_key = get_property_key(this._collection.type, "COLOR"); + if (color_key && this._share.Properties[color_key] !== undefined) { + color = this._share.Properties[color_key]; + if (color.length === 9 && color.endsWith("ff")) { + color = color.substring(0, 7); + } + color_override_enabled_value = true; } - return false; } - this.show = function () { - this.release(); - html_scene.classList.remove("hidden"); - html_scene.querySelectorAll("details").forEach(function (details) { - if (details.dataset.name !== "properties_override") { - details.open = true; - } else { - details.open = false; - } - }); - cancel_btn.onclick = oncancel; - form.onsubmit = onsubmit; + this._displayname_override_enabled.checked = displayname_override_enabled_value; + this._displayname_override_input.value = displayname; + this._displayname_override_input.disabled = !displayname_override_enabled_value; - /** @type {HTMLHeadingElement} */ let title = /** @type {HTMLHeadingElement} */ (get_element(html_scene, "h1")); - title.textContent = edit ? "Edit Share" : "New Share"; - submit_btn.textContent = edit ? "Save" : "Create"; + this._description_override_enabled.checked = description_override_enabled_value; + this._description_override_input.value = description; + this._description_override_input.disabled = !description_override_enabled_value; - shareuser_input.value = (edit && share) ? share.User : ""; - shareuser_input.disabled = edit; - enabled_checkbox.checked = (edit && share && share.EnabledByOwner !== null) ? share.EnabledByOwner : true; - hidden_checkbox.checked = (edit && share && share.HiddenByOwner !== null) ? share.HiddenByOwner : false; + this._color_override_enabled.checked = color_override_enabled_value; + this._color_override_input.value = color; + this._color_override_input.disabled = !color_override_enabled_value; - let initial_conversion = (edit && share && share.Conversion) ? share.Conversion : "none"; + this._properties_fieldset.classList.remove("hidden"); + if (displayname_override_enabled_value || description_override_enabled_value || color_override_enabled_value) { + this._properties_fieldset.open = true; + } - collectionsCache.getServerFeatures(user, password, errorHandler.setError, (features) => { - conversions_container.innerHTML = ""; - let supported = (features.sharing && features.sharing.SupportedConversions) || []; - if (supported.length > 0) { - let options = ["none", ...supported.filter(opt => opt !== "none")]; - options.forEach(opt => { - let id = "newshare_conv_" + opt; - let radio = document.createElement("input"); - radio.type = "radio"; - radio.name = "conversion"; - radio.value = opt; - radio.id = id; - radio.checked = (opt === initial_conversion); - radio.onchange = on_conversion_change; - - let label = document.createElement("label"); - label.htmlFor = id; - if (opt === "bday") { - label.textContent = "Birthday"; - } else { - label.textContent = opt.charAt(0).toUpperCase() + opt.slice(1); - } - - conversions_container.appendChild(radio); - conversions_container.appendChild(label); - }); - - let is_addressbook = collection.type === CollectionType.ADDRESSBOOK; - if (is_addressbook) { - conversions_details.classList.remove("hidden"); - conversions_details.open = true; - } else { - conversions_details.classList.add("hidden"); - } - } else { - conversions_details.classList.add("hidden"); - } - on_conversion_change(); - }); - - let displayname = collection.displayname || ""; - let description = collection.description || ""; - let color = collection.color || "#ffffff"; - let displayname_override_enabled_value = false; - let description_override_enabled_value = false; - let color_override_enabled_value = false; - - if (edit && share && share.Properties) { - let displayname_key = get_property_key(collection.type, "DISPLAYNAME"); - if (displayname_key && share.Properties[displayname_key] !== undefined) { - displayname = share.Properties[displayname_key]; - displayname_override_enabled_value = true; - } - let description_key = get_property_key(collection.type, "DESCRIPTION"); - if (description_key && share.Properties[description_key] !== undefined) { - description = share.Properties[description_key]; - description_override_enabled_value = true; - } - let color_key = get_property_key(collection.type, "COLOR"); - if (color_key && share.Properties[color_key] !== undefined) { - color = share.Properties[color_key]; - if (color.length === 9 && color.endsWith("ff")) { - color = color.substring(0, 7); - } - color_override_enabled_value = true; - } + let displayname_key = get_property_key(this._collection.type, "DISPLAYNAME"); + if (!displayname_key) { + if (this._displayname_override_enabled.parentElement) { + this._displayname_override_enabled.parentElement.classList.add("hidden"); } - - displayname_override_enabled.checked = displayname_override_enabled_value; - displayname_override_input.value = displayname; - displayname_override_input.disabled = !displayname_override_enabled_value; - - description_override_enabled.checked = description_override_enabled_value; - description_override_input.value = description; - description_override_input.disabled = !description_override_enabled_value; - - color_override_enabled.checked = color_override_enabled_value; - color_override_input.value = color; - color_override_input.disabled = !color_override_enabled_value; - - properties_fieldset.classList.remove("hidden"); - if (displayname_override_enabled_value || description_override_enabled_value || color_override_enabled_value) { - properties_fieldset.open = true; + } else { + if (this._displayname_override_enabled.parentElement) { + this._displayname_override_enabled.parentElement.classList.remove("hidden"); } + } + let description_key = get_property_key(this._collection.type, "DESCRIPTION"); + if (!description_key) { + if (this._description_override_enabled.parentElement) { + this._description_override_enabled.parentElement.classList.add("hidden"); + } + } else { + if (this._description_override_enabled.parentElement) { + this._description_override_enabled.parentElement.classList.remove("hidden"); + } + } - let displayname_key = get_property_key(collection.type, "DISPLAYNAME"); - if (!displayname_key) { - if (displayname_override_enabled.parentElement) { - displayname_override_enabled.parentElement.classList.add("hidden"); - } + let color_key = get_property_key(this._collection.type, "COLOR"); + if (!color_key) { + if (this._color_override_enabled.parentElement) { + this._color_override_enabled.parentElement.classList.add("hidden"); + } + } else { + if (this._color_override_enabled.parentElement) { + this._color_override_enabled.parentElement.classList.remove("hidden"); + } + } + + if (this._shareType === "map") { + if (this._edit && this._share) { + this._sharehref_input.value = this._share.PathOrToken.split("/").filter(Boolean).pop() || ""; } else { - if (displayname_override_enabled.parentElement) { - displayname_override_enabled.parentElement.classList.remove("hidden"); - } + this._sharehref_input.value = random_uuid(); } - let description_key = get_property_key(collection.type, "DESCRIPTION"); - if (!description_key) { - if (description_override_enabled.parentElement) { - description_override_enabled.parentElement.classList.add("hidden"); - } - } else { - if (description_override_enabled.parentElement) { - description_override_enabled.parentElement.classList.remove("hidden"); - } - } - - let color_key = get_property_key(collection.type, "COLOR"); - if (!color_key) { - if (color_override_enabled.parentElement) { - color_override_enabled.parentElement.classList.add("hidden"); - } - } else { - if (color_override_enabled.parentElement) { - color_override_enabled.parentElement.classList.remove("hidden"); - } - } - - if (shareType === "map") { - if (edit && share) { - sharehref_input.value = share.PathOrToken.split("/").filter(Boolean).pop() || ""; - } else { - sharehref_input.value = random_uuid(); - } - sharehref_input.disabled = edit; - sharemapfields.classList.remove("hidden"); - map_validator.validate(); - } else { - sharehref_input.value = ""; - sharemapfields.classList.add("hidden"); - errorHandler.clearError(); - } - }; - - this.hide = function () { - html_scene.classList.add("hidden"); - cancel_btn.onclick = null; - form.onsubmit = null; - }; - - this.release = function () { - }; - this.is_transient = function () { return false; }; + this._sharehref_input.disabled = this._edit; + this._sharemapfields.classList.remove("hidden"); + this._map_validator.validate(); + } else { + this._sharehref_input.value = ""; + this._sharemapfields.classList.add("hidden"); + this._errorHandler.clearError(); + } } + + hide() { + this._html_scene.classList.add("hidden"); + this._cancel_btn.onclick = null; + this._form.onsubmit = null; + } + + release() { + } + + is_transient() { return false; } } diff --git a/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js b/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js index e2f9ffa7..fe71ed4c 100644 --- a/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js +++ b/radicale/web/internal_data/js/scenes/DeleteConfirmationScene.js @@ -42,104 +42,115 @@ export class DeleteConfirmationScene { * @param {function} [on_success] */ constructor(user, password, header_title, item, item_title, delete_action, needsconfirmation, on_success) { - /** @type {HTMLElement} */ let html_scene = get_element_by_id("deleteconfirmationscene"); - /** @type {HTMLElement} */ let header_html = get_element(html_scene, "[data-name=headertitle]"); + this._user = user; + this._password = password; + this._item = item; + this._item_title = item_title; + this._delete_action = delete_action; + this._needsconfirmation = needsconfirmation; + this._on_success = on_success; + + this._html_scene = get_element_by_id("deleteconfirmationscene"); + /** @type {HTMLElement} */ let header_html = get_element(this._html_scene, "[data-name=headertitle]"); if (header_html) header_html.textContent = header_title; - /** @type {HTMLElement} */ let title_form = get_element(html_scene, "[data-name=title]"); - /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); - /** @type {HTMLElement} */ let confirmation_prompt = get_element(html_scene, "[data-name=confirmationprompt]"); - /** @type {HTMLInputElement} */ let confirmation_txt = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=confirmationtxt]")); - /** @type {HTMLElement} */ let delete_confirmation_lbl = get_element(html_scene, "[data-name=deleteconfirmationtext]"); - /** @type {HTMLElement} */ let delete_btn = get_element(html_scene, "[data-name=delete]"); - /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); + this._title_form = get_element(this._html_scene, "[data-name=title]"); + this._error_form = get_element(this._html_scene, "[data-name=error]"); + this._confirmation_prompt = get_element(this._html_scene, "[data-name=confirmationprompt]"); + this._confirmation_txt = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=confirmationtxt]")); + this._delete_confirmation_lbl = get_element(this._html_scene, "[data-name=deleteconfirmationtext]"); + this._delete_btn = get_element(this._html_scene, "[data-name=delete]"); + this._cancel_btn = get_element(this._html_scene, "[data-name=cancel]"); if (needsconfirmation) { - delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT; - confirmation_txt.value = ""; - confirmation_txt.addEventListener("keydown", onkeydown); - confirmation_prompt.classList.remove("hidden"); - confirmation_txt.classList.remove("hidden"); + this._delete_confirmation_lbl.innerHTML = DELETE_CONFIRMATION_TEXT; + this._confirmation_txt.value = ""; + this._confirmation_txt.addEventListener("keydown", (e) => this._onkeydown(e)); + this._confirmation_prompt.classList.remove("hidden"); + this._confirmation_txt.classList.remove("hidden"); } else { - confirmation_prompt.classList.add("hidden"); - confirmation_txt.classList.add("hidden"); - confirmation_txt.removeEventListener("keydown", onkeydown); + this._confirmation_prompt.classList.add("hidden"); + this._confirmation_txt.classList.add("hidden"); } - /** @type {?XMLHttpRequest} */ let delete_req = null; + /** @type {?XMLHttpRequest} */ this._delete_req = null; - let errorHandler = new ErrorHandler(error_form); - let validator = new FormValidator(errorHandler); + this._errorHandler = new ErrorHandler(this._error_form); + this._validator = new FormValidator(this._errorHandler); if (needsconfirmation) { - validator.addValidator(confirmation_txt, validate_equals(confirmation_txt, DELETE_CONFIRMATION_TEXT, "confirmation")); + this._validator.addValidator(this._confirmation_txt, validate_equals(this._confirmation_txt, DELETE_CONFIRMATION_TEXT, "confirmation")); } + } - function ondelete() { - if (needsconfirmation && !validator.validate()) { - return false; - } - try { - let loading_scene = new LoadingScene(); - push_scene(loading_scene); - delete_req = delete_action(user, password, item, function (/** @type {?string} */ error1) { - if (!is_current_scene(loading_scene)) { - return; - } - delete_req = null; - if (error1) { - pop_scene(); - errorHandler.setError(error1); + _ondelete() { + if (this._needsconfirmation && !this._validator.validate()) { + return false; + } + try { + let loading_scene = new LoadingScene(); + push_scene(loading_scene); + this._delete_req = this._delete_action(this._user, this._password, this._item, (/** @type {?string} */ error1) => { + if (!is_current_scene(loading_scene)) { + return; + } + this._delete_req = null; + if (error1) { + pop_scene(); + this._errorHandler.setError(error1); + } else { + pop_scene(); + if (this._on_success) { + this._on_success(); } else { + collectionsCache.invalidate(); pop_scene(); - if (on_success) { - on_success(); - } else { - collectionsCache.invalidate(); - pop_scene(); - } } - }); - } catch (err) { - console.error(err); - } - return false; + } + }); + } catch (err) { + console.error(err); } + return false; + } - function oncancel() { - try { - pop_scene(); - } catch (err) { - console.error(err); - } - return false; + _oncancel() { + try { + pop_scene(); + } catch (err) { + console.error(err); } + return false; + } - function onkeydown(/** @type {KeyboardEvent}*/ event) { - if (event.code !== "Enter") { - return; - } - ondelete(); + /** @param {KeyboardEvent} event */ + _onkeydown(event) { + if (event.code !== "Enter") { + return; } + this._ondelete(); + } - this.show = function () { - this.release(); - html_scene.classList.remove("hidden"); - title_form.textContent = item_title; - delete_btn.onclick = ondelete; - cancel_btn.onclick = oncancel; - validator.validate(); - }; - this.hide = function () { - html_scene.classList.add("hidden"); - cancel_btn.onclick = null; - delete_btn.onclick = null; - }; - this.is_transient = function () { return false; }; - this.release = function () { - if (delete_req !== null) { - delete_req.abort(); - delete_req = null; - } - }; + show() { + this.release(); + this._html_scene.classList.remove("hidden"); + this._title_form.textContent = this._item_title; + this._delete_btn.onclick = () => this._ondelete(); + this._cancel_btn.onclick = () => this._oncancel(); + this._validator.validate(); + } + + hide() { + this._html_scene.classList.add("hidden"); + this._cancel_btn.onclick = null; + this._delete_btn.onclick = null; + } + + is_transient() { return false; } + + release() { + if (this._delete_req !== null) { + this._delete_req.abort(); + this._delete_req = null; + } } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js index fad42a41..e45c2aac 100644 --- a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js +++ b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js @@ -35,137 +35,136 @@ export class IncomingSharingScene { * @param {?string} password */ constructor(user, password) { - /** @type {HTMLElement} */ let html_scene = get_element_by_id("incomingsharingscene"); - /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); - /** @type {HTMLElement} */ let error_element = get_element(html_scene, "[data-name=error]"); - /** @type {HTMLElement} */ let tbody = get_element(html_scene, "tbody[data-name=incomingsharesbody]"); - /** @type {HTMLElement} */ let template = get_element(tbody, "[data-name=incomingsharerowtemplate]"); - /** @type {HTMLElement} */ let table = get_element(html_scene, "table"); - /** @type {HTMLElement} */ let noshares_message = get_element(html_scene, "[data-name=nosharesmessage]"); + this._user = user; + this._password = password; - let error_handler = new ErrorHandler(error_element); + this._html_scene = get_element_by_id("incomingsharingscene"); + this._cancel_btn = get_element(this._html_scene, "[data-name=cancel]"); + this._error_element = get_element(this._html_scene, "[data-name=error]"); + this._tbody = get_element(this._html_scene, "tbody[data-name=incomingsharesbody]"); + this._template = get_element(this._tbody, "[data-name=incomingsharerowtemplate]"); + this._table = get_element(this._html_scene, "table"); + this._noshares_message = get_element(this._html_scene, "[data-name=nosharesmessage]"); - /** @type {Array} */ let nodes = []; + this._error_handler = new ErrorHandler(this._error_element); - function on_cancel() { - pop_scene(); - } + /** @type {Array} */ this._nodes = []; + } - /** - * @param {import("../api/sharing.js").Share} share - * @param {HTMLElement} node - */ - function toggle_share(share, node) { - let enabled_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=enabled]")); - let shown_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=shown]")); + /** + * @param {import("../api/sharing.js").Share} share + * @param {HTMLElement} node + */ + _toggle_share(share, node) { + let enabled_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=enabled]")); + let shown_cb = /** @type {HTMLInputElement} */ (node.querySelector("[data-name=shown]")); - // disable checkboxes while updating - enabled_cb.disabled = true; - shown_cb.disabled = true; + enabled_cb.disabled = true; + shown_cb.disabled = true; - let old_enabled = share.EnabledByUser || false; - let old_hidden = share.HiddenByUser || false; + let old_enabled = share.EnabledByUser || false; + let old_hidden = share.HiddenByUser || false; - share.EnabledByUser = enabled_cb.checked; - share.HiddenByUser = !shown_cb.checked; - error_handler.clearError(); + share.EnabledByUser = enabled_cb.checked; + share.HiddenByUser = !shown_cb.checked; + this._error_handler.clearError(); - update_incoming_share(user, password, share, function (error) { - enabled_cb.disabled = false; - shown_cb.disabled = !share.EnabledByUser; + update_incoming_share(this._user, this._password, share, (error) => { + enabled_cb.disabled = false; + shown_cb.disabled = !share.EnabledByUser; - if (error) { - error_handler.setError(error); - enabled_cb.checked = old_enabled; - shown_cb.checked = !old_hidden; - shown_cb.disabled = !old_enabled; - } else { - collectionsCache.invalidate(); - } - }); - } - - /** - * @param {import("../api/sharing.js").Share[]} shares - */ - function render_shares(shares) { - // clear old nodes - nodes.forEach(function (node) { - if (node.parentNode) { - node.parentNode.removeChild(node); - } - }); - nodes = []; - - let prefix = "/" + user + "/"; - let filtered_shares = shares.filter( - share => (share.ShareType === "map") - && share.PathOrToken.startsWith(prefix)); - - if (filtered_shares.length === 0) { - table.classList.add("hidden"); - noshares_message.classList.remove("hidden"); + if (error) { + this._error_handler.setError(error); + enabled_cb.checked = old_enabled; + shown_cb.checked = !old_hidden; + shown_cb.disabled = !old_enabled; } else { - table.classList.remove("hidden"); - noshares_message.classList.add("hidden"); + collectionsCache.invalidate(); + } + }); + } + + /** + * @param {import("../api/sharing.js").Share[]} shares + */ + _render_shares(shares) { + // clear old nodes + this._nodes.forEach(function (node) { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + }); + this._nodes = []; + + let prefix = "/" + this._user + "/"; + let filtered_shares = shares.filter( + share => (share.ShareType === "map") + && share.PathOrToken.startsWith(prefix)); + + if (filtered_shares.length === 0) { + this._table.classList.add("hidden"); + this._noshares_message.classList.remove("hidden"); + } else { + this._table.classList.remove("hidden"); + this._noshares_message.classList.add("hidden"); + } + + filtered_shares.forEach((share) => { + let node = /** @type {HTMLElement} */(this._template.cloneNode(true)); + node.classList.remove("hidden"); + + let pathortoken = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=pathortoken]")); + let owner_td = get_element(node, "[data-name=owner]"); + let permissions_td = /** @type {HTMLElement} */ (get_element(node, "[data-name=permissions]")); + let enabled_cb = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=enabled]")); + let shown_cb = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=shown]")); + + let displayPath = share.PathOrToken.substring(prefix.length); + if (displayPath.endsWith("/")) { + displayPath = displayPath.substring(0, displayPath.length - 1); } - filtered_shares.forEach(function (share) { - let node = /** @type {HTMLElement} */(template.cloneNode(true)); - node.classList.remove("hidden"); + pathortoken.value = displayPath; + owner_td.textContent = share.Owner; + displayPermissionsOrConversion(share.Conversion, share.Permissions, permissions_td); - let pathortoken = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=pathortoken]")); - let owner_td = get_element(node, "[data-name=owner]"); - let permissions_td = /** @type {HTMLElement} */ (get_element(node, "[data-name=permissions]")); - let enabled_cb = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=enabled]")); - let shown_cb = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=shown]")); + let enabled = share.EnabledByUser !== null ? share.EnabledByUser : true; + let shown = share.HiddenByUser !== null ? !share.HiddenByUser : true; - let displayPath = share.PathOrToken.substring(prefix.length); - if (displayPath.endsWith("/")) { - displayPath = displayPath.substring(0, displayPath.length - 1); - } + enabled_cb.checked = enabled; + shown_cb.checked = shown; + shown_cb.disabled = !enabled; - pathortoken.value = displayPath; - owner_td.textContent = share.Owner; - displayPermissionsOrConversion(share.Conversion, share.Permissions, permissions_td); + enabled_cb.onchange = () => { this._toggle_share(share, node); }; + shown_cb.onchange = () => { this._toggle_share(share, node); }; - let enabled = share.EnabledByUser !== null ? share.EnabledByUser : true; - let shown = share.HiddenByUser !== null ? !share.HiddenByUser : true; + this._nodes.push(node); + this._tbody.appendChild(node); + }); + } - enabled_cb.checked = enabled; - shown_cb.checked = shown; - shown_cb.disabled = !enabled; + show() { + this._html_scene.classList.remove("hidden"); + this._cancel_btn.onclick = () => pop_scene(); + this._error_handler.clearError(); + collectionsCache.getIncomingShares(this._user, this._password, this._error_handler.setError, (shares) => this._render_shares(shares)); + } - enabled_cb.onchange = function () { toggle_share(share, node); }; - shown_cb.onchange = function () { toggle_share(share, node); }; + hide() { + this._html_scene.classList.add("hidden"); + this._cancel_btn.onclick = null; + this._error_handler.clearError(); + } - nodes.push(node); - tbody.appendChild(node); - }); - } + is_transient() { return false; } - this.show = function () { - html_scene.classList.remove("hidden"); - cancel_btn.onclick = on_cancel; - error_handler.clearError(); - collectionsCache.getIncomingShares(user, password, error_handler.setError, render_shares); - }; - - this.hide = function () { - html_scene.classList.add("hidden"); - cancel_btn.onclick = null; - error_handler.clearError(); - }; - - this.is_transient = function () { return false; }; - this.release = function () { - error_handler.clearError(); - nodes.forEach(function (node) { - if (node.parentNode) { - node.parentNode.removeChild(node); - } - }); - nodes = []; - }; + release() { + this._error_handler.clearError(); + this._nodes.forEach(function (node) { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + }); + this._nodes = []; } } diff --git a/radicale/web/internal_data/js/scenes/LoginScene.js b/radicale/web/internal_data/js/scenes/LoginScene.js index ca5f33de..6d75ccaa 100644 --- a/radicale/web/internal_data/js/scenes/LoginScene.js +++ b/radicale/web/internal_data/js/scenes/LoginScene.js @@ -31,166 +31,167 @@ import { LoadingScene } from "./LoadingScene.js"; import { Scene, is_current_scene, pop_scene, pop_to_root, push_scene, replace_scene } from "./scene_manager.js"; /** - * @constructor * @implements {Scene} */ export class LoginScene { constructor() { - /** @type {HTMLElement} */ let html_scene = get_element_by_id("loginscene"); - /** @type {HTMLElement} */ let form = get_element(html_scene, "[data-name=form]"); - /** @type {HTMLInputElement} */ let user_form = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=user]")); - /** @type {HTMLInputElement} */ let password_form = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=password]")); - /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); - /** @type {HTMLElement} */ let logout_view = get_element_by_id("logoutview"); - /** @type {HTMLElement} */ let logout_user_form = get_element(logout_view, "[data-name=user]"); - /** @type {HTMLElement} */ let logout_btn = get_element(logout_view, "[data-name=logout]"); - /** @type {HTMLElement} */ let refresh_btn = get_element(logout_view, "[data-name=refresh]"); + this._html_scene = get_element_by_id("loginscene"); + this._form = get_element(this._html_scene, "[data-name=form]"); + this._user_form = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=user]")); + this._password_form = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=password]")); + this._error_form = get_element(this._html_scene, "[data-name=error]"); + this._logout_view = get_element_by_id("logoutview"); + this._logout_user_form = get_element(this._logout_view, "[data-name=user]"); + this._logout_btn = get_element(this._logout_view, "[data-name=logout]"); + this._refresh_btn = get_element(this._logout_view, "[data-name=refresh]"); - let user = ""; - /** @type {?XMLHttpRequest} */ let principal_req = null; - let errorHandler = new ErrorHandler(error_form); - let validator = new FormValidator(errorHandler); - validator.addValidator(user_form, validate_non_empty(user_form, "Username")); + this._user = ""; + /** @type {?XMLHttpRequest} */ this._principal_req = null; + this._errorHandler = new ErrorHandler(this._error_form); + this._validator = new FormValidator(this._errorHandler); + this._validator.addValidator(this._user_form, validate_non_empty(this._user_form, "Username")); + } - function read_form() { - user = user_form.value; + _read_form() { + this._user = this._user_form.value; + } + + _fill_form() { + this._user_form.value = this._user; + this._password_form.value = ""; + } + + /** + * @param {string} p_user + * @param {?string} p_password + */ + _perform_login(p_user, p_password) { + this._user = p_user; + this._fill_form(); + // setup logout + this._logout_view.classList.remove("hidden"); + if (p_password === null) { + this._logout_btn.classList.add("hidden"); + } else { + this._logout_btn.classList.remove("hidden"); } - - function fill_form() { - user_form.value = user; - password_form.value = ""; - } - - /** - * @param {string} p_user - * @param {?string} p_password - */ - function perform_login(p_user, p_password) { - user = p_user; - fill_form(); - // setup logout - logout_view.classList.remove("hidden"); - if (p_password === null) { - logout_btn.classList.add("hidden"); - } else { - logout_btn.classList.remove("hidden"); + this._logout_btn.onclick = () => this._onlogout(); + this._refresh_btn.onclick = () => this._refresh(); + this._logout_user_form.textContent = this._user + "'s Collections"; + // Fetch principal + let loading_scene = new LoadingScene(); + push_scene(loading_scene); + this._principal_req = get_principal(this._user, p_password, (/** @type {?Collection} */ principal_collection, error1) => { + if (!is_current_scene(loading_scene)) { + return; } - logout_btn.onclick = onlogout; - refresh_btn.onclick = refresh; - logout_user_form.textContent = user + "'s Collections"; - // Fetch principal - let loading_scene = new LoadingScene(); - push_scene(loading_scene); - principal_req = get_principal(user, p_password, function (/** @type {?Collection} */ principal_collection, error1) { - if (!is_current_scene(loading_scene)) { - return; - } - principal_req = null; - if (error1) { - errorHandler.setError(error1); - pop_scene(); - } else if (principal_collection) { - // show collections - let saved_user = user; - user = ""; - let collections_scene = new CollectionsScene( - saved_user, p_password, principal_collection, function (/** @type {?string} */ error1) { - errorHandler.setError(error1); - user = saved_user; - }); - replace_scene(collections_scene); - } - }); - } - - function onlogin() { - try { - collectionsCache.invalidate(); - read_form(); - let password = password_form.value; - if (!validator.validate()) { - return false; - } - perform_login(user, password); - } catch (err) { - console.error(err); - } - return false; - } - - let onlogout = function () { - try { - collectionsCache.invalidate(); - user = ""; - pop_to_root(); - } catch (err) { - console.error(err); - } - return false; - }; - - - function remove_logout() { - logout_view.classList.add("hidden"); - logout_btn.onclick = null; - refresh_btn.onclick = null; - logout_user_form.textContent = ""; - } - - function refresh() { - // The easiest way to refresh is to push a LoadingScene onto the stack and then pop it - // forcing the scene below it, the Collections Scene to refresh itself. - collectionsCache.invalidate(); - push_scene(new LoadingScene()); - pop_scene(); - } - - this.show = function () { - remove_logout(); - fill_form(); - form.onsubmit = onlogin; - html_scene.classList.remove("hidden"); - user_form.focus(); - - // Probe for existing authentication (e.g. X-Remote-User) - // Use fetch with credentials: 'omit' to avoid browser login prompt on 401 - fetch(SERVER + ROOT_PATH, { - method: 'PROPFIND', - headers: { 'Depth': '0' }, - credentials: 'omit' - }).then(function (response) { - if (response.ok) { - // Authenticated! Now it's safe to call get_principal - get_principal(null, null, function (/** @type {?Collection} */ principal_collection, error) { - if (!error && principal_collection) { - let authenticated_user = principal_collection.displayname; - if (!authenticated_user) { - let href = principal_collection.href.replace(/\/+$/, ""); - if (href && href !== ROOT_PATH.replace(/\/+$/, "")) { - authenticated_user = href.substring(href.lastIndexOf("/") + 1); - } - } - perform_login(authenticated_user, null); - } + this._principal_req = null; + if (error1) { + this._errorHandler.setError(error1); + pop_scene(); + } else if (principal_collection) { + // show collections + let saved_user = this._user; + this._user = ""; + let collections_scene = new CollectionsScene( + saved_user, p_password, principal_collection, (/** @type {?string} */ error1) => { + this._errorHandler.setError(error1); + this._user = saved_user; }); - } - })["catch"](function () { - // Ignore error: we are not authenticated or something else went wrong - }); - }; - this.hide = function () { - read_form(); - html_scene.classList.add("hidden"); - form.onsubmit = null; - }; - this.is_transient = function () { return false; }; - this.release = function () { - // cancel pending requests - if (principal_req !== null) { - principal_req.abort(); - principal_req = null; + replace_scene(collections_scene); } - remove_logout(); - }; + }); + } + + _onlogin() { + try { + collectionsCache.invalidate(); + this._read_form(); + let password = this._password_form.value; + if (!this._validator.validate()) { + return false; + } + this._perform_login(this._user, password); + } catch (err) { + console.error(err); + } + return false; + } + + _onlogout() { + try { + collectionsCache.invalidate(); + this._user = ""; + pop_to_root(); + } catch (err) { + console.error(err); + } + return false; + } + + _remove_logout() { + this._logout_view.classList.add("hidden"); + this._logout_btn.onclick = null; + this._refresh_btn.onclick = null; + this._logout_user_form.textContent = ""; + } + + _refresh() { + // The easiest way to refresh is to push a LoadingScene onto the stack and then pop it + // forcing the scene below it, the Collections Scene to refresh itself. + collectionsCache.invalidate(); + push_scene(new LoadingScene()); + pop_scene(); + } + + show() { + this._remove_logout(); + this._fill_form(); + this._form.onsubmit = () => this._onlogin(); + this._html_scene.classList.remove("hidden"); + this._user_form.focus(); + + // Probe for existing authentication (e.g. X-Remote-User) + // Use fetch with credentials: 'omit' to avoid browser login prompt on 401 + fetch(SERVER + ROOT_PATH, { + method: 'PROPFIND', + headers: { 'Depth': '0' }, + credentials: 'omit' + }).then((response) => { + if (response.ok) { + // Authenticated! Now it's safe to call get_principal + get_principal(null, null, (/** @type {?Collection} */ principal_collection, error) => { + if (!error && principal_collection) { + let authenticated_user = principal_collection.displayname; + if (!authenticated_user) { + let href = principal_collection.href.replace(/\/+$/, ""); + if (href && href !== ROOT_PATH.replace(/\/+$/, "")) { + authenticated_user = href.substring(href.lastIndexOf("/") + 1); + } + } + this._perform_login(authenticated_user, null); + } + }); + } + })["catch"](function () { + // Ignore error: we are not authenticated or something else went wrong + }); + } + + hide() { + this._read_form(); + this._html_scene.classList.add("hidden"); + this._form.onsubmit = null; + } + + is_transient() { return false; } + + release() { + // cancel pending requests + if (this._principal_req !== null) { + this._principal_req.abort(); + this._principal_req = null; + } + this._remove_logout(); } } \ No newline at end of file diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js index 3c1ea910..b377c601 100644 --- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js @@ -43,91 +43,96 @@ export class ShareCollectionScene { * @param {Collection} collection The collection on which to edit sharing setting. Must exist. */ constructor(user, password, collection) { + this._user = user; + this._password = password; + this._collection = collection; - let html_scene = get_element_by_id("sharecollectionscene"); + this._html_scene = get_element_by_id("sharecollectionscene"); + this._cancel_btn = get_element(this._html_scene, "[data-name=cancel]"); + this._share_by_token_btn = get_element(this._html_scene, "button[data-name=sharebytoken]"); + this._share_by_map_btn = get_element(this._html_scene, "button[data-name=sharebymap]"); + this._share_by_token_div = get_element(this._html_scene, "div[data-name=sharebytoken]"); + this._share_by_map_div = get_element(this._html_scene, "div[data-name=sharebymap]"); + this._error_form = get_element(this._html_scene, "[data-name=error]"); - /** @type {HTMLElement} */ let cancel_btn = get_element(html_scene, "[data-name=cancel]"); - /** @type {HTMLElement} */ let share_by_token_btn = get_element(html_scene, "button[data-name=sharebytoken]"); - /** @type {HTMLElement} */ let share_by_map_btn = get_element(html_scene, "button[data-name=sharebymap]"); - /** @type {HTMLElement} */ let share_by_token_div = get_element(html_scene, "div[data-name=sharebytoken]"); - /** @type {HTMLElement} */ let share_by_map_div = get_element(html_scene, "div[data-name=sharebymap]"); - /** @type {HTMLElement} */ let error_form = get_element(html_scene, "[data-name=error]"); + this._errorHandler = new ErrorHandler(this._error_form); - let errorHandler = new ErrorHandler(error_form); - - /** @type {HTMLElement} */ let title = get_element(html_scene, "[data-name=title]"); - - function oncancel() { - try { - pop_scene(); - } catch (err) { - console.error(err); - } - return false; - } - - function onsharebytoken() { - let create_edit_share_scene = new CreateEditShareScene(user, password, collection, "token"); - push_scene(create_edit_share_scene); - } - - function onsharebymap() { - let create_edit_share_scene = new CreateEditShareScene(user, password, collection, "map"); - push_scene(create_edit_share_scene); - } - - this.show = function () { - this.release(); - html_scene.classList.remove("hidden"); - html_scene.querySelectorAll("details").forEach(function (details) { - details.open = true; - }); - cancel_btn.onclick = oncancel; - - collectionsCache.getServerFeatures(user, password, errorHandler.setError, (features) => { - if (features.sharing && features.sharing.PermittedCreateCollectionByToken) { - if (share_by_token_btn) { - share_by_token_btn.classList.remove("hidden"); - share_by_token_btn.onclick = onsharebytoken; - } - } else { - if (share_by_token_btn) share_by_token_btn.classList.add("hidden"); - } - - if (features.sharing && features.sharing.FeatureEnabledCollectionByToken) { - if (share_by_token_div) share_by_token_div.classList.remove("hidden"); - } else { - if (share_by_token_div) share_by_token_div.classList.add("hidden"); - } - - if (features.sharing && features.sharing.PermittedCreateCollectionByMap) { - if (share_by_map_btn) { - share_by_map_btn.classList.remove("hidden"); - share_by_map_btn.onclick = onsharebymap; - } - } else { - if (share_by_map_btn) share_by_map_btn.classList.add("hidden"); - } - - if (features.sharing && features.sharing.FeatureEnabledCollectionByMap) { - if (share_by_map_div) share_by_map_div.classList.remove("hidden"); - } else { - if (share_by_map_div) share_by_map_div.classList.add("hidden"); - } - - }); - - title.textContent = collection.displayname || collection.href; - update_share_list(user, password, collection, errorHandler); - }; - this.hide = function () { - html_scene.classList.add("hidden"); - cancel_btn.onclick = null; - }; - this.release = function () { - }; - this.is_transient = function () { return false; }; + this._title = get_element(this._html_scene, "[data-name=title]"); } + + _oncancel() { + try { + pop_scene(); + } catch (err) { + console.error(err); + } + return false; + } + + _onsharebytoken() { + let create_edit_share_scene = new CreateEditShareScene(this._user, this._password, this._collection, "token"); + push_scene(create_edit_share_scene); + } + + _onsharebymap() { + let create_edit_share_scene = new CreateEditShareScene(this._user, this._password, this._collection, "map"); + push_scene(create_edit_share_scene); + } + + show() { + this.release(); + this._html_scene.classList.remove("hidden"); + this._html_scene.querySelectorAll("details").forEach(function (details) { + details.open = true; + }); + this._cancel_btn.onclick = () => this._oncancel(); + + collectionsCache.getServerFeatures(this._user, this._password, this._errorHandler.setError, (features) => { + if (features.sharing && features.sharing.PermittedCreateCollectionByToken) { + if (this._share_by_token_btn) { + this._share_by_token_btn.classList.remove("hidden"); + this._share_by_token_btn.onclick = () => this._onsharebytoken(); + } + } else { + if (this._share_by_token_btn) this._share_by_token_btn.classList.add("hidden"); + } + + if (features.sharing && features.sharing.FeatureEnabledCollectionByToken) { + if (this._share_by_token_div) this._share_by_token_div.classList.remove("hidden"); + } else { + if (this._share_by_token_div) this._share_by_token_div.classList.add("hidden"); + } + + if (features.sharing && features.sharing.PermittedCreateCollectionByMap) { + if (this._share_by_map_btn) { + this._share_by_map_btn.classList.remove("hidden"); + this._share_by_map_btn.onclick = () => this._onsharebymap(); + } + } else { + if (this._share_by_map_btn) this._share_by_map_btn.classList.add("hidden"); + } + + if (features.sharing && features.sharing.FeatureEnabledCollectionByMap) { + if (this._share_by_map_div) this._share_by_map_div.classList.remove("hidden"); + } else { + if (this._share_by_map_div) this._share_by_map_div.classList.add("hidden"); + } + + }); + + this._title.textContent = this._collection.displayname || this._collection.href; + update_share_list(this._user, this._password, this._collection, this._errorHandler); + } + + hide() { + this._html_scene.classList.add("hidden"); + this._cancel_btn.onclick = null; + } + + release() { + } + + is_transient() { return false; } } /** diff --git a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js index 95845868..10f0375f 100644 --- a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js @@ -37,202 +37,208 @@ export class UploadCollectionScene { * @param {Collection} collection parent collection */ constructor(user, password, collection) { - /** @type {HTMLElement} */ let html_scene = get_element_by_id("uploadcollectionscene"); - /** @type {HTMLElement} */ let template = get_element(html_scene, "[data-name=filetemplate]"); - /** @type {HTMLElement} */ let upload_btn = get_element(html_scene, "[data-name=submit]"); - /** @type {HTMLElement} */ let close_btn = get_element(html_scene, "[data-name=close]"); - /** @type {HTMLInputElement} */ let uploadfile_form = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=uploadfile]")); - /** @type {HTMLElement} */ let uploadfile_lbl = get_element(html_scene, "label[for=uploadfile]"); - /** @type {HTMLInputElement} */ let href_form = /** @type {HTMLInputElement} */ (get_element(html_scene, "[data-name=href]")); - /** @type {HTMLElement} */ let href_label = get_element(html_scene, "label[for=href]"); - /** @type {HTMLElement} */ let hreflimitmsg_html = get_element(html_scene, "[data-name=hreflimitmsg]"); - /** @type {HTMLElement} */ let pending_html = get_element(html_scene, "[data-name=pending]"); - /** @type {HTMLElement} */ let error_form = get_element(html_scene, ":scope > span[data-name=error]"); + this._user = user; + this._password = password; + this._collection = collection; - let files = uploadfile_form.files; - href_form.addEventListener("input", onCleanHREFinput); - upload_btn.onclick = upload_start; - uploadfile_form.onchange = onfileschange; + this._html_scene = get_element_by_id("uploadcollectionscene"); + this._template = get_element(this._html_scene, "[data-name=filetemplate]"); + this._upload_btn = get_element(this._html_scene, "[data-name=submit]"); + this._close_btn = get_element(this._html_scene, "[data-name=close]"); + this._uploadfile_form = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=uploadfile]")); + this._uploadfile_lbl = get_element(this._html_scene, "label[for=uploadfile]"); + this._href_form = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=href]")); + this._href_label = get_element(this._html_scene, "label[for=href]"); + this._hreflimitmsg_html = get_element(this._html_scene, "[data-name=hreflimitmsg]"); + this._pending_html = get_element(this._html_scene, "[data-name=pending]"); + this._error_form = get_element(this._html_scene, ":scope > span[data-name=error]"); - href_form.value = ""; - let href = ""; + this._files = this._uploadfile_form.files; + this._href_form.addEventListener("input", onCleanHREFinput); + this._upload_btn.onclick = () => this._upload_start(); + this._uploadfile_form.onchange = () => this._onfileschange(); - let errorHandler = new ErrorHandler(error_form); - let validator = new FormValidator(errorHandler); + this._href_form.value = ""; + this._href = ""; - validator.addValidator(href_form, validate_href(href_form, "HREF")); - validator.addValidator(uploadfile_form, validate_files(uploadfile_form, "file")); + this._errorHandler = new ErrorHandler(this._error_form); + this._validator = new FormValidator(this._errorHandler); - /** @type {?XMLHttpRequest} */ let upload_req = null; - /** @type {Array} */ let results = []; - /** @type {?Array} */ let nodes = null; + this._validator.addValidator(this._href_form, validate_href(this._href_form, "HREF")); + this._validator.addValidator(this._uploadfile_form, validate_files(this._uploadfile_form, "file")); - function upload_start() { - try { - if (!validator.validate()) { - return false; - } - if (!files) { - return false; - } - read_form(); - uploadfile_form.classList.add("hidden"); - uploadfile_lbl.classList.add("hidden"); - href_form.classList.add("hidden"); - href_label.classList.add("hidden"); - hreflimitmsg_html.classList.add("hidden"); - upload_btn.classList.add("hidden"); - close_btn.classList.add("hidden"); + /** @type {?XMLHttpRequest} */ this._upload_req = null; + /** @type {Array} */ this._results = []; + /** @type {?Array} */ this._nodes = null; + } - pending_html.classList.remove("hidden"); - - nodes = []; - for (let i = 0; i < files.length; i++) { - let file = files[i]; - let node = /** @type {HTMLElement} */ (template.cloneNode(true)); - node.classList.remove("hidden"); - let name_form = get_element(node, "[data-name=name]"); - name_form.textContent = file.name; - node.classList.remove("hidden"); - nodes.push(node); - updateFileStatus(i); - if (template.parentNode) { - template.parentNode.insertBefore(node, template); - } - } - upload_next(); - } catch (err) { - console.error(err); + _upload_start() { + try { + if (!this._validator.validate()) { + return false; } - return false; - } + if (!this._files) { + return false; + } + this._read_form(); + this._uploadfile_form.classList.add("hidden"); + this._uploadfile_lbl.classList.add("hidden"); + this._href_form.classList.add("hidden"); + this._href_label.classList.add("hidden"); + this._hreflimitmsg_html.classList.add("hidden"); + this._upload_btn.classList.add("hidden"); + this._close_btn.classList.add("hidden"); - function upload_next() { - if (!files) { + this._pending_html.classList.remove("hidden"); + + this._nodes = []; + for (let i = 0; i < this._files.length; i++) { + let file = this._files[i]; + let node = /** @type {HTMLElement} */ (this._template.cloneNode(true)); + node.classList.remove("hidden"); + let name_form = get_element(node, "[data-name=name]"); + name_form.textContent = file.name; + node.classList.remove("hidden"); + this._nodes.push(node); + this._updateFileStatus(i); + if (this._template.parentNode) { + this._template.parentNode.insertBefore(node, this._template); + } + } + this._upload_next(); + } catch (err) { + console.error(err); + } + return false; + } + + _upload_next() { + if (!this._files) { + return; + } + try { + if (this._files.length === this._results.length) { + this._pending_html.classList.add("hidden"); + this._close_btn.classList.remove("hidden"); return; - } - try { - if (files.length === results.length) { - pending_html.classList.add("hidden"); - close_btn.classList.remove("hidden"); - return; - } else { - let file = files[results.length]; - if (files.length > 1 || href.length == 0) { - href = random_uuid(); - } - let upload_href = collection.href + href + "/"; - upload_req = upload_collection(user, password, upload_href, file, function (result) { - upload_req = null; - results.push(result); - updateFileStatus(results.length - 1); - upload_next(); - }); - } - } catch (err) { - console.error(err); - } - } - - function onclose() { - try { - if (results.length > 0) { - collectionsCache.invalidate(); - } - pop_scene(); - } catch (err) { - console.error(err); - } - return false; - } - - /** - * @param {number} i - */ - function updateFileStatus(i) { - if (nodes === null) { - return; - } - /** @type {HTMLElement} */ let file_success_form = get_element(nodes[i], "[data-name=success]"); - /** @type {HTMLElement} */ let file_error_form = get_element(nodes[i], "[data-name=error]"); - if (results.length > i) { - if (results[i]) { - file_success_form.classList.add("hidden"); - file_error_form.textContent = "Error: " + results[i]; - error_form.classList.remove("hidden"); - } else { - file_success_form.classList.remove("hidden"); - file_error_form.classList.add("hidden"); - } } else { + let file = this._files[this._results.length]; + if (this._files.length > 1 || this._href.length == 0) { + this._href = random_uuid(); + } + let upload_href = this._collection.href + this._href + "/"; + this._upload_req = upload_collection(this._user, this._password, upload_href, file, (result) => { + this._upload_req = null; + this._results.push(result); + this._updateFileStatus(this._results.length - 1); + this._upload_next(); + }); + } + } catch (err) { + console.error(err); + } + } + + _onclose() { + try { + if (this._results.length > 0) { + collectionsCache.invalidate(); + } + pop_scene(); + } catch (err) { + console.error(err); + } + return false; + } + + /** + * @param {number} i + */ + _updateFileStatus(i) { + if (this._nodes === null) { + return; + } + /** @type {HTMLElement} */ let file_success_form = get_element(this._nodes[i], "[data-name=success]"); + /** @type {HTMLElement} */ let file_error_form = get_element(this._nodes[i], "[data-name=error]"); + if (this._results.length > i) { + if (this._results[i]) { file_success_form.classList.add("hidden"); + file_error_form.textContent = "Error: " + this._results[i]; + this._error_form.classList.remove("hidden"); + } else { + file_success_form.classList.remove("hidden"); file_error_form.classList.add("hidden"); } + } else { + file_success_form.classList.add("hidden"); + file_error_form.classList.add("hidden"); } + } - function read_form() { - cleanHREFinput(href_form); - href = href_form.value.trim().toLowerCase(); - files = uploadfile_form.files; - return true; - } + _read_form() { + cleanHREFinput(this._href_form); + this._href = this._href_form.value.trim().toLowerCase(); + this._files = this._uploadfile_form.files; + return true; + } - function onfileschange() { - files = uploadfile_form.files; - if (files && files.length > 1) { - hreflimitmsg_html.classList.remove("hidden"); - href_form.classList.add("hidden"); - href_label.classList.add("hidden"); - href_form.value = random_uuid(); // fake HREF, will be replaced on upload + _onfileschange() { + this._files = this._uploadfile_form.files; + if (this._files && this._files.length > 1) { + this._hreflimitmsg_html.classList.remove("hidden"); + this._href_form.classList.add("hidden"); + this._href_label.classList.add("hidden"); + this._href_form.value = random_uuid(); // fake HREF, will be replaced on upload + } else { + this._hreflimitmsg_html.classList.add("hidden"); + this._href_form.classList.remove("hidden"); + this._href_label.classList.remove("hidden"); + if (this._files && this._files.length > 0) { + this._href_form.value = this._files[0].name.replace(/\.(ics|vcf)$/, ''); } else { - hreflimitmsg_html.classList.add("hidden"); - href_form.classList.remove("hidden"); - href_label.classList.remove("hidden"); - if (files && files.length > 0) { - href_form.value = files[0].name.replace(/\.(ics|vcf)$/, ''); - } else { - href_form.value = ""; - } + this._href_form.value = ""; } - return false; } + return false; + } - this.show = function () { - html_scene.classList.remove("hidden"); - close_btn.onclick = onclose; - }; + show() { + this._html_scene.classList.remove("hidden"); + this._close_btn.onclick = () => this._onclose(); + } - this.hide = function () { - html_scene.classList.add("hidden"); - close_btn.classList.remove("hidden"); - upload_btn.classList.remove("hidden"); - uploadfile_form.classList.remove("hidden"); - uploadfile_lbl.classList.remove("hidden"); - href_form.classList.remove("hidden"); - href_label.classList.remove("hidden"); - hreflimitmsg_html.classList.add("hidden"); - pending_html.classList.add("hidden"); - errorHandler.clearError(); - close_btn.onclick = null; - upload_btn.onclick = null; - href_form.value = ""; - uploadfile_form.value = ""; - if (nodes == null) { - return; + hide() { + this._html_scene.classList.add("hidden"); + this._close_btn.classList.remove("hidden"); + this._upload_btn.classList.remove("hidden"); + this._uploadfile_form.classList.remove("hidden"); + this._uploadfile_lbl.classList.remove("hidden"); + this._href_form.classList.remove("hidden"); + this._href_label.classList.remove("hidden"); + this._hreflimitmsg_html.classList.add("hidden"); + this._pending_html.classList.add("hidden"); + this._errorHandler.clearError(); + this._close_btn.onclick = null; + this._upload_btn.onclick = null; + this._href_form.value = ""; + this._uploadfile_form.value = ""; + if (this._nodes == null) { + return; + } + this._nodes.forEach(function (node) { + if (node.parentNode) { + node.parentNode.removeChild(node); } - nodes.forEach(function (node) { - if (node.parentNode) { - node.parentNode.removeChild(node); - } - }); - nodes = null; - }; - this.is_transient = function () { return false; }; - this.release = function () { - if (upload_req !== null) { - upload_req.abort(); - upload_req = null; - } - }; + }); + this._nodes = null; + } + + is_transient() { return false; } + + release() { + if (this._upload_req !== null) { + this._upload_req.abort(); + this._upload_req = null; + } } } \ No newline at end of file From 9688db7e217ad69036831559d3f0eff0eb39f194 Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sat, 28 Mar 2026 23:42:14 +0100 Subject: [PATCH 3/4] UI: Remove leftover hardcodes for bday conversion --- .../js/scenes/CreateEditShareScene.js | 14 ++++++-------- radicale/web/internal_data/js/utils/permissions.js | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js index 6f8e74b3..9b266981 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditShareScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditShareScene.js @@ -76,7 +76,7 @@ export class CreateEditShareScene { this._map_validator.addValidator(this._shareuser_input, () => { let conversion = this._get_selected_conversion(); - if (conversion === "bday") { + if (conversion != "none") { return validate_non_empty(this._shareuser_input, "Share User")(); } else { return validate_not_empty_or_equals(this._shareuser_input, user, "Share User")(); @@ -105,9 +105,7 @@ export class CreateEditShareScene { _on_conversion_change() { let conversion = this._get_selected_conversion(); - if (conversion === "bday") { - this._permissions_ro_radio.checked = true; - this._permissions_rw_radio.checked = false; + if (conversion != "none") { this._permissions_ro_radio.disabled = true; this._permissions_rw_radio.disabled = true; this._enabled_checkbox.checked = true; @@ -138,10 +136,10 @@ export class CreateEditShareScene { } } let conversion = this._get_selected_conversion(); - let is_birthday = conversion === "bday"; - let enabled_by_owner = is_birthday ? true : this._enabled_checkbox.checked; - let hidden_by_owner = is_birthday ? false : this._hidden_checkbox.checked; - let permissions = is_birthday ? "r" : (this._permissions_rw_radio.checked ? "rw" : "r"); + let is_conversion = conversion != "none"; + let enabled_by_owner = is_conversion ? true : this._enabled_checkbox.checked; + let hidden_by_owner = is_conversion ? false : this._hidden_checkbox.checked; + let permissions = is_conversion ? "r" : (this._permissions_rw_radio.checked ? "rw" : "r"); /** @type {string} */ let conversion_value = conversion; /** @type {Object} */ let properties = {}; diff --git a/radicale/web/internal_data/js/utils/permissions.js b/radicale/web/internal_data/js/utils/permissions.js index 517b6e28..209694e4 100644 --- a/radicale/web/internal_data/js/utils/permissions.js +++ b/radicale/web/internal_data/js/utils/permissions.js @@ -28,7 +28,7 @@ export function displayPermissionsOrConversion(conversion, permissions, node) { const roElement = get_element(node, "[data-name=ro]"); const rwElement = get_element(node, "[data-name=rw]"); let fixedConversion = (conversion || "").toLowerCase(); - if (fixedConversion === "bday") { + if (fixedConversion != "none" && fixedConversion != "") { conversionElement.classList.remove("hidden"); roElement.classList.add("hidden"); rwElement.classList.add("hidden"); From 405c1921582dc5d471fc921ad69b991b2947e5ef Mon Sep 17 00:00:00 2001 From: Max Berger Date: Sun, 29 Mar 2026 00:05:17 +0100 Subject: [PATCH 4/4] UI: Fix html/css validation errors --- radicale/web/internal_data/css/main.css | 5 ++-- radicale/web/internal_data/index.html | 24 +++++++++---------- .../js/scenes/CreateEditCollectionScene.js | 2 +- .../js/scenes/UploadCollectionScene.js | 2 +- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index b06e2241..acb75fd6 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -191,7 +191,7 @@ main { #collectionsscene article p { font-size: 1em; max-height: 130px; - overflow: overlay; + overflow: auto; word-wrap: break-word; } @@ -476,7 +476,8 @@ a.blue:active { } button.inline { - padding-inline: 1px; + padding-left: 1px; + padding-right: 1px; min-width: 2em; margin: 0; } diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index 278632c9..d7d6c4ef 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -127,13 +127,13 @@ - + - + - + - +

@@ -309,15 +309,15 @@ - + - + - + - + - +

@@ -342,9 +342,9 @@
- - - + + + diff --git a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js index f0a8c92a..18583c81 100644 --- a/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/CreateEditCollectionScene.js @@ -53,7 +53,7 @@ export class CreateEditCollectionScene { this._displayname_form = /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=displayname]")); this._description_form = /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=description]")); this._source_form = /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=source]")); - this._source_label = get_element(this._html_scene, "label[for=source]"); + this._source_label = get_element(this._html_scene, "label[for$=source]"); this._type_form = /** @type {HTMLSelectElement} */(get_element(this._html_scene, "[data-name=type]")); this._color_form = /** @type {HTMLInputElement} */(get_element(this._html_scene, "[data-name=color]")); this._submit_btn = get_element(this._html_scene, "[data-name=submit]"); diff --git a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js index 10f0375f..b89e2f7f 100644 --- a/radicale/web/internal_data/js/scenes/UploadCollectionScene.js +++ b/radicale/web/internal_data/js/scenes/UploadCollectionScene.js @@ -48,7 +48,7 @@ export class UploadCollectionScene { this._uploadfile_form = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=uploadfile]")); this._uploadfile_lbl = get_element(this._html_scene, "label[for=uploadfile]"); this._href_form = /** @type {HTMLInputElement} */ (get_element(this._html_scene, "[data-name=href]")); - this._href_label = get_element(this._html_scene, "label[for=href]"); + this._href_label = get_element(this._html_scene, "label[for$=href]"); this._hreflimitmsg_html = get_element(this._html_scene, "[data-name=hreflimitmsg]"); this._pending_html = get_element(this._html_scene, "[data-name=pending]"); this._error_form = get_element(this._html_scene, ":scope > span[data-name=error]");