diff --git a/integ_tests/test_sharing_login.py b/integ_tests/test_sharing_login.py
index fad534be..b8bdaefd 100644
--- a/integ_tests/test_sharing_login.py
+++ b/integ_tests/test_sharing_login.py
@@ -19,6 +19,7 @@ Integration tests for sharing (login/logout specific)
"""
import pathlib
+import re
from typing import Any, Generator
import pytest
@@ -71,7 +72,7 @@ def test_incoming_shares(page: Page, radicale_server: str, permissions: str) ->
page.locator(
"tr[data-name='incomingsharerowtemplate']:not(.hidden) input[data-name='pathortoken']"
)
- ).to_have_value("mapped")
+ ).to_have_value(re.compile(r".*mapped/"))
# 5. Max enables and shows the share
# Initially, it's disabled and not shown (security by default)
diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css
index acb75fd6..403244b4 100644
--- a/radicale/web/internal_data/css/main.css
+++ b/radicale/web/internal_data/css/main.css
@@ -412,6 +412,65 @@ input:focus-visible {
border-width: 1px !important;
}
+.url-input-wrapper {
+ position: relative;
+ width: 100%;
+ margin-bottom: 0;
+ display: flex;
+ align-items: center;
+ clear: both;
+}
+
+.url-input-wrapper input {
+ flex: 1;
+ min-width: 0;
+ padding-right: 45px !important;
+ box-sizing: border-box;
+ width: 100% !important;
+ margin-bottom: 0 !important;
+}
+
+#collectionsscene article .url-input-wrapper {
+ margin-bottom: 15px;
+}
+
+.copy-btn {
+ flex: none;
+ margin-left: -38px;
+ background: transparent;
+ border: none;
+ color: #2494fe;
+ font-size: 1.25em;
+ cursor: pointer;
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: background-color 0.2s, color 0.2s, transform 0.1s;
+ line-height: 1;
+ z-index: 2;
+ float: none;
+ margin-bottom: 0;
+ margin-top: 0;
+ margin-right: 8px;
+}
+
+.copy-btn:hover {
+ background-color: #f0f7ff;
+ color: #055fb5;
+}
+
+.copy-btn:active {
+ transform: scale(0.9);
+}
+
+.copy-btn.copied {
+ color: #4e9a06;
+}
+
input[type=radio],
input[type=checkbox] {
width: auto;
@@ -507,21 +566,21 @@ button.inline {
}
.pill {
- display: inline-block;
- padding: 0.25em 0.6em;
- font-size: 75%;
- font-weight: 700;
- line-height: 1;
- text-align: center;
- white-space: nowrap;
- vertical-align: baseline;
- border-radius: 0.375rem;
- background-color: #6c757d;
- color: #fff;
- margin: 0 2px;
- width: 1.4em;
+ display: inline-block;
+ padding: 0.25em 0.6em;
+ font-size: 75%;
+ font-weight: 700;
+ line-height: 1;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ border-radius: 0.375rem;
+ background-color: #6c757d;
+ color: #fff;
+ margin: 0 2px;
+ width: 1.4em;
}
.hidden {
display: none !important;
-}
+}
\ No newline at end of file
diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html
index d7d6c4ef..baa242c7 100644
--- a/radicale/web/internal_data/index.html
+++ b/radicale/web/internal_data/index.html
@@ -71,7 +71,7 @@
Title
👥 shared by >
- Address book ⟿
+ Address book ⟿
Address book
Calendar, journal and tasks
Calendar and journal
@@ -83,7 +83,10 @@
Webcal
-
+
+
+
+
Description
-
@@ -155,18 +158,23 @@
|
-
|
rwro⟿ |
- |
- |
+
+
|
- 
|
@@ -181,19 +189,24 @@
- 
|
rwro⟿ |
- |
-
+
+ ⧉
+
+ |
+
+
|
-
+
|
@@ -223,7 +236,12 @@
- |
+
+
+
+ ⧉
+
+ |
|
rwro⟿ |
diff --git a/radicale/web/internal_data/js/main.js b/radicale/web/internal_data/js/main.js
index 025f131a..478d269d 100644
--- a/radicale/web/internal_data/js/main.js
+++ b/radicale/web/internal_data/js/main.js
@@ -22,10 +22,7 @@
import { LoadingScene } from "./scenes/LoadingScene.js";
import { LoginScene } from "./scenes/LoginScene.js";
import { push_scene } from "./scenes/scene_manager.js";
-import { setupSelectAll } from "./utils/misc.js";
-// Add selection handler for input fields with 'selectall' class.
-setupSelectAll();
// Hide startup loading message
// This works because the LoadingScene is the one that is already active in index.html,
diff --git a/radicale/web/internal_data/js/scenes/CollectionsScene.js b/radicale/web/internal_data/js/scenes/CollectionsScene.js
index 8c6c37d9..8bd5e11b 100644
--- a/radicale/web/internal_data/js/scenes/CollectionsScene.js
+++ b/radicale/web/internal_data/js/scenes/CollectionsScene.js
@@ -21,7 +21,6 @@
import { delete_collection } from "../api/api.js";
import { get_auth_header } from "../api/common.js";
-import { SERVER } from "../constants.js";
import { Collection, CollectionType } from "../models/collection.js";
import { collectionsCache } from "../utils/collections_cache.js";
import { ErrorHandler } from "../utils/error.js";
@@ -30,6 +29,7 @@ import { CreateEditCollectionScene } from "./CreateEditCollectionScene.js";
import { DeleteConfirmationScene } from "./DeleteConfirmationScene.js";
import { IncomingSharingScene } from "./IncomingSharingScene.js";
import { Scene, push_scene } from "./scene_manager.js";
+import { UrlTextHandler } from "../utils/url_text.js";
import { ShareCollectionScene, maybe_enable_sharing_options } from "./ShareCollectionScene.js";
import { UploadCollectionScene } from "./UploadCollectionScene.js";
@@ -168,6 +168,7 @@ export class CollectionsScene {
/** @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]"));
+ /** @type {HTMLButtonElement} */ let copy_btn = /** @type {HTMLButtonElement} */ (get_element(node, "[data-name=copy-url]"));
if (collection.color) {
color_form.style.background = collection.color;
}
@@ -225,8 +226,8 @@ export class CollectionsScene {
}
contentcount_form.textContent = contentcount_form_txt;
}
- let href = SERVER + collection.href;
- url_form.value = href;
+ let href = collection.href;
+ new UrlTextHandler(url_form, copy_btn).setHref(href);
download_btn.href = href;
download_btn.onclick = (event) => {
event.preventDefault();
diff --git a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js
index e45c2aac..02207fff 100644
--- a/radicale/web/internal_data/js/scenes/IncomingSharingScene.js
+++ b/radicale/web/internal_data/js/scenes/IncomingSharingScene.js
@@ -25,6 +25,7 @@ import { ErrorHandler } from "../utils/error.js";
import { get_element, get_element_by_id } from "../utils/misc.js";
import { displayPermissionsOrConversion } from "../utils/permissions.js";
import { Scene, pop_scene } from "./scene_manager.js";
+import { UrlTextHandler } from "../utils/url_text.js";
/**
* @implements {Scene}
@@ -118,13 +119,9 @@ export class IncomingSharingScene {
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 copy_btn = /** @type {HTMLButtonElement} */ (get_element(node, "[data-name=copy-url]"));
- let displayPath = share.PathOrToken.substring(prefix.length);
- if (displayPath.endsWith("/")) {
- displayPath = displayPath.substring(0, displayPath.length - 1);
- }
-
- pathortoken.value = displayPath;
+ new UrlTextHandler(pathortoken, copy_btn).setHref(share.PathOrToken);
owner_td.textContent = share.Owner;
displayPermissionsOrConversion(share.Conversion, share.Permissions, permissions_td);
@@ -132,7 +129,9 @@ export class IncomingSharingScene {
let shown = share.HiddenByUser !== null ? !share.HiddenByUser : true;
enabled_cb.checked = enabled;
+ enabled_cb.setAttribute("title", "Enabled");
shown_cb.checked = shown;
+ shown_cb.setAttribute("title", "Shown");
shown_cb.disabled = !enabled;
enabled_cb.onchange = () => { this._toggle_share(share, node); };
diff --git a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js
index b377c601..186e6eac 100644
--- a/radicale/web/internal_data/js/scenes/ShareCollectionScene.js
+++ b/radicale/web/internal_data/js/scenes/ShareCollectionScene.js
@@ -32,6 +32,7 @@ import { displayPermissionsOrConversion } from "../utils/permissions.js";
import { CreateEditShareScene } from "./CreateEditShareScene.js";
import { DeleteConfirmationScene } from "./DeleteConfirmationScene.js";
import { Scene, pop_scene, push_scene } from "./scene_manager.js";
+import { UrlTextHandler } from "../utils/url_text.js";
/**
* @implements {Scene}
@@ -180,8 +181,9 @@ function add_share_row_node(user, password, collection, share, template, delete_
node.classList.remove("hidden");
/** @type {HTMLInputElement} */ let pathortoken_form = /** @type {HTMLInputElement} */ (get_element(node, "[data-name=pathortoken]"));
+ /** @type {HTMLButtonElement} */ let copy_btn = /** @type {HTMLButtonElement} */ (get_element(node, "[data-name=copy-url]"));
if (pathortoken_form) {
- pathortoken_form.value = pathortoken;
+ new UrlTextHandler(pathortoken_form, copy_btn).setHref(pathortoken);
}
let permissions = (share["Permissions"] || "").toLowerCase();
diff --git a/radicale/web/internal_data/js/utils/misc.js b/radicale/web/internal_data/js/utils/misc.js
index 3014041f..df7b7a37 100644
--- a/radicale/web/internal_data/js/utils/misc.js
+++ b/radicale/web/internal_data/js/utils/misc.js
@@ -105,16 +105,6 @@ export function bytesToHumanReadable(bytes) {
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + units[i];
}
-/**
- * Add selection handler for input fields with 'selectall' class.
- */
-export function setupSelectAll() {
- document.addEventListener("focusin", (event) => {
- if (event.target instanceof HTMLInputElement && event.target.classList.contains("selectall")) {
- event.target.setSelectionRange(0, 99999);
- }
- });
-}
/**
* Get an element by its ID and throw an error if it's not found.
diff --git a/radicale/web/internal_data/js/utils/permissions.js b/radicale/web/internal_data/js/utils/permissions.js
index 209694e4..82f6bd05 100644
--- a/radicale/web/internal_data/js/utils/permissions.js
+++ b/radicale/web/internal_data/js/utils/permissions.js
@@ -30,16 +30,19 @@ export function displayPermissionsOrConversion(conversion, permissions, node) {
let fixedConversion = (conversion || "").toLowerCase();
if (fixedConversion != "none" && fixedConversion != "") {
conversionElement.classList.remove("hidden");
+ conversionElement.setAttribute("title", "Converted");
roElement.classList.add("hidden");
rwElement.classList.add("hidden");
} else {
permissions = (permissions || "").toLowerCase();
if (permissions === "rw") {
rwElement.classList.remove("hidden");
+ rwElement.setAttribute("title", "Read and write");
roElement.classList.add("hidden");
conversionElement.classList.add("hidden");
} else if (permissions === "r") {
roElement.classList.remove("hidden");
+ roElement.setAttribute("title", "Read-only");
rwElement.classList.add("hidden");
conversionElement.classList.add("hidden");
} else {
diff --git a/radicale/web/internal_data/js/utils/url_text.js b/radicale/web/internal_data/js/utils/url_text.js
new file mode 100644
index 00000000..aa86b637
--- /dev/null
+++ b/radicale/web/internal_data/js/utils/url_text.js
@@ -0,0 +1,85 @@
+/**
+ * This file is part of Radicale Server - Calendar Server
+ * Copyright © 2026-2026 Max Berger
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * Utilities for resource URL boxes
+ */
+import { SERVER } from "../constants.js";
+
+/**
+ * Handles the display of URLs in input fields.
+ */
+export class UrlTextHandler {
+ /**
+ * @param {HTMLInputElement} element The input element to handle.
+ * @param {HTMLButtonElement} copyButton The button element for copying.
+ */
+ constructor(element, copyButton) {
+ this._element = element;
+ this._copyButton = copyButton;
+
+ this._element.addEventListener("focusin", () => {
+ this._element.setSelectionRange(0, 99999);
+ });
+
+ if (this._copyButton) {
+ this._copyButton.onclick = () => this._oncopy();
+ }
+ }
+
+ /**
+ * Copy the current value of the input to the clipboard.
+ */
+ _oncopy() {
+ if (!this._element.value) return;
+
+ navigator.clipboard.writeText(this._element.value).then(() => {
+ if (this._copyButton) {
+ this._copyButton.classList.add("copied");
+ this._copyButton.title = "Copied!";
+ setTimeout(() => {
+ this._copyButton.classList.remove("copied");
+ this._copyButton.title = "copy";
+ }, 1500);
+ }
+ }).catch(err => {
+ console.error("Could not copy text: ", err);
+ });
+ }
+
+ /**
+ * Set the value of the input field to the given href.
+ * If the href is relative, it will be prefixed with the server URL.
+ * @param {string} href The href to set.
+ */
+ setHref(href) {
+ if (href.startsWith("/")) {
+ this._element.value = SERVER + href;
+ } else if (!href.includes("://")) {
+ // Handle cases where the href might not start with a slash
+ this._element.value = SERVER + "/" + href;
+ } else {
+ this._element.value = href;
+ }
+
+ // Needs timeout to work correctly.
+ setTimeout(() => {
+ this._element.scrollLeft = this._element.scrollWidth;
+ }, 0);
+ }
+}